Files
Kendrick Bollens 113bc1bc20 web-editor: TinyMCE-Editor, Verwaltung, schnelles Tree-Laden, CI-Design
- TinyMCE (selbst gehostet) mit Base64-Bildeinbettung statt contenteditable
- Kategorie-Tabs Vorlagen/Fußzeilen/Signaturen + Verwaltung
  (Übersicht, Abteilungen, E-Mail-Zuordnung, Schlagwörter)
- /api/tree über rekursive git/trees-API (1 statt ~17 Anfragen)
- Plus-Jakarta-Sans-Font, SVG-Icons, farbige Abteilungs-Badges
- Platzhalter-Hinweis (nur in Signatur-Vorlage _vorlage.html)
- LOCAL- und DEMO-Modus im Server

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:16:36 +02:00

581 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// server.js — HPS Vorlagen & Signaturen Web-Editor
// Express backend that proxies the Gitea/Forgejo Contents API.
// The Gitea token stays server-side (never reaches the browser) and CORS
// is avoided because the browser only ever talks to this server.
const express = require('express');
const path = require('path');
const fs = require('fs');
const {
GITEA_URL,
GITEA_OWNER,
GITEA_REPO,
GITEA_BRANCH = 'main',
GITEA_TOKEN,
PORT = 3000,
COMMIT_AUTHOR_NAME = 'Web-Editor',
COMMIT_AUTHOR_EMAIL = '',
BASIC_AUTH_USER,
BASIC_AUTH_PASS,
} = process.env;
const SHARED_FOLDER = '_gemeinsam';
const USER_FOLDER = '_benutzer';
const CONFIG_FOLDER = '_config';
const SIG_FOOTERS = 'signatures/footers';
const SIG_HEADERS = 'signatures/headers';
// Demo mode: serve an in-memory sample repo so the editor can be tried out
// without a real Gitea server. Enable with DEMO=1.
const DEMO = process.env.DEMO === '1' || process.env.DEMO === 'true';
// Local mode: read/write a local checkout of the repo on disk instead of
// talking to Gitea. Useful for inspecting/editing real content offline.
const LOCAL_REPO = process.env.LOCAL_REPO || '';
const LOCAL = !!LOCAL_REPO;
const localRoot = LOCAL ? path.resolve(LOCAL_REPO) : null;
const configured = DEMO || LOCAL || !!(GITEA_URL && GITEA_OWNER && GITEA_REPO && GITEA_TOKEN);
if (!configured) {
console.warn('[WARN] Gitea-Verbindung unvollständig konfiguriert. ' +
'Bitte GITEA_URL, GITEA_OWNER, GITEA_REPO und GITEA_TOKEN setzen (siehe .env.example).');
}
// ── Gitea API client ──
const apiBase = () => `${GITEA_URL.replace(/\/$/, '')}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}`;
const giteaHeaders = () => ({
'Authorization': `token ${GITEA_TOKEN}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
});
const authorInfo = () => {
if (!COMMIT_AUTHOR_NAME) return {};
return {
author: {
name: COMMIT_AUTHOR_NAME,
email: COMMIT_AUTHOR_EMAIL ||
`${COMMIT_AUTHOR_NAME.toLowerCase().replace(/\s+/g, '.')}@local`,
},
};
};
const encodePath = (p) => p.split('/').map(encodeURIComponent).join('/');
const toBase64 = (str) => Buffer.from(str, 'utf-8').toString('base64');
const fromBase64 = (b64) => Buffer.from((b64 || '').replace(/\s/g, ''), 'base64').toString('utf-8');
async function giteaError(method, filepath, res) {
let detail = '';
try { const body = await res.json(); detail = body.message || JSON.stringify(body); } catch (_) {}
const err = new Error(`${method} ${filepath}: ${res.status} ${res.statusText}${detail ? ' — ' + detail : ''}`);
err.status = res.status;
throw err;
}
async function getFile(filepath) {
if (LOCAL) return localGetFile(filepath);
if (DEMO) return demoGetFile(filepath);
const url = `${apiBase()}/contents/${encodePath(filepath)}?ref=${encodeURIComponent(GITEA_BRANCH)}`;
const res = await fetch(url, { headers: giteaHeaders() });
if (res.status === 404) return null;
if (!res.ok) await giteaError('GET', filepath, res);
return res.json();
}
async function listDir(dirpath) {
if (LOCAL) return localListDir(dirpath);
if (DEMO) return demoListDir(dirpath);
const pathPart = dirpath ? `/${encodePath(dirpath)}` : '';
const url = `${apiBase()}/contents${pathPart}?ref=${encodeURIComponent(GITEA_BRANCH)}`;
const res = await fetch(url, { headers: giteaHeaders() });
if (res.status === 404) return [];
if (!res.ok) await giteaError('LIST', dirpath, res);
const result = await res.json();
return Array.isArray(result) ? result : [];
}
async function createFile(filepath, content, message) {
if (LOCAL) return localWrite(filepath, content);
if (DEMO) return demoWrite(filepath, content);
const url = `${apiBase()}/contents/${encodePath(filepath)}`;
const res = await fetch(url, {
method: 'POST',
headers: giteaHeaders(),
body: JSON.stringify({
content: toBase64(content),
message: message || `Add ${filepath}`,
branch: GITEA_BRANCH,
...authorInfo(),
}),
});
if (!res.ok) await giteaError('POST', filepath, res);
return res.json();
}
async function updateFile(filepath, content, sha, message) {
if (LOCAL) return localWrite(filepath, content);
if (DEMO) return demoWrite(filepath, content);
const url = `${apiBase()}/contents/${encodePath(filepath)}`;
const res = await fetch(url, {
method: 'PUT',
headers: giteaHeaders(),
body: JSON.stringify({
content: toBase64(content),
sha,
message: message || `Update ${filepath}`,
branch: GITEA_BRANCH,
...authorInfo(),
}),
});
if (!res.ok) await giteaError('PUT', filepath, res);
return res.json();
}
async function deleteFile(filepath, sha, message) {
if (LOCAL) { fs.rmSync(localResolve(filepath), { force: true }); return { success: true }; }
if (DEMO) { delete demoStore[filepath]; return { success: true }; }
const url = `${apiBase()}/contents/${encodePath(filepath)}`;
const res = await fetch(url, {
method: 'DELETE',
headers: giteaHeaders(),
body: JSON.stringify({
sha,
message: message || `Delete ${filepath}`,
branch: GITEA_BRANCH,
...authorInfo(),
}),
});
if (!res.ok) await giteaError('DELETE', filepath, res);
return res.json();
}
// Save = create-or-update, server resolves the latest sha to avoid races.
async function saveFile(filepath, content, message) {
const existing = await getFile(filepath);
if (existing && typeof existing.content === 'string') {
const existingContent = fromBase64(existing.content);
if (existingContent === content) {
return { unchanged: true, sha: existing.sha };
}
const r = await updateFile(filepath, content, existing.sha, message);
return { sha: r.content?.sha };
}
const r = await createFile(filepath, content, message);
return { sha: r.content?.sha };
}
// ── Local filesystem backend ──
// Reads/writes a local checkout of the repo. Mimics the Gitea response shapes
// so the rest of the server is unchanged. Paths are confined to localRoot.
function localResolve(filepath) {
const abs = path.resolve(localRoot, filepath);
if (abs !== localRoot && !abs.startsWith(localRoot + path.sep)) {
throw Object.assign(new Error('Pfad außerhalb des Repos'), { status: 400 });
}
return abs;
}
function localGetFile(filepath) {
const abs = localResolve(filepath);
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return null;
const content = fs.readFileSync(abs, 'utf-8');
return { path: filepath, name: path.basename(filepath), type: 'file', sha: 'local', content: toBase64(content) };
}
function localListDir(dirpath) {
const abs = localResolve(dirpath || '.');
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) return [];
return fs.readdirSync(abs, { withFileTypes: true })
.filter(e => e.name !== '.git')
.map(e => ({
name: e.name,
path: dirpath ? `${dirpath}/${e.name}` : e.name,
type: e.isDirectory() ? 'dir' : 'file',
sha: 'local',
}));
}
function localWrite(filepath, content) {
const abs = localResolve(filepath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content, 'utf-8');
return { content: { sha: 'local', path: filepath } };
}
function localAllFiles() {
const out = [];
(function walk(dir) {
const abs = dir ? localResolve(dir) : localRoot;
for (const e of fs.readdirSync(abs, { withFileTypes: true })) {
if (e.name === '.git') continue;
const rel = dir ? `${dir}/${e.name}` : e.name;
if (e.isDirectory()) walk(rel);
else out.push({ path: rel, sha: 'local' });
}
})('');
return out;
}
// ── Demo backend (in-memory) ──
// A flat map of repoPath → raw content, seeded with sample data. The demo
// versions of the primitives mimic the Gitea API response shapes so the rest
// of the server works unchanged.
const demoStore = {};
function demoGetFile(filepath) {
if (!(filepath in demoStore)) return null;
return { path: filepath, name: filepath.split('/').pop(), type: 'file', sha: 'demo', content: toBase64(demoStore[filepath]) };
}
function demoListDir(dirpath) {
const prefix = dirpath ? `${dirpath}/` : '';
const out = [];
const seenDirs = new Set();
for (const p of Object.keys(demoStore)) {
if (prefix && !p.startsWith(prefix)) continue;
const rest = p.slice(prefix.length);
const slash = rest.indexOf('/');
if (slash === -1) {
out.push({ name: rest, path: p, type: 'file', sha: 'demo' });
} else {
const dir = rest.slice(0, slash);
if (!seenDirs.has(dir)) {
seenDirs.add(dir);
out.push({ name: dir, path: prefix + dir, type: 'dir', sha: 'demo' });
}
}
}
return out;
}
function demoWrite(filepath, content) {
demoStore[filepath] = content;
return { content: { sha: 'demo', path: filepath } };
}
function seedDemo() {
const sig = (name, role) =>
`<div style="font-family:Arial,sans-serif;font-size:13px;color:#333;line-height:1.5">
<strong style="color:#0d3b66">${name}</strong><br>
<span style="color:#666">${role}</span><br>
Hotel Park Soltau · Winsener Straße 111 · 29614 Soltau
</div>`;
Object.assign(demoStore, {
// Empty department (only a .gitkeep) — shows up with no templates yet.
'Haustechnik/.gitkeep': '',
'_config/abteilungen.json': JSON.stringify({
'info@hotel-park-soltau.de': 'Rezeption',
'veranstaltung@hotel-park-soltau.de': 'Veranstaltungsbuero',
'it@hotel-park-soltau.de': 'IT',
'haustechnik@hotel-park-soltau.de': 'Haustechnik',
}, null, 2),
'_gemeinsam/Begruessung.html':
`<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Sehr geehrte Damen und Herren,</p>
<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">vielen Dank f&uuml;r Ihre Nachricht an das <strong>Hotel Park Soltau</strong>. Wir freuen uns &uuml;ber Ihr Interesse und melden uns schnellstm&ouml;glich bei Ihnen.</p>`,
'_gemeinsam/Buchungsbestaetigung.html':
`<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Guten Tag,</p>
<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">wir best&auml;tigen Ihnen hiermit Ihre Buchung im Hotel Park Soltau. Bei Fragen stehen wir Ihnen jederzeit gerne zur Verf&uuml;gung.</p>
<ul style="font-family:Arial,sans-serif;font-size:14px;color:#222">
<li>Anreise: ab 15:00 Uhr</li>
<li>Abreise: bis 11:00 Uhr</li>
<li>Inklusive Fr&uuml;hst&uuml;cksbuffet</li>
</ul>`,
'Rezeption/Anfrage Verfuegbarkeit.html':
`<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Guten Tag,</p>
<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">gerne pr&uuml;fen wir die Verf&uuml;gbarkeit f&uuml;r Ihren gew&uuml;nschten Zeitraum. K&ouml;nnten Sie uns bitte folgende Angaben mitteilen?</p>
<ul style="font-family:Arial,sans-serif;font-size:14px;color:#222"><li>An- und Abreisedatum</li><li>Anzahl der Personen</li><li>Zimmerkategorie</li></ul>`,
'Rezeption/Check-in Informationen.html':
`<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Liebe G&auml;ste,</p>
<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Ihr Check-in ist ab 15:00 Uhr m&ouml;glich. Unsere Rezeption ist rund um die Uhr f&uuml;r Sie besetzt. Kostenfreie Parkpl&auml;tze stehen direkt am Hotel zur Verf&uuml;gung.</p>`,
'Veranstaltungsbuero/Angebot Tagung.html':
`<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Sehr geehrte Damen und Herren,</p>
<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">anbei erhalten Sie unser unverbindliches Angebot f&uuml;r Ihre Tagung. Unsere Veranstaltungsr&auml;ume bieten Platz f&uuml;r bis zu 120 Personen &mdash; inklusive moderner Tagungstechnik.</p>`,
'IT/Passwort zuruecksetzen.html':
`<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Hallo,</p>
<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Ihr Passwort wurde zur&uuml;ckgesetzt. Bitte melden Sie sich mit dem tempor&auml;ren Kennwort an und vergeben Sie umgehend ein neues.</p>`,
'_benutzer/info@hotel-park-soltau.de/Persoenliche Notiz.html':
`<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Kurzer pers&ouml;nlicher Hinweis &mdash; nur f&uuml;r meinen eigenen Gebrauch sichtbar.</p>`,
'signatures/footers/_default.html':
`<table style="font-family:Arial,sans-serif;font-size:12px;color:#888;border-top:2px solid #0d3b66;padding-top:8px;margin-top:12px">
<tr><td>Hotel Park Soltau GmbH · Gesch&auml;ftsf&uuml;hrer: M. Mustermann · Amtsgericht L&uuml;neburg HRB 12345<br>
Tel. +49 5191 0000 · <a href="https://hotel-park-soltau.de" style="color:#0d3b66">hotel-park-soltau.de</a></td></tr>
</table>`,
'signatures/footers/Rezeption.html':
`<table style="font-family:Arial,sans-serif;font-size:12px;color:#888;border-top:2px solid #0d3b66;padding-top:8px;margin-top:12px">
<tr><td><strong>Rezeption</strong> &mdash; rund um die Uhr f&uuml;r Sie da · Tel. +49 5191 0000-0<br>
Hotel Park Soltau · Winsener Stra&szlig;e 111 · 29614 Soltau</td></tr>
</table>`,
'signatures/headers/_vorlage.html':
sig('[Vorname Nachname]', '[Position]'),
'signatures/headers/info@hotel-park-soltau.de.max-mustermann.html':
sig('Max Mustermann', 'Rezeptionsleitung'),
'signatures/headers/veranstaltung@hotel-park-soltau.de.anna-beispiel.html':
sig('Anna Beispiel', 'Veranstaltungsmanagement'),
});
}
if (DEMO) {
seedDemo();
console.log('[DEMO] In-Memory-Demodaten geladen — keine echte Gitea-Verbindung nötig.');
}
// Liefert ALLE Dateien des Repos als flache Liste [{path, sha}].
// Gitea: ein einziger rekursiver git/trees-Aufruf (statt vieler listDir).
async function listAllFiles() {
if (LOCAL) return localAllFiles();
if (DEMO) return Object.keys(demoStore).map((p) => ({ path: p, sha: 'demo' }));
const out = [];
let page = 1, total = Infinity;
while (out.length < total) {
const url = `${apiBase()}/git/trees/${encodeURIComponent(GITEA_BRANCH)}?recursive=true&per_page=1000&page=${page}`;
const res = await fetch(url, { headers: giteaHeaders() });
if (res.status === 404) return [];
if (!res.ok) await giteaError('TREE', '', res);
const data = await res.json();
total = data.total_count || 0;
const blobs = (data.tree || []).filter((e) => e.type === 'blob').map((e) => ({ path: e.path, sha: e.sha }));
out.push(...blobs);
if (!data.tree || data.tree.length === 0) break;
page++;
}
return out;
}
// ── Express app ──
const app = express();
app.use(express.json({ limit: '5mb' }));
// Optional basic auth — protects the whole editor when credentials are set.
if (BASIC_AUTH_USER && BASIC_AUTH_PASS) {
app.use((req, res, next) => {
const hdr = req.headers.authorization || '';
const [scheme, encoded] = hdr.split(' ');
if (scheme === 'Basic' && encoded) {
const [user, pass] = Buffer.from(encoded, 'base64').toString('utf-8').split(':');
if (user === BASIC_AUTH_USER && pass === BASIC_AUTH_PASS) return next();
}
res.set('WWW-Authenticate', 'Basic realm="HPS Web-Editor"');
res.status(401).send('Authentifizierung erforderlich');
});
}
// Async route wrapper → forwards errors to the JSON error handler.
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
function requireConfigured(_req, res, next) {
if (!configured) {
return res.status(503).json({ error: 'Gitea-Verbindung nicht konfiguriert. Bitte Umgebungsvariablen setzen.' });
}
next();
}
// Public, non-sensitive config (no token).
app.get('/api/config', (_req, res) => {
res.json({
configured,
demo: DEMO,
local: LOCAL,
url: GITEA_URL || (LOCAL ? `lokal: ${localRoot}` : (DEMO ? '(Demo-Modus — keine echten Daten)' : null)),
owner: GITEA_OWNER || (LOCAL ? 'lokal' : (DEMO ? 'demo' : null)),
repo: GITEA_REPO || (LOCAL ? path.basename(localRoot) : (DEMO ? 'email-vorlagen' : null)),
branch: GITEA_BRANCH,
author: COMMIT_AUTHOR_NAME,
});
});
app.get('/api/health', wrap(async (_req, res) => {
if (LOCAL) return res.json({ ok: true, repo: `lokal: ${localRoot}` });
if (DEMO) return res.json({ ok: true, repo: 'demo/email-vorlagen (Demo-Modus)' });
if (!configured) return res.status(503).json({ ok: false, error: 'nicht konfiguriert' });
const r = await fetch(`${apiBase()}`, { headers: giteaHeaders() });
if (!r.ok) return res.status(502).json({ ok: false, error: `${r.status} ${r.statusText}` });
const info = await r.json();
res.json({ ok: true, repo: info.full_name });
}));
app.use('/api', requireConfigured);
// Departments = top-level dirs minus the special folders.
app.get('/api/departments', wrap(async (_req, res) => {
const entries = await listDir('');
const departments = entries
.filter(e => e.type === 'dir'
&& ![SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, 'signatures'].includes(e.name)
&& !e.name.startsWith('.'))
.map(e => e.name)
.sort((a, b) => a.localeCompare(b, 'de'));
res.json({ departments });
}));
app.post('/api/departments', wrap(async (req, res) => {
const name = (req.body?.name || '').trim();
if (!name) return res.status(400).json({ error: 'Kein Name angegeben' });
if (/[\/\\:*?"<>|]/.test(name)) return res.status(400).json({ error: 'Ungültiger Abteilungsname' });
await createFile(`${name}/.gitkeep`, '', `Abteilung "${name}" angelegt (Web-Editor)`);
res.json({ success: true, name });
}));
// List .html files in any folder. Folder is validated against known prefixes.
const ALLOWED_LIST_PREFIXES = [SHARED_FOLDER, USER_FOLDER, SIG_FOOTERS, SIG_HEADERS];
app.get('/api/files', wrap(async (req, res) => {
const folder = String(req.query.folder || '');
const files = (await listDir(folder))
.filter(f => f.type === 'file' && f.name.endsWith('.html'))
.map(f => ({ name: f.name, path: f.path, sha: f.sha }))
.sort((a, b) => a.name.localeCompare(b.name, 'de'));
res.json({ folder, files });
}));
// Full inventory in ONE request (recursive tree), strukturiert aus den Pfaden.
app.get('/api/tree', wrap(async (_req, res) => {
const files = await listAllFiles();
const special = [SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, 'signatures'];
const dirOf = (p) => { const i = p.lastIndexOf('/'); return i < 0 ? '' : p.slice(0, i); };
const baseOf = (p) => p.slice(p.lastIndexOf('/') + 1);
// .html-Dateien direkt in einem bestimmten Ordner
const htmlIn = (folder) => files
.filter((f) => dirOf(f.path) === folder && baseOf(f.path).endsWith('.html'))
.map((f) => ({ name: baseOf(f.path), path: f.path, sha: f.sha }))
.sort((a, b) => a.name.localeCompare(b.name, 'de'));
// Abteilungen = oberste Ordner außer den Spezialordnern (auch leere, via .gitkeep)
const deptSet = new Set();
for (const f of files) {
const seg = f.path.split('/')[0];
if (f.path.includes('/') && !special.includes(seg) && !seg.startsWith('.')) deptSet.add(seg);
}
const departments = [...deptSet].sort((a, b) => a.localeCompare(b, 'de'));
const templates = { [SHARED_FOLDER]: htmlIn(SHARED_FOLDER) };
departments.forEach((d) => { templates[d] = htmlIn(d); });
const users = {};
for (const f of files) {
const parts = f.path.split('/');
if (parts[0] === USER_FOLDER && parts.length >= 3) users[parts[1]] = users[parts[1]] || [];
}
Object.keys(users).forEach((email) => { users[email] = htmlIn(`${USER_FOLDER}/${email}`); });
res.json({ departments, templates, users, footers: htmlIn(SIG_FOOTERS), headers: htmlIn(SIG_HEADERS) });
}));
// Read a single file. Missing file → exists:false, empty content (editor can create it).
app.get('/api/file', wrap(async (req, res) => {
const filepath = String(req.query.path || '');
if (!filepath || filepath.includes('..')) return res.status(400).json({ error: 'Ungültiger Pfad' });
const data = await getFile(filepath);
if (!data) return res.json({ path: filepath, content: '', sha: null, exists: false });
res.json({ path: filepath, content: fromBase64(data.content), sha: data.sha, exists: true });
}));
// Create or update a file.
app.put('/api/file', wrap(async (req, res) => {
const { path: filepath, content = '', message } = req.body || {};
if (!filepath || filepath.includes('..')) return res.status(400).json({ error: 'Ungültiger Pfad' });
if (!filepath.endsWith('.html') && !filepath.endsWith('.json')) {
return res.status(400).json({ error: 'Nur .html- oder .json-Dateien erlaubt' });
}
const result = await saveFile(filepath, content, message);
res.json({ success: true, ...result });
}));
// Delete a file.
app.delete('/api/file', wrap(async (req, res) => {
const filepath = String(req.body?.path || '');
if (!filepath || filepath.includes('..')) return res.status(400).json({ error: 'Ungültiger Pfad' });
const data = await getFile(filepath);
if (!data) return res.status(404).json({ error: 'Datei nicht gefunden' });
await deleteFile(filepath, data.sha, req.body?.message || `${filepath.split('/').pop()} gelöscht (Web-Editor)`);
res.json({ success: true });
}));
// Read/write the email→department mapping.
app.get('/api/abteilungen', wrap(async (_req, res) => {
const data = await getFile(`${CONFIG_FOLDER}/abteilungen.json`);
let mapping = {};
if (data) { try { mapping = JSON.parse(fromBase64(data.content)); } catch (_) {} }
res.json({ mapping, exists: !!data });
}));
app.put('/api/abteilungen', wrap(async (req, res) => {
const mapping = req.body?.mapping;
if (!mapping || typeof mapping !== 'object') return res.status(400).json({ error: 'Ungültiges Mapping' });
const json = JSON.stringify(mapping, null, 2);
const result = await saveFile(`${CONFIG_FOLDER}/abteilungen.json`, json, 'abteilungen.json aktualisiert (Web-Editor)');
res.json({ success: true, ...result });
}));
// Read/write the Schlagwörter (Thunderbird-Tags) config.
app.get('/api/schlagwoerter', wrap(async (_req, res) => {
const data = await getFile(`${CONFIG_FOLDER}/schlagwoerter.json`);
let tags = [];
if (data) { try { tags = JSON.parse(fromBase64(data.content)); } catch (_) {} }
if (!Array.isArray(tags)) tags = [];
res.json({ tags, exists: !!data });
}));
app.put('/api/schlagwoerter', wrap(async (req, res) => {
const tags = req.body?.tags;
if (!Array.isArray(tags)) return res.status(400).json({ error: 'Ungültige Schlagwörter' });
const json = JSON.stringify(tags, null, 2);
const result = await saveFile(`${CONFIG_FOLDER}/schlagwoerter.json`, json, 'schlagwoerter.json aktualisiert (Web-Editor)');
res.json({ success: true, ...result });
}));
// Delete a whole department folder (all its files). Destructive guarded.
app.delete('/api/departments', wrap(async (req, res) => {
const name = (req.body?.name || '').trim();
if (!name) return res.status(400).json({ error: 'Kein Name angegeben' });
if ([SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, 'signatures'].includes(name) || name.includes('/') || name.includes('..')) {
return res.status(400).json({ error: 'Geschützter oder ungültiger Ordner' });
}
const entries = await listDir(name);
let deleted = 0;
for (const f of entries) {
if (f.type !== 'file') continue;
const fd = await getFile(f.path);
if (fd) { await deleteFile(f.path, fd.sha, `Abteilung "${name}" gelöscht (Web-Editor)`); deleted++; }
}
res.json({ success: true, deleted });
}));
// TinyMCE (selbst gehostet, offline-tauglich) direkt aus node_modules ausliefern.
app.use('/vendor/tinymce', express.static(path.join(__dirname, 'node_modules', 'tinymce')));
// Static frontend.
app.use(express.static(path.join(__dirname, 'public')));
// JSON error handler.
app.use((err, _req, res, _next) => {
console.error('[ERROR]', err.message);
res.status(err.status && err.status >= 400 && err.status < 600 ? err.status : 500)
.json({ error: err.message || 'Interner Fehler' });
});
app.listen(PORT, () => {
console.log(`HPS Web-Editor läuft auf http://localhost:${PORT}`);
if (configured) console.log(`→ Repo: ${GITEA_OWNER}/${GITEA_REPO}@${GITEA_BRANCH} (${GITEA_URL})`);
});