Exporte laufen nacheinander. Der Fortschritt steht im Speicher; ein nach einem Neustart verwaister Export wird als unterbrochen gemeldet. Nach Änderungen an Einstellungen oder Dateien gilt ein fertiges Video als veraltet. Der Download trägt den Zeitraum im Dateinamen. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
156 lines
5.5 KiB
JavaScript
156 lines
5.5 KiB
JavaScript
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;
|
|
}
|