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,106 @@
|
||||
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
|
||||
// <Beitrags-ID>.webp oder <Beitrags-ID>-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()
|
||||
function rateLimited(ip) {
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of hits) if (now - entry.start >= WINDOW_MS) hits.delete(key)
|
||||
let entry = hits.get(ip)
|
||||
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, 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') 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`, srcSmall: `${base}/${p.id}-s.webp` } : 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
|
||||
Reference in new issue
Block a user