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
`;
}
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}`);
});