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

+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;
}