News: Kunden-API und öffentliche Lese-API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d320730251
commit
319f8937aa
5 files changed
+421
-1
No files matched your search
@@ -0,0 +1,159 @@
|
||||
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, limitWrites, withProject(), 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, limitWrites, withProject(), 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, limitWrites, withProject(), 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
|
||||
Reference in new issue
Block a user