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,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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
Reference in new issue
Block a user