From 319f8937aa2f91c035b164af5dad922750dcb8f6 Mon Sep 17 00:00:00 2001 From: knso Date: Wed, 30 Sep 2026 16:49:39 +0000 Subject: [PATCH] =?UTF-8?q?News:=20Kunden-API=20und=20=C3=B6ffentliche=20L?= =?UTF-8?q?ese-API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/index.js | 13 +++ server/routes/chat.js | 2 +- server/routes/news.js | 159 ++++++++++++++++++++++++++++++++++++ server/routes/publicNews.js | 106 ++++++++++++++++++++++++ test/newsRoutes.test.js | 142 ++++++++++++++++++++++++++++++++ 5 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 server/routes/news.js create mode 100644 server/routes/publicNews.js create mode 100644 test/newsRoutes.test.js diff --git a/server/index.js b/server/index.js index 8c75f8f..a259219 100644 --- a/server/index.js +++ b/server/index.js @@ -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() diff --git a/server/routes/chat.js b/server/routes/chat.js index f9cd3c4..1c4bc5c 100644 --- a/server/routes/chat.js +++ b/server/routes/chat.js @@ -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', () => { diff --git a/server/routes/news.js b/server/routes/news.js new file mode 100644 index 0000000..d2f22f3 --- /dev/null +++ b/server/routes/news.js @@ -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 diff --git a/server/routes/publicNews.js b/server/routes/publicNews.js new file mode 100644 index 0000000..20d51e9 --- /dev/null +++ b/server/routes/publicNews.js @@ -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 +// .webp oder -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 diff --git a/test/newsRoutes.test.js b/test/newsRoutes.test.js new file mode 100644 index 0000000..2a935e8 --- /dev/null +++ b/test/newsRoutes.test.js @@ -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) +})