Rich-text fields (hero lead, paragraphs, FAQ-style copy, datenschutz sections, etc.) now use a proper toolbar editor - bold/italic/underline buttons plus a per-selection font picker drawn from the same curated font list as the Design tab - instead of typed **markdown** syntax. Content is stored as a small allowlisted HTML subset (strong/em/u/span with a validated data-font/br) and sanitized server-side with sanitize-html on every save (admin/lib/rich-sanitize.js), independent of what the client sends - verified this rejects script tags, event handlers, and made-up font names while preserving legitimate markup, and that re-saving already-sanitized content is a no-op. The Astro site mirrors the same allowlist at render time for defense-in-depth (src/lib/rich-sanitize.ts) and only requests Google Fonts for fonts actually referenced somewhere in content, in addition to the two Design-tab fonts - verified a font used via a data-font span gets its own Google Fonts request and [data-font] CSS rule in the built page, and that unused fonts don't get requested. Migrated the three existing fields that used the old markdown-lite syntax (**bold**/*italic*) to real HTML so they keep rendering correctly under the new system. Also replaces the single long scrolling admin page with a tabbed sidebar (grouped "Inhalte"/"Design") to cut down on clutter, per feedback that the all-in-one-page layout was hard to navigate.
217 lines
7.8 KiB
JavaScript
217 lines
7.8 KiB
JavaScript
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 { sanitizeContentPayload } = require('./lib/sanitize-content');
|
|
const schema = require('./lib/schema');
|
|
const { runBuild } = require('./lib/build');
|
|
const { FONT_OPTIONS } = require('./lib/fonts');
|
|
|
|
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 `<!doctype html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<meta name="robots" content="noindex, nofollow" />
|
|
<title>Anmelden · larpaso Admin</title>
|
|
<style>
|
|
body { font-family: system-ui, sans-serif; background: #14110f; color: #f2ede4; display: flex; min-height: 100vh; align-items: center; justify-content: center; margin: 0; }
|
|
form { background: #1f1a16; padding: 2.5rem; border-radius: 12px; width: min(360px, 90vw); box-shadow: 0 10px 30px rgba(0,0,0,0.4); }
|
|
h1 { font-size: 1.25rem; margin: 0 0 1.5rem; }
|
|
label { display: block; font-size: 0.85rem; margin-bottom: 0.3rem; color: #c9a876; }
|
|
input { width: 100%; box-sizing: border-box; padding: 0.6rem 0.75rem; margin-bottom: 1rem; border-radius: 6px; border: 1px solid #3a332c; background: #14110f; color: #f2ede4; }
|
|
button { width: 100%; padding: 0.7rem; border-radius: 6px; border: none; background: #c9a876; color: #14110f; font-weight: 600; cursor: pointer; }
|
|
.error { background: #4a1f1f; color: #f2b8b8; padding: 0.6rem 0.75rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.85rem; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<form method="POST" action="/admin/login">
|
|
<h1>larpaso · Admin-Bereich</h1>
|
|
${error ? `<div class="error">${error}</div>` : ''}
|
|
<label for="username">Benutzername</label>
|
|
<input type="text" id="username" name="username" autocomplete="username" required ${locked ? 'disabled' : ''} />
|
|
<label for="password">Passwort</label>
|
|
<input type="password" id="password" name="password" autocomplete="current-password" required ${locked ? 'disabled' : ''} />
|
|
<input type="hidden" name="csrf" value="${csrfToken}" />
|
|
<button type="submit" ${locked ? 'disabled' : ''}>Anmelden</button>
|
|
</form>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
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/fonts', auth.requireAuth, (req, res) => {
|
|
res.json(FONT_OPTIONS);
|
|
});
|
|
|
|
app.get('/admin/api/content', auth.requireAuth, (req, res) => {
|
|
res.json(readContent());
|
|
});
|
|
|
|
app.put('/admin/api/content', auth.requireAuth, auth.requireAdminHeader, (req, res) => {
|
|
// Sanitize before validating: rich-text HTML is never trusted as-is, and
|
|
// the sanitized result is what actually gets checked and persisted.
|
|
const sanitized = sanitizeContentPayload(req.body);
|
|
const errors = validateContent(sanitized);
|
|
if (errors.length > 0) {
|
|
return res.status(400).json({ error: 'Ungültige Daten.', details: errors });
|
|
}
|
|
try {
|
|
writeContent(sanitized);
|
|
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}`);
|
|
});
|