require('dotenv').config(); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const express = require('express'); const multer = require('multer'); const auth = require('./lib/auth'); const { readContent, writeContent, readTheme, writeTheme, PROJECT_DIR } = require('./lib/content-store'); const { validateContent, validateTheme } = require('./lib/validate'); const schema = require('./lib/schema'); const { runBuild } = require('./lib/build'); const PORT = process.env.PORT || 4321; const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(PROJECT_DIR, 'public', 'uploads'); const ALLOWED_IMAGE_TYPES = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/svg+xml': '.svg', }; const MAX_UPLOAD_BYTES = 8 * 1024 * 1024; if (!auth.hasConfiguredUsers() || !process.env.SESSION_SECRET) { console.error( 'Mindestens ein Benutzer (ADMIN_USERNAME/ADMIN_PASSWORD_HASH) und SESSION_SECRET müssen gesetzt sein (siehe admin/.env.example).' ); process.exit(1); } fs.mkdirSync(UPLOAD_DIR, { recursive: true }); const app = express(); app.set('trust proxy', 1); app.disable('x-powered-by'); app.use((req, res, next) => { res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Referrer-Policy', 'same-origin'); next(); }); app.use(express.json({ limit: '2mb' })); // --- Login --- function renderLoginPage({ csrfToken, error, locked }) { return ` Anmelden · larpaso Admin

larpaso · Admin-Bereich

${error ? `
${error}
` : ''}
`; } app.get('/admin/login', (req, res) => { const cookies = auth.getCookies(req); if (auth.isValidSession(cookies[auth.SESSION_COOKIE])) { return res.redirect('/admin'); } const csrfToken = auth.issueCsrfToken(res); const locked = auth.isLockedOut(req); res.send( renderLoginPage({ csrfToken, locked, error: locked ? 'Zu viele Fehlversuche. Bitte in 15 Minuten erneut versuchen.' : null, }) ); }); app.post('/admin/login', express.urlencoded({ extended: false }), (req, res) => { if (auth.isLockedOut(req)) { const csrfToken = auth.issueCsrfToken(res); return res .status(429) .send(renderLoginPage({ csrfToken, locked: true, error: 'Zu viele Fehlversuche. Bitte in 15 Minuten erneut versuchen.' })); } if (!auth.verifyCsrfToken(req)) { const csrfToken = auth.issueCsrfToken(res); return res.status(403).send(renderLoginPage({ csrfToken, error: 'Sitzung abgelaufen, bitte erneut versuchen.' })); } const { username, password } = req.body; if (!auth.verifyPassword(username, password)) { auth.recordFailedLogin(req); const csrfToken = auth.issueCsrfToken(res); return res.status(401).send(renderLoginPage({ csrfToken, error: 'Benutzername oder Passwort falsch.' })); } auth.recordSuccessfulLogin(req); const token = auth.createSession(); res.cookie(auth.SESSION_COOKIE, token, { ...auth.baseCookieOptions(), maxAge: 12 * 60 * 60 * 1000 }); res.redirect('/admin'); }); app.post('/admin/logout', (req, res) => { const cookies = auth.getCookies(req); auth.destroySession(cookies[auth.SESSION_COOKIE]); res.clearCookie(auth.SESSION_COOKIE, auth.baseCookieOptions()); res.redirect('/admin/login'); }); // --- Protected admin UI + API --- app.get('/admin', auth.requireAuth, (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); app.get('/admin/api/schema', auth.requireAuth, (req, res) => { res.json(schema); }); app.get('/admin/api/content', auth.requireAuth, (req, res) => { res.json(readContent()); }); app.put('/admin/api/content', auth.requireAuth, auth.requireAdminHeader, (req, res) => { const errors = validateContent(req.body); if (errors.length > 0) { return res.status(400).json({ error: 'Ungültige Daten.', details: errors }); } try { writeContent(req.body); res.json({ ok: true }); } catch (err) { res.status(500).json({ error: `Speichern fehlgeschlagen: ${err.message}` }); } }); app.get('/admin/api/theme', auth.requireAuth, (req, res) => { res.json(readTheme()); }); app.put('/admin/api/theme', auth.requireAuth, auth.requireAdminHeader, (req, res) => { const errors = validateTheme(req.body); if (errors.length > 0) { return res.status(400).json({ error: 'Ungültige Daten.', details: errors }); } try { writeTheme(req.body); res.json({ ok: true }); } catch (err) { res.status(500).json({ error: `Speichern fehlgeschlagen: ${err.message}` }); } }); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: MAX_UPLOAD_BYTES }, }); app.post('/admin/api/upload', auth.requireAuth, auth.requireAdminHeader, upload.single('file'), (req, res) => { if (!req.file) { return res.status(400).json({ error: 'Keine Datei erhalten.' }); } const ext = ALLOWED_IMAGE_TYPES[req.file.mimetype]; if (!ext) { return res.status(400).json({ error: 'Nur JPG, PNG, WebP oder SVG erlaubt.' }); } const filename = `${Date.now()}-${crypto.randomBytes(6).toString('hex')}${ext}`; fs.writeFileSync(path.join(UPLOAD_DIR, filename), req.file.buffer); res.json({ url: `/uploads/${filename}` }); }); app.post('/admin/api/publish', auth.requireAuth, auth.requireAdminHeader, async (req, res) => { const result = await runBuild(); const output = result.output.slice(-8000); if (!result.ok) { return res.status(500).json({ ok: false, error: result.error, output }); } res.json({ ok: true, output }); }); // Static admin UI assets (index.html, app.js, style.css) — mounted after the // API routes above so it never intercepts /admin/api/* requests. app.use('/admin', auth.requireAuth, express.static(path.join(__dirname, 'public'))); app.listen(PORT, () => { console.log(`larpaso admin läuft auf Port ${PORT}`); });