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:
1 parent
8ed1396b1c
commit
f7d249f28b
23 files changed
+4456
-1358
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user