Grundgerüst mit Server, PIN-Anmeldung und Docker-Deploy aufbauen

Die alte Clip-Auswahl-Oberfläche entfällt. Neu sind ein Express-Server,
der im Entwicklungsbetrieb Vite einbindet, eine PIN-Anmeldung mit
signiertem Sitzungs-Cookie und ein Docker-Image mit ffmpeg für VCH.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ferdi2goandClaude Opus 5.5 committed 2026-09-29 09:15:01 +00:00
1 parent 8ed1396b1c
commit f7d249f28b
23 files changed
+4456 -1358

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
import express from 'express';
import {
SESSION_COOKIE,
SESSION_MAX_AGE_MS,
createLoginLimiter,
createToken,
pinMatches,
readCookie,
verifyToken,
} from './auth.js';
export function createApp({ config, secret }) {
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();
});
app.use('/api', (req, res) => res.status(404).json({ error: 'Nicht gefunden.' }));
return app;
}
+81
View File
@@ -0,0 +1,81 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
export const SESSION_COOKIE = 'fdc_session';
export const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
// Ohne SESSION_SECRET wird einmalig ein Geheimnis im Datenverzeichnis erzeugt,
// damit Sitzungen einen Neustart überleben.
export function loadSessionSecret(config) {
if (config.sessionSecret) return config.sessionSecret;
const file = path.join(config.dataDir, 'session-secret');
try {
return fs.readFileSync(file, 'utf8').trim();
} catch {
const secret = crypto.randomBytes(32).toString('hex');
fs.mkdirSync(config.dataDir, { recursive: true });
fs.writeFileSync(file, secret, { mode: 0o600 });
return secret;
}
}
// Die PIN fließt in die Signatur ein: wer die PIN ändert, meldet alle Sitzungen ab.
function sign(secret, pin, payload) {
return crypto.createHmac('sha256', secret).update(`${payload}|${pin}`).digest('base64url');
}
function safeEqual(a, b) {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
return bufA.length === bufB.length && crypto.timingSafeEqual(bufA, bufB);
}
export function createToken(secret, pin, now = Date.now()) {
const payload = String(now + SESSION_MAX_AGE_MS);
return `${payload}.${sign(secret, pin, payload)}`;
}
export function verifyToken(secret, pin, token, now = Date.now()) {
if (!pin || typeof token !== 'string') return false;
const [payload, signature] = token.split('.');
if (!payload || !signature) return false;
if (!safeEqual(signature, sign(secret, pin, payload))) return false;
return Number(payload) > now;
}
export function pinMatches(expected, given) {
if (!expected || typeof given !== 'string') return false;
const hash = (value) => crypto.createHash('sha256').update(value).digest();
return crypto.timingSafeEqual(hash(expected), hash(given));
}
// Nur ein Nutzer: ein globaler Zähler reicht und greift auch hinter dem Proxy.
export function createLoginLimiter({ maxFailures = 10, windowMs = 15 * 60 * 1000 } = {}) {
let failures = [];
return {
isBlocked(now = Date.now()) {
failures = failures.filter((time) => now - time < windowMs);
return failures.length >= maxFailures;
},
recordFailure(now = Date.now()) {
failures.push(now);
},
reset() {
failures = [];
},
};
}
export function readCookie(req, name) {
const header = req.headers.cookie;
if (!header) return undefined;
for (const part of header.split(';')) {
const index = part.indexOf('=');
if (index === -1) continue;
if (part.slice(0, index).trim() === name) {
return decodeURIComponent(part.slice(index + 1).trim());
}
}
return undefined;
}
+12
View File
@@ -0,0 +1,12 @@
import path from 'node:path';
export function loadConfig(env = process.env) {
return {
// Im Docker-Image fest auf 3000 (APP_PORT), sonst der von VCH vergebene PORT.
port: Number(env.APP_PORT || env.PORT || 3000),
pin: env.APP_PIN || '',
sessionSecret: env.SESSION_SECRET || '',
dataDir: path.resolve(env.VCH_DATA_DIR || './data'),
isProduction: env.NODE_ENV === 'production',
};
}
+39
View File
@@ -0,0 +1,39 @@
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
import { createApp } from './app.js';
import { loadSessionSecret } from './auth.js';
import { loadConfig } from './config.js';
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const config = loadConfig();
if (!config.pin) {
console.warn('APP_PIN ist nicht gesetzt – Anmeldung ist nicht möglich.');
}
const app = createApp({ config, secret: loadSessionSecret(config) });
const server = http.createServer(app);
if (config.isProduction) {
const distDir = path.join(rootDir, 'dist');
app.use(express.static(distDir));
app.use((req, res, next) => {
if (req.method !== 'GET') return next();
res.sendFile(path.join(distDir, 'index.html'));
});
} else {
// Im Entwicklungsbetrieb liefert Vite das Frontend über denselben Port aus.
const { createServer } = await import('vite');
const vite = await createServer({
root: rootDir,
appType: 'spa',
server: { middlewareMode: true, hmr: { server } },
});
app.use(vite.middlewares);
}
server.listen(config.port, '0.0.0.0', () => {
console.log(`FuerDieCuts läuft auf Port ${config.port}`);
});