News: Cache-Buster für Bild-URLs, HEAD, Limiter-Feinschliff
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
319f8937aa
commit
a421308019
5 files changed
+57
-12
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+39
-3
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user