Files
Webklar-Kundenbereich/server/routes/news.js
T
ClaudeandClaude Sonnet 5.5 bb45d35ec7 Terminplaner: Kunden-Schnittstelle – Nachbesserungen
- GET /zeiten?ohne=<Buchung>: Leistung, Dauer und Person der Buchung wie beim
  Verschieben (verschiebeZeiten exportiert), unbekannte/fremde/Sperre 404 (R42)
- Schreib-Begrenzung als gemeinsamer Baustein (writeLimiter.js) fuer News und
  Terminplaner statt kopierter Funktion (R43)
- Unlesbares Einstellungs-JSON: Kontext traegt die echte Version, Speichern
  ueberschreibt den Schaden statt dauerhaft 409
- U+FEFF als Escape statt als rohes Zeichen
- Tests: Mandantentrennung mit echtem zweitem Kunden, R42, Limiter, kaputtes JSON

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00

147 lines
5.7 KiB
JavaScript

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'
import { config } from '../config.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { logIfKundenansicht } from '../services/kundenansicht.js'
import { createWriteLimiter } from '../services/writeLimiter.js'
const router = Router()
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 limitWrites = createWriteLimiter({ max: 30 }).middleware(
(req) => getSessionCustomerId(req),
'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.'
)
// Protokoll-Detail der Kundenansicht: Titel, sonst der Anfang des Textes
const postLabel = (post) => String(post?.title || '').trim() || String(post?.text || '').slice(0, 60)
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, withProject(), limitWrites, async (req, res) => {
try {
const post = await createPost({ customerId: getSessionCustomerId(req), projectId: req.projectId, input: req.body })
await logIfKundenansicht(req, 'news_angelegt', postLabel(post))
res.status(201).json(post)
} catch (err) {
fail(res, err)
}
})
// Foto als rohe Bytes (application/octet-stream), hoechstens 10 MB
router.post(
'/image',
requireSession,
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 })
await logIfKundenansicht(req, 'news_bild')
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, withProject(), limitWrites, async (req, res) => {
try {
const post = await updatePost({ customerId: getSessionCustomerId(req), projectId: req.projectId, id: req.params.id, input: req.body })
await logIfKundenansicht(req, 'news_geaendert', postLabel(post))
res.json(post)
} catch (err) {
fail(res, err)
}
})
router.delete('/:id', requireSession, withProject(), limitWrites, async (req, res) => {
try {
// Titel fuers Protokoll vor dem Loeschen lesen (deletePost prueft den Besitz danach selbst)
const before = req.session.viewId ? await getDocument(config.collections.newsPosts, req.params.id).catch(() => null) : null
await deletePost({ customerId: getSessionCustomerId(req), projectId: req.projectId, id: req.params.id })
await logIfKundenansicht(req, 'news_geloescht', postLabel(before))
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