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
@@ -16,6 +16,7 @@ import funktionenRoutes from './routes/funktionen.js'
|
||||
import takeoverCreditRoutes from './routes/takeoverCredits.js'
|
||||
import { purgeExpiredUploads } from './services/takeoverCredits.js'
|
||||
import { purgePendingChatUploads } from './services/chatAttachments.js'
|
||||
import { purgeUnclaimedNewsImages } from './services/newsImages.js'
|
||||
import { runActivation } from './services/siteFeatures.js'
|
||||
import giteaWebhookRoutes from './routes/webhook/gitea.js'
|
||||
import pushTimeRoutes from './routes/pushTime.js'
|
||||
@@ -25,6 +26,8 @@ import adminCustomersRoutes from './routes/admin/customers.js'
|
||||
import adminEmployeesRoutes from './routes/admin/employees.js'
|
||||
import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js'
|
||||
import adminPreviewOpenRoutes from './routes/admin/previewOpen.js'
|
||||
import newsRoutes from './routes/news.js'
|
||||
import publicNewsRoutes from './routes/publicNews.js'
|
||||
import adminSwitchLinkRoutes from './routes/admin/switchLink.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
@@ -58,6 +61,8 @@ app.use((req, res, next) => {
|
||||
app.use('/api/auth', authRoutes)
|
||||
app.use('/api/projects', projectsRoutes)
|
||||
app.use('/api/features', featuresRoutes)
|
||||
app.use('/api/public/news', publicNewsRoutes)
|
||||
app.use('/api/news', newsRoutes)
|
||||
app.use('/api', customerDataRoutes)
|
||||
app.use('/api/chat', chatRoutes)
|
||||
app.use('/api/abo/takeover-credits', takeoverCreditRoutes)
|
||||
@@ -134,6 +139,14 @@ const server = app.listen(config.port, '0.0.0.0', () => {
|
||||
setTimeout(purgeChat, 90 * 1000).unref()
|
||||
setInterval(purgeChat, 60 * 60 * 1000).unref()
|
||||
|
||||
// News-Fotos, die nie an einen Beitrag gebunden wurden, nach 24 Stunden loeschen (stuendlich)
|
||||
const purgeNewsImages = () =>
|
||||
purgeUnclaimedNewsImages()
|
||||
.then((n) => n && console.log(`[news] ${n} nicht verwendete Fotos gelöscht`))
|
||||
.catch((err) => console.warn('[news] Aufräumen fehlgeschlagen:', err.message))
|
||||
setTimeout(purgeNewsImages, 120 * 1000).unref()
|
||||
setInterval(purgeNewsImages, 60 * 60 * 1000).unref()
|
||||
|
||||
// Funktionen (News-Pinnwand): geschlossene Einrichtungs-Tickets freischalten und den Kunden benachrichtigen (alle 5 Minuten)
|
||||
const activateFeatures = () =>
|
||||
runActivation()
|
||||
|
||||
@@ -43,7 +43,7 @@ const waitingUploads = []
|
||||
function startWaitingUploads() {
|
||||
while (activeUploads < config.chat.maxParallelUploads && waitingUploads.length) waitingUploads.shift()()
|
||||
}
|
||||
function uploadSlot(_req, res, next) {
|
||||
export function uploadSlot(_req, res, next) {
|
||||
let state = 'waiting'
|
||||
// 'close' kommt nach der Antwort oder wenn der Browser abbricht - auch waehrend des Wartens
|
||||
res.once('close', () => {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,142 @@
|
||||
import { test, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import sharp from 'sharp'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
|
||||
process.env.NEWS_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'news-routes-'))
|
||||
const aw = await startAppwriteMock({
|
||||
schema: { portalFeatures: schemaKeys('portalFeatures'), newsPosts: schemaKeys('newsPosts') },
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { startTestApp, get, post, sessionCookie } = await import('./support/testApp.js')
|
||||
const { customerCookie, seedFixtures } = await import('./support/fixtures.js')
|
||||
const { default: newsRoutes } = await import('../server/routes/news.js')
|
||||
const { default: publicNewsRoutes } = await import('../server/routes/publicNews.js')
|
||||
|
||||
const app = await startTestApp({ '/api/public/news': publicNewsRoutes, '/api/news': newsRoutes })
|
||||
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
||||
seedFixtures(aw)
|
||||
|
||||
const KEY = 'abcdefghijklmnop'
|
||||
const KEY2 = 'bcdefghijklmnopq'
|
||||
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', liveDomain: 'baeckerei.de' })
|
||||
aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Café' })
|
||||
aw.seed('websiteProjects', { $id: 'p3', customerId: 'c2', projectName: 'Rate' })
|
||||
const feature = (projectId, customerId, state, siteKey) =>
|
||||
aw.seed('portalFeatures', { $id: `f_${projectId}_news`, customerId, projectId, featureKey: 'news', state, enabled: state === 'active', siteKey })
|
||||
feature('p1', 'c1', 'active', KEY)
|
||||
feature('p2', 'c1', 'setup', KEY2)
|
||||
feature('p3', 'c2', 'active', 'cdefghijklmnopqr')
|
||||
|
||||
const cookie = customerCookie('c1')
|
||||
const call = (method, url, { body, cookie: c = cookie, headers = {} } = {}) =>
|
||||
fetch(app.url + url, {
|
||||
method,
|
||||
headers: { ...(c ? { cookie: c } : {}), ...(body !== undefined && !Buffer.isBuffer(body) ? { 'content-type': 'application/json' } : {}), ...headers },
|
||||
body: body === undefined ? undefined : Buffer.isBuffer(body) ? body : JSON.stringify(body),
|
||||
})
|
||||
const note = { projectId: 'p1', type: 'note', title: 'Hallo', text: 'Wir haben offen', paper: 'sun', duration: '1m' }
|
||||
const png = () => sharp({ create: { width: 40, height: 30, channels: 3, background: '#c33' } }).png().toBuffer()
|
||||
const upload = (projectId, c = cookie) =>
|
||||
png().then((buf) => call('POST', `/api/news/image?projectId=${projectId}`, { body: buf, cookie: c, headers: { 'content-type': 'application/octet-stream' } }))
|
||||
|
||||
test('nicht_aktiv_409', async () => {
|
||||
const res = await post('/api/news', { ...note, projectId: 'p2' }, { cookie })
|
||||
assert.equal(res.status, 409)
|
||||
assert.equal(res.body.error, 'News ist für diese Website nicht aktiv.')
|
||||
})
|
||||
|
||||
test('fremdes_projekt_403_und_viewAs_403', async () => {
|
||||
assert.equal((await post('/api/news', { ...note, projectId: 'p3' }, { cookie })).status, 403)
|
||||
const admin = sessionCookie({ customerId: 'c1', appwriteUserId: 'u1', viewAs: true })
|
||||
assert.equal((await post('/api/news', note, { cookie: admin })).status, 403)
|
||||
})
|
||||
|
||||
test('kunde_crud', async () => {
|
||||
const created = await post('/api/news', note, { cookie })
|
||||
assert.equal(created.status, 201)
|
||||
const id = created.body.$id
|
||||
const list = await get('/api/news?projectId=p1', { cookie })
|
||||
assert.equal(list.hanging.length, 1)
|
||||
assert.equal(list.liveUrl, 'https://baeckerei.de')
|
||||
const patched = await call('PATCH', `/api/news/${id}`, { body: { projectId: 'p1', action: 'takeDown' } })
|
||||
assert.equal(patched.status, 200)
|
||||
const after = await get('/api/news?projectId=p1', { cookie })
|
||||
assert.deepEqual([after.hanging.length, after.expired.length], [0, 1])
|
||||
assert.equal((await call('DELETE', `/api/news/${id}?projectId=p1`)).status, 204)
|
||||
assert.equal((await get('/api/news?projectId=p1', { cookie })).expired.length, 0)
|
||||
})
|
||||
|
||||
test('upload_und_vorschau', async () => {
|
||||
const res = await upload('p1')
|
||||
assert.equal(res.status, 201)
|
||||
const { imageId } = await res.json()
|
||||
const ok = await call('GET', `/api/news/image/p1/${imageId}-s.webp`)
|
||||
assert.equal(ok.status, 200)
|
||||
assert.equal(ok.headers.get('content-type'), 'image/webp')
|
||||
await ok.arrayBuffer()
|
||||
const other = await call('GET', `/api/news/image/p1/${imageId}-s.webp`, { cookie: customerCookie('c2') })
|
||||
assert.equal(other.status, 403)
|
||||
assert.equal((await call('GET', '/api/news/image/p1/..%2Fx-l.webp')).status, 404)
|
||||
})
|
||||
|
||||
test('oeffentlich_cors_cache', async () => {
|
||||
const { imageId } = await (await upload('p1')).json()
|
||||
const created = await post('/api/news', { ...note, type: 'combo', imageId }, { cookie })
|
||||
assert.equal(created.status, 201)
|
||||
const res = await call('GET', `/api/public/news/${KEY}`, { cookie: null })
|
||||
assert.equal(res.status, 200)
|
||||
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'))
|
||||
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`)
|
||||
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 } })
|
||||
assert.equal(again.status, 304)
|
||||
const img = await call('GET', body.posts[0].image.src, { cookie: null })
|
||||
assert.equal(img.status, 200)
|
||||
assert.equal(img.headers.get('cache-control'), 'public, max-age=86400')
|
||||
assert.equal(img.headers.get('x-content-type-options'), 'nosniff')
|
||||
assert.equal(img.headers.get('access-control-allow-origin'), '*')
|
||||
await img.arrayBuffer()
|
||||
const pre = await call('OPTIONS', `/api/public/news/${KEY}`, { cookie: null })
|
||||
assert.equal(pre.status, 204)
|
||||
})
|
||||
|
||||
test('oeffentlich_unbekannt_leer', async () => {
|
||||
for (const key of ['zzzzzzzzzzzzzzzz', 'kaputt', KEY2]) {
|
||||
const res = await call('GET', `/api/public/news/${key}`, { cookie: null })
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual(await res.json(), { posts: [], hasMore: false })
|
||||
}
|
||||
})
|
||||
|
||||
test('bild_nur_wenn_haengend', async () => {
|
||||
const { imageId } = await (await upload('p1')).json()
|
||||
const created = await post('/api/news', { ...note, type: 'photo', imageId }, { cookie })
|
||||
const url = `/api/public/news/${KEY}/img/${created.body.$id}.webp`
|
||||
assert.equal((await call('GET', url, { cookie: null })).status, 200)
|
||||
await call('PATCH', `/api/news/${created.body.$id}`, { body: { projectId: 'p1', action: 'takeDown' } })
|
||||
assert.equal((await call('GET', url, { cookie: null })).status, 404)
|
||||
assert.equal((await call('GET', `/api/public/news/${KEY}/img/nichtda.webp`, { cookie: null })).status, 404)
|
||||
})
|
||||
|
||||
test('rate_limit_schreiben', async () => {
|
||||
const c2 = customerCookie('c2')
|
||||
const statuses = []
|
||||
for (let i = 0; i < 31; i++) statuses.push((await post('/api/news', { ...note, projectId: 'p3' }, { cookie: c2 })).status)
|
||||
assert.ok(statuses.slice(0, 30).every((s) => s === 201), statuses.join())
|
||||
assert.equal(statuses[30], 429)
|
||||
})
|
||||
Reference in new issue
Block a user