import { createHash } from 'node:crypto' import { Router } from 'express' import { config } from '../config.js' import { getDocument } from '../services/appwriteAdmin.js' import { findActiveBySiteKey } from '../services/siteFeatures.js' import { newsImagePath } from '../services/newsImages.js' import { isHanging, publicPage } from '../services/newsPosts.js' /** * Oeffentliche Lese-API fuer die Pinnwand auf den Kundenwebsites: ohne Session, eigenes CORS (nur GET). * Antworten enthalten nie Kunden-, Projekt-, Ticket- oder Stripe-IDs. */ const router = Router() const WINDOW_MS = 60 * 1000 const MAX_PER_WINDOW = 120 // .webp oder -s.webp (die Bild-ID bleibt intern) const IMAGE_FILE_RE = /^([A-Za-z0-9][A-Za-z0-9._-]*?)(-s)?\.webp$/ // Limit je IP und Minute (im Speicher); die App steht hinter Traefik, 'trust proxy' ist gesetzt -> req.ip ist die Client-IP const hits = new Map() let lastPrune = 0 function rateLimited(ip) { const now = Date.now() // Aufraeumen hoechstens einmal je Fenster if (now - lastPrune >= WINDOW_MS) { lastPrune = now for (const [key, entry] of hits) if (now - entry.start >= WINDOW_MS) hits.delete(key) } let entry = hits.get(ip) if (entry && now - entry.start >= WINDOW_MS) entry = null if (!entry) { entry = { start: now, count: 0 } hits.set(ip, entry) } entry.count += 1 return entry.count > MAX_PER_WINDOW } router.use((req, res, next) => { res.set({ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, If-None-Match', 'Access-Control-Max-Age': '86400', }) if (req.method === 'OPTIONS') return res.sendStatus(204) if (req.method !== 'GET' && req.method !== 'HEAD') return res.status(405).json({ error: 'Methode nicht erlaubt' }) if (rateLimited(req.ip)) { res.set('Retry-After', '60') return res.status(429).json({ error: 'Zu viele Anfragen. Bitte versuch es gleich noch einmal.' }) } return next() }) const EMPTY = { posts: [], hasMore: false } function sendJsonWithEtag(req, res, body) { const json = JSON.stringify(body) const etag = `W/"${createHash('sha1').update(json).digest('base64url')}"` res.set({ ETag: etag, 'Cache-Control': 'public, max-age=60' }) const sent = String(req.get('If-None-Match') || '').split(',').map((s) => s.trim().replace(/^W\//, '')) if (sent.includes(etag.slice(2)) || sent.includes('*')) return res.status(304).end() return res.type('application/json').send(json) } router.get('/:siteKey', async (req, res) => { try { const feature = await findActiveBySiteKey(req.params.siteKey) if (!feature) return sendJsonWithEtag(req, res, EMPTY) const { posts, hasMore } = await publicPage(feature.projectId, req.query.page) const base = `/api/public/news/${req.params.siteKey}/img` return sendJsonWithEtag(req, res, { posts: posts.map((p) => ({ ...p, image: p.image ? { w: p.image.w, h: p.image.h, src: `${base}/${p.id}.webp?v=${p.image.v}`, srcSmall: `${base}/${p.id}-s.webp?v=${p.image.v}` } : null, })), hasMore, }) } catch (err) { console.error('[publicNews]', err.message) return res.status(503).json({ error: 'Vorübergehend nicht erreichbar.' }) } }) router.get('/:siteKey/img/:file', async (req, res) => { try { const match = IMAGE_FILE_RE.exec(req.params.file) if (!match) return res.status(404).end() const [, postId, small] = match const feature = await findActiveBySiteKey(req.params.siteKey) if (!feature) return res.status(404).end() let post = null try { post = await getDocument(config.collections.newsPosts, postId) } catch (err) { if (err.status !== 404 && err.status !== 400) throw err } if (!post || post.projectId !== feature.projectId || !post.imageId || !isHanging(post, new Date())) return res.status(404).end() const file = newsImagePath(feature.projectId, post.imageId, small ? 's' : 'l') if (!file) return res.status(404).end() res.set({ 'Cache-Control': 'public, max-age=86400', 'X-Content-Type-Options': 'nosniff' }) return res.sendFile(file, (err) => { if (err && !res.headersSent) res.status(404).end() }) } catch (err) { console.error('[publicNews]', err.message) return res.status(503).json({ error: 'Vorübergehend nicht erreichbar.' }) } }) export default router