import express from 'express'; import { SESSION_COOKIE, SESSION_MAX_AGE_MS, createLoginLimiter, createToken, pinMatches, readCookie, verifyToken, } from './auth.js'; import { HttpError, badRequest } from './http-error.js'; export function createApp({ config, secret, store, queue }) { const app = express(); app.disable('x-powered-by'); app.set('trust proxy', true); const limiter = createLoginLimiter(); const isAuthenticated = (req) => verifyToken(secret, config.pin, readCookie(req, SESSION_COOKIE)); app.get('/healthz', (req, res) => res.json({ ok: true })); app.use('/api', express.json({ limit: '100kb' })); app.get('/api/session', (req, res) => { res.json({ authenticated: isAuthenticated(req), pinConfigured: Boolean(config.pin) }); }); app.post('/api/login', (req, res) => { if (limiter.isBlocked()) { return res.status(429).json({ error: 'Zu viele Fehlversuche. Bitte später erneut versuchen.' }); } if (!pinMatches(config.pin, req.body?.pin)) { limiter.recordFailure(); return res.status(401).json({ error: 'PIN falsch.' }); } limiter.reset(); res.cookie(SESSION_COOKIE, createToken(secret, config.pin), { httpOnly: true, sameSite: 'lax', secure: req.secure, maxAge: SESSION_MAX_AGE_MS, path: '/', }); res.json({ authenticated: true }); }); app.post('/api/logout', (req, res) => { res.clearCookie(SESSION_COOKIE, { path: '/' }); res.json({ authenticated: false }); }); app.use('/api', (req, res, next) => { if (!isAuthenticated(req)) return res.status(401).json({ error: 'Nicht angemeldet.' }); next(); }); const route = (handler) => (req, res, next) => Promise.resolve(handler(req, res)).catch(next); const present = (project) => ({ ...project, render: queue.describe(project) }); app.get('/api/projects', route(async (req, res) => { res.json(await store.list()); })); app.post('/api/projects', route(async (req, res) => { res.status(201).json(present(await store.create(req.body ?? {}))); })); app.get('/api/projects/:id', route(async (req, res) => { res.json(present(await store.get(req.params.id))); })); app.delete('/api/projects/:id', route(async (req, res) => { if (queue.isRunning(req.params.id)) queue.cancel(req.params.id); await store.remove(req.params.id); res.status(204).end(); })); const readOffset = (req) => { const offset = Number(req.query.offset); if (!Number.isSafeInteger(offset) || offset < 0) throw badRequest('Ungültiger Offset.'); return offset; }; app.patch('/api/projects/:id/settings', route(async (req, res) => { res.json(present(await store.updateSettings(req.params.id, req.body))); })); app.put('/api/projects/:id/clips/:index', route(async (req, res) => { const target = { type: 'clip', index: req.params.index }; res.json(await store.appendChunk(req.params.id, target, readOffset(req), req)); })); app.get('/api/projects/:id/clips/:index/file', route(async (req, res) => { const project = await store.get(req.params.id); res.sendFile(store.readyPath(project, { type: 'clip', index: req.params.index })); })); app.post('/api/projects/:id/assets/:slot', route(async (req, res) => { res.status(201).json(present(await store.createAsset(req.params.id, req.params.slot, req.body))); })); app.put('/api/projects/:id/assets/:slot', route(async (req, res) => { const target = { type: 'asset', slot: req.params.slot }; res.json(await store.appendChunk(req.params.id, target, readOffset(req), req)); })); app.delete('/api/projects/:id/assets/:slot', route(async (req, res) => { res.json(present(await store.removeAsset(req.params.id, req.params.slot))); })); app.get('/api/projects/:id/assets/:slot/file', route(async (req, res) => { const project = await store.get(req.params.id); res.sendFile(store.readyPath(project, { type: 'asset', slot: req.params.slot })); })); app.post('/api/projects/:id/render', route(async (req, res) => { await queue.start(req.params.id); res.status(202).json(present(await store.get(req.params.id))); })); app.delete('/api/projects/:id/render', route(async (req, res) => { queue.cancel(req.params.id); res.status(202).json(present(await store.get(req.params.id))); })); // ?download=1 speichert die Datei, sonst Wiedergabe im Browser app.get('/api/projects/:id/output', route(async (req, res) => { const project = await store.get(req.params.id); const file = await queue.outputPath(project); if (req.query.download === undefined) return res.sendFile(file); const { from, to } = project.range; res.download(file, from && to ? `FuerDieCuts_${from}_bis_${to}.mp4` : 'FuerDieCuts.mp4'); })); app.use('/api', (req, res) => res.status(404).json({ error: 'Nicht gefunden.' })); // eslint-disable-next-line no-unused-vars -- Express erkennt Fehlerbehandler an vier Parametern app.use((error, req, res, next) => { if (error instanceof HttpError) { return res.status(error.status).json({ error: error.message, ...error.details }); } // Fehler aus express.json (kaputtes oder zu großes JSON) if (error.status >= 400 && error.status < 500) { return res.status(error.status).json({ error: 'Ungültige Anfrage.' }); } // Der Browser hat den Upload abgebrochen; der Stand bleibt gültig. if (error.code === 'ERR_STREAM_PREMATURE_CLOSE') return; console.error(error); if (!res.headersSent) res.status(500).json({ error: 'Interner Fehler.' }); }); return app; }