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>
82 lines
2.7 KiB
JavaScript
82 lines
2.7 KiB
JavaScript
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;
|
|
}
|