import express, { Router } from 'express' import { getSessionCustomerId, requireSession } from '../middleware/session.js' import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js' import { NEWS_IMAGE_MAX_BYTES, newsImagePath, newsImageRateLimited, storeNewsImage } from '../services/newsImages.js' import { createPost, deletePost, listPosts, updatePost } from '../services/newsPosts.js' import { uploadSlot } from './chat.js' const router = Router() const HOUR_MS = 60 * 60 * 1000 const MAX_WRITES_PER_HOUR = 30 const IMAGE_FILE_RE = /^([a-z0-9-]+)-(l|s)\.webp$/ function fail(res, err) { const status = err.status >= 400 && err.status < 600 ? err.status : 500 if (status >= 500) console.error('[news]', err.message) const body = { error: status >= 500 ? 'Interner Serverfehler' : err.message } if (err.field) body.field = err.field return res.status(status).json(body) } // Schreib-Limit je Kunde und Stunde (im Speicher, wie beim Chat); zaehlt POST/PATCH/DELETE, nicht die Foto-Uploads const recentWrites = new Map() function writeRateLimited(customerId) { const now = Date.now() for (const [key, times] of recentWrites) { const live = times.filter((t) => now - t < HOUR_MS) if (live.length === 0) recentWrites.delete(key) else if (live.length !== times.length) recentWrites.set(key, live) } const list = recentWrites.get(customerId) || [] list.push(now) recentWrites.set(customerId, list) return list.length > MAX_WRITES_PER_HOUR } function rejectViewAs(req, res, next) { if (req.session.viewAs) { return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nichts geändert werden.' }) } return next() } function limitWrites(req, res, next) { if (writeRateLimited(getSessionCustomerId(req))) { return res.status(429).json({ error: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.' }) } return next() } const projectIdOf = (req) => { const raw = req.query.projectId ?? req.body?.projectId return typeof raw === 'string' ? raw : '' } /** Eigenes Projekt pruefen (req.projectId, req.project); mit requireActive zusaetzlich die News-Funktion. */ const withProject = ({ requireActive = true } = {}) => async (req, res, next) => { try { const projectId = projectIdOf(req) if (!projectId) return res.status(400).json({ error: 'Bitte wähle ein Projekt.' }) const customerId = getSessionCustomerId(req) req.project = await assertOwnProject(customerId, projectId) req.projectId = projectId if (requireActive) { const feature = await getFeatureDoc(customerId, projectId, 'news') if (feature?.state !== 'active') { return res.status(409).json({ error: 'News ist für diese Website nicht aktiv.' }) } } return next() } catch (err) { return fail(res, err) } } router.get('/', requireSession, withProject(), async (req, res) => { try { const { hanging, expired } = await listPosts(req.projectId) res.json({ hanging, expired, liveUrl: req.project.liveDomain ? `https://${req.project.liveDomain}` : '' }) } catch (err) { fail(res, err) } }) router.post('/', requireSession, rejectViewAs, withProject(), limitWrites, async (req, res) => { try { const post = await createPost({ customerId: getSessionCustomerId(req), projectId: req.projectId, input: req.body }) res.status(201).json(post) } catch (err) { fail(res, err) } }) // Foto als rohe Bytes (application/octet-stream), hoechstens 10 MB router.post( '/image', requireSession, rejectViewAs, withProject(), (req, res, next) => { if (newsImageRateLimited(getSessionCustomerId(req))) { return res.status(429).json({ error: 'Zu viele Fotos in kurzer Zeit. Bitte versuch es später wieder.' }) } return next() }, uploadSlot, express.raw({ type: 'application/octet-stream', limit: NEWS_IMAGE_MAX_BYTES }), async (req, res) => { try { const image = await storeNewsImage({ customerId: getSessionCustomerId(req), projectId: req.projectId, buffer: req.body }) res.status(201).json(image) } catch (err) { fail(res, err) } } ) // Vorschau im Editor: nur fuer den Kunden, dem das Projekt gehoert router.get('/image/:projectId/:file', requireSession, async (req, res) => { try { await assertOwnProject(getSessionCustomerId(req), req.params.projectId) const match = IMAGE_FILE_RE.exec(req.params.file) const file = match && newsImagePath(req.params.projectId, match[1], match[2]) if (!file) return res.status(404).json({ error: 'Foto nicht gefunden.' }) res.set({ 'Cache-Control': 'private, max-age=3600', 'X-Content-Type-Options': 'nosniff' }) return res.sendFile(file, (err) => { if (err && !res.headersSent) res.status(404).json({ error: 'Foto nicht gefunden.' }) }) } catch (err) { return fail(res, err) } }) router.patch('/:id', requireSession, rejectViewAs, withProject(), limitWrites, async (req, res) => { try { const post = await updatePost({ customerId: getSessionCustomerId(req), projectId: req.projectId, id: req.params.id, input: req.body }) res.json(post) } catch (err) { fail(res, err) } }) router.delete('/:id', requireSession, rejectViewAs, withProject(), limitWrites, async (req, res) => { try { await deletePost({ customerId: getSessionCustomerId(req), projectId: req.projectId, id: req.params.id }) res.status(204).end() } catch (err) { fail(res, err) } }) // Zu grosse Fotos verstaendlich melden router.use((err, _req, res, next) => { if (err?.type === 'entity.too.large') return res.status(413).json({ error: 'Das Foto ist größer als 10 MB.' }) if (err?.type === 'request.aborted') return res.status(400).end() return next(err) }) export default router