const path = require('path'); const express = require('express'); const cookieParser = require('cookie-parser'); const { db, getDoc, putDoc } = require('./db'); const { scheduleBackups } = require('./backup'); const { audit } = require('./audit'); const { hit } = require('./ratelimit'); const { sendMagicLink } = require('./mailer'); const { normalizeEmail, hashToken, createLoginToken, consumeLoginToken, createSession, validateSession, deleteSession, SESSION_TTL_DAYS, seedAdmin, migrateToMultiUser, } = require('./auth'); const app = express(); app.use(express.json({ limit: '5mb' })); app.use(express.urlencoded({ extended: false })); app.use(cookieParser()); const KEYS = ['employees', 'duties', 'vacation']; const EMPTY = { employees: [], duties: {}, vacation: {} }; const RATE_LIMIT_EMAIL = parseInt(process.env.RATE_LIMIT_EMAIL, 10) || 5; const RATE_LIMIT_IP = parseInt(process.env.RATE_LIMIT_IP, 10) || 50; const RATE_LIMIT_WINDOW_MIN = parseInt(process.env.RATE_LIMIT_WINDOW_MIN, 10) || 15; const SESSION_COOKIE = 'session'; // Secure-by-default; nur für lokalen HTTP-Dev per COOKIE_INSECURE=true abschaltbar. const cookieSecure = process.env.COOKIE_INSECURE !== 'true'; function setSessionCookie(res, raw) { res.cookie(SESSION_COOKIE, raw, { httpOnly: true, secure: cookieSecure, sameSite: 'lax', maxAge: SESSION_TTL_DAYS * 86400 * 1000, path: '/', }); } function clearSessionCookie(res) { res.clearCookie(SESSION_COOKIE, { path: '/' }); } function ipHashOf(req) { return hashToken(req.ip || ''); } function baseUrl(req) { return process.env.APP_BASE_URL || `${req.protocol}://${req.get('host')}`; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function authMiddleware(req, res, next) { const raw = req.cookies && req.cookies[SESSION_COOKIE]; const u = raw ? validateSession(raw) : null; if (!u) { clearSessionCookie(res); return res.status(401).json({ error: 'nicht angemeldet' }); } req.user = { id: u.userId, email: u.email, isAdmin: u.isAdmin }; next(); } function adminMiddleware(req, res, next) { if (!req.user || !req.user.isAdmin) return res.status(403).json({ error: 'Admin-Rechte erforderlich' }); next(); } app.get('/api/health', (req, res) => res.json({ status: 'ok' })); // ── Auth ────────────────────────────────────────────────────────────── // Login-Link anfordern: neutral (verrät keine Allowlist-Mitgliedschaft), rate-limited. app.post('/api/auth/request', async (req, res) => { const email = normalizeEmail((req.body && req.body.email) || ''); const ipH = ipHashOf(req); const okEmail = hit('email:' + email, RATE_LIMIT_EMAIL, RATE_LIMIT_WINDOW_MIN); const okIp = hit('ip:' + ipH, RATE_LIMIT_IP, RATE_LIMIT_WINDOW_MIN); if (!okEmail || !okIp) { audit('auth_fail', null, ipH); return res.status(429).json({ error: 'Zu viele Anfragen.' }); } const user = email ? db.prepare('SELECT id FROM users WHERE email = ?').get(email) : null; if (user) { db.prepare('UPDATE login_tokens SET used_at = ? WHERE user_id = ? AND used_at IS NULL') .run(new Date().toISOString(), user.id); const raw = createLoginToken(user.id); try { await sendMagicLink(email, `${baseUrl(req)}/auth?token=${raw}`); } catch (e) { console.error('sendMagicLink fehlgeschlagen:', e.message); } // neutral bleiben, nie hängen } res.json({ ok: true }); // immer identisch }); // Scanner-sichere Einlösung: GET zeigt nur die Bestätigungsseite (verbraucht NICHT). app.get('/auth', (req, res) => { const token = String(req.query.token || ''); res.set('Content-Type', 'text/html; charset=utf-8').send(` Anmelden – Dienstplan-Pro

Bei Dienstplan-Pro anmelden

Klicke auf „Jetzt anmelden", um deine Sitzung zu starten.

`); }); // Bestätigung (menschlicher POST) verbraucht den Token und erzeugt die Sitzung. app.post('/auth/confirm', (req, res) => { const token = String((req.body && req.body.token) || ''); const user = consumeLoginToken(token); if (!user) { return res.status(400).set('Content-Type', 'text/html; charset=utf-8').send(`Link ungültig

Link ungültig oder abgelaufen

Bitte fordere einen neuen Login-Link an.

Zur Startseite
`); } const raw = createSession(user.userId); // neue Sitzung erst bei erfolgreichem Consume (keine Fixation) setSessionCookie(res, raw); audit('login_ok', user.userId, ipHashOf(req)); res.redirect(302, '/'); }); app.post('/api/auth/logout', (req, res) => { const raw = req.cookies && req.cookies[SESSION_COOKIE]; if (raw) { const u = validateSession(raw); deleteSession(raw); if (u) audit('logout', u.userId); } clearSessionCookie(res); res.json({ ok: true }); }); app.get('/api/auth/me', (req, res) => { const raw = req.cookies && req.cookies[SESSION_COOKIE]; const u = raw ? validateSession(raw) : null; if (!u) return res.status(401).json({ error: 'nicht angemeldet' }); res.json({ email: u.email, isAdmin: u.isAdmin }); }); // Notzugang: Admin erzeugt einen Login-Link out-of-band (audit-geloggt). app.post('/api/admin/login-link', authMiddleware, adminMiddleware, (req, res) => { const email = normalizeEmail((req.body && req.body.email) || ''); const user = email ? db.prepare('SELECT id FROM users WHERE email = ?').get(email) : null; if (!user) return res.status(404).json({ error: 'E-Mail nicht freigeschaltet' }); const raw = createLoginToken(user.id); audit('emergency_link', user.id, ipHashOf(req)); res.json({ url: `${baseUrl(req)}/auth?token=${raw}` }); }); // ── Admin: Nutzerverwaltung (Allowlist) ────────────────────────────── app.get('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => { const rows = db.prepare('SELECT id, email, is_admin FROM users ORDER BY email').all(); res.json({ users: rows.map(u => ({ id: u.id, email: u.email, isAdmin: !!u.is_admin })) }); }); app.post('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => { const email = normalizeEmail((req.body && req.body.email) || ''); if (!email || !email.includes('@')) return res.status(400).json({ error: 'Ungültige E-Mail' }); const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email); if (!existing) { db.prepare('INSERT INTO users (email, is_admin, created_at) VALUES (?, 0, ?)').run(email, new Date().toISOString()); audit('admin_add', req.user.id, ipHashOf(req)); } res.json({ ok: true }); }); app.delete('/api/admin/users/:id', authMiddleware, adminMiddleware, (req, res) => { const id = parseInt(req.params.id, 10); if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'Ungültige ID' }); const target = db.prepare('SELECT id, is_admin FROM users WHERE id = ?').get(id); if (!target) return res.status(404).json({ error: 'Nutzer nicht gefunden' }); if (target.id === req.user.id) return res.status(400).json({ error: 'Du kannst dich nicht selbst entfernen.' }); if (target.is_admin && db.prepare('SELECT COUNT(*) c FROM users WHERE is_admin = 1').get().c <= 1) { return res.status(400).json({ error: 'Der letzte Admin kann nicht entfernt werden.' }); } db.prepare('DELETE FROM users WHERE id = ?').run(id); // CASCADE räumt Sessions/Tokens audit('admin_remove', req.user.id, ipHashOf(req)); res.json({ ok: true }); }); // ── Daten pro Nutzer (hinter Auth; user_id NUR aus der Session, nie aus dem Client) ── app.get('/api/state', authMiddleware, (req, res) => { const state = { ...EMPTY, updatedAt: null }; for (const key of KEYS) { const doc = getDoc(req.user.id, key); if (doc) { state[key] = doc.value; if (!state.updatedAt || doc.updatedAt > state.updatedAt) state.updatedAt = doc.updatedAt; } } res.json(state); }); app.put('/api/state', authMiddleware, (req, res) => { const body = req.body || {}; const now = new Date().toISOString(); for (const key of KEYS) { if (body[key] !== undefined) putDoc(req.user.id, key, body[key], now); } res.json({ status: 'ok', updatedAt: now }); }); app.use(express.static(path.join(__dirname, '..'))); if (require.main === module) { // Startup-Reihenfolge: Fail-Fast auf ADMIN_EMAIL → Seed → Migration → listen. const adminId = seedAdmin(process.env.ADMIN_EMAIL); migrateToMultiUser(adminId); const PORT = process.env.PORT || 3000; app.listen(PORT, '0.0.0.0', () => { console.log(`Dienstplan-Pro auf :${PORT}`); scheduleBackups(); }); } module.exports = app; module.exports.authMiddleware = authMiddleware; module.exports.adminMiddleware = adminMiddleware;