diff --git a/server/routes/news.js b/server/routes/news.js index d2f22f3..9202aa0 100644 --- a/server/routes/news.js +++ b/server/routes/news.js @@ -82,7 +82,7 @@ router.get('/', requireSession, withProject(), async (req, res) => { } }) -router.post('/', requireSession, rejectViewAs, limitWrites, withProject(), async (req, res) => { +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) @@ -131,7 +131,7 @@ router.get('/image/:projectId/:file', requireSession, async (req, res) => { } }) -router.patch('/:id', requireSession, rejectViewAs, limitWrites, withProject(), async (req, res) => { +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) @@ -140,7 +140,7 @@ router.patch('/:id', requireSession, rejectViewAs, limitWrites, withProject(), a } }) -router.delete('/:id', requireSession, rejectViewAs, limitWrites, withProject(), async (req, res) => { +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() diff --git a/server/routes/publicNews.js b/server/routes/publicNews.js index 20d51e9..379ae3b 100644 --- a/server/routes/publicNews.js +++ b/server/routes/publicNews.js @@ -19,10 +19,16 @@ 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() - for (const [key, entry] of hits) if (now - entry.start >= WINDOW_MS) hits.delete(key) + // 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) @@ -34,12 +40,12 @@ function rateLimited(ip) { router.use((req, res, next) => { res.set({ 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', + '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') return res.status(405).json({ error: 'Methode nicht erlaubt' }) + 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.' }) @@ -67,7 +73,7 @@ router.get('/:siteKey', async (req, res) => { 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, + 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, }) diff --git a/server/services/newsPosts.js b/server/services/newsPosts.js index 8155183..af1b58d 100644 --- a/server/services/newsPosts.js +++ b/server/services/newsPosts.js @@ -191,7 +191,9 @@ export async function publicPage(projectId, page, now = new Date()) { const slice = hanging.slice((n - 1) * PAGE_SIZE, n * PAGE_SIZE) const posts = [] for (const p of slice) { - const image = p.imageId ? await readNewsImageMeta(projectId, p.imageId) : null + const meta = p.imageId ? await readNewsImageMeta(projectId, p.imageId) : null + // v (Zeitstempel der letzten Aenderung) dient der Route als Cache-Buster fuer die Bild-URL + const image = meta ? { ...meta, v: Date.parse(p.updatedAt || p.visibleFrom) } : null posts.push({ id: p.$id, type: p.type, title: p.title, text: p.text, paper: p.paper, date: berlinDay(p.visibleFrom), image }) } return { posts, hasMore: hanging.length > n * PAGE_SIZE } diff --git a/test/newsPosts.test.js b/test/newsPosts.test.js index 648ea66..c53e9d5 100644 --- a/test/newsPosts.test.js +++ b/test/newsPosts.test.js @@ -91,7 +91,8 @@ test('oeffentlich_nur_haengende_ohne_interna', async () => { assert.equal(p1.posts.length, 6) assert.equal(p1.hasMore, true) assert.equal(p1.posts[0].title, 'Mit Foto') - assert.deepEqual(p1.posts[0].image, { w: 40, h: 30 }) + assert.deepEqual({ ...p1.posts[0].image, v: undefined }, { w: 40, h: 30, v: undefined }) + assert.ok(Number.isFinite(p1.posts[0].image.v), 'v = Cache-Buster fuer die Route') assert.equal(p1.posts[0].date, '2026-10-01') assert.equal(p1.posts[1].image, null) for (const p of p1.posts) { diff --git a/test/newsRoutes.test.js b/test/newsRoutes.test.js index 2a935e8..324285a 100644 --- a/test/newsRoutes.test.js +++ b/test/newsRoutes.test.js @@ -96,11 +96,11 @@ test('oeffentlich_cors_cache', async () => { assert.equal(res.headers.get('access-control-allow-origin'), '*') assert.equal(res.headers.get('cache-control'), 'public, max-age=60') const text = await res.text() - assert.ok(!text.includes('c1') && !text.includes('"p1"') && !text.includes('customerId') && !text.includes('projectId')) + assert.ok(!text.includes('"c1"') && !text.includes('"p1"') && !text.includes('customerId') && !text.includes('projectId')) const body = JSON.parse(text) assert.equal(body.posts.length, 1) - assert.equal(body.posts[0].image.src, `/api/public/news/${KEY}/img/${created.body.$id}.webp`) - assert.equal(body.posts[0].image.srcSmall, `/api/public/news/${KEY}/img/${created.body.$id}-s.webp`) + assert.equal(body.posts[0].image.src, `/api/public/news/${KEY}/img/${created.body.$id}.webp?v=${Date.parse(created.body.updatedAt)}`) + assert.equal(body.posts[0].image.srcSmall, `/api/public/news/${KEY}/img/${created.body.$id}-s.webp?v=${Date.parse(created.body.updatedAt)}`) const etag = res.headers.get('etag') assert.match(etag, /^W\//) const again = await call('GET', `/api/public/news/${KEY}`, { cookie: null, headers: { 'if-none-match': etag } }) @@ -140,3 +140,39 @@ test('rate_limit_schreiben', async () => { assert.ok(statuses.slice(0, 30).every((s) => s === 201), statuses.join()) assert.equal(statuses[30], 429) }) + +test('cache_buster_aendert_sich_beim_bildtausch', async () => { + const first = await (await upload('p1')).json() + const created = await post('/api/news', { ...note, type: 'photo', imageId: first.imageId }, { cookie }) + const src = async () => (await (await call('GET', `/api/public/news/${KEY}`, { cookie: null })).json()).posts.find((p) => p.id === created.body.$id).image + const before = await src() + assert.match(before.src, /\.webp\?v=\d+$/) + assert.match(before.srcSmall, /-s\.webp\?v=\d+$/) + await new Promise((r) => setTimeout(r, 15)) + const second = await (await upload('p1')).json() + const res = await call('PATCH', `/api/news/${created.body.$id}`, { body: { ...note, type: 'photo', imageId: second.imageId } }) + assert.equal(res.status, 200) + const now = await src() + assert.notEqual(now.src, before.src) + assert.equal((await call('GET', now.src, { cookie: null })).status, 200) +}) + +test('tippfehler_zaehlt_nicht_gegen_schreiblimit', async () => { + aw.seed('websiteProjects', { $id: 'p4', customerId: 'c3', projectName: 'Tippfehler' }) + feature('p4', 'c3', 'active', 'defghijklmnopqrs') + const c = customerCookie('c3') + for (let i = 0; i < 35; i++) assert.equal((await post('/api/news', { ...note, projectId: 'gibtsnicht' }, { cookie: c })).status, 403) + assert.equal((await post('/api/news', { ...note, projectId: 'p4' }, { cookie: c })).status, 201) +}) + +test('head_erlaubt', async () => { + assert.equal((await call('HEAD', `/api/public/news/${KEY}`, { cookie: null })).status, 200) +}) + +test('oeffentlich_rate_limit_429', async () => { + const ip = { 'x-forwarded-for': '203.0.113.77' } + const statuses = [] + for (let i = 0; i < 121; i++) statuses.push((await call('GET', `/api/public/news/${KEY2}`, { cookie: null, headers: ip })).status) + assert.ok(statuses.slice(0, 120).every((s) => s === 200)) + assert.equal(statuses[120], 429) +})