Files
Kendrick Bollens eff90e9517 Auto-Update über Gitea einrichten + Web-Editor + Sync-Verbesserungen
- Thunderbird Auto-Update: update_url im Manifest, updates.json, release.sh
- .xpi neu gebaut (mit update_url, ohne defaults.local.json/Token)
- README + CLAUDE.md: Auto-Update-Doku, Repo muss public bleiben
- web-editor/ (Node/Docker WYSIWYG-Editor) hinzugefügt
- gitea-sync.js + templates_options: bestehende Anpassungen

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

511 lines
22 KiB
JavaScript

// 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 } };
}
// ── 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.');
}
// ── 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 shot: departments + all template/footer/header files.
app.get('/api/tree', wrap(async (_req, res) => {
const top = await listDir('');
const departments = top
.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'));
const mapFiles = (entries) => entries
.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'));
// Templates: shared + each department (personal user folders listed separately).
const templateFolders = [SHARED_FOLDER, ...departments];
const templates = {};
await Promise.all(templateFolders.map(async (folder) => {
templates[folder] = mapFiles(await listDir(folder));
}));
// Personal user template folders under _benutzer/<email>/
const userEntries = await listDir(USER_FOLDER);
const users = {};
await Promise.all(userEntries
.filter(e => e.type === 'dir')
.map(async (e) => { users[e.name] = mapFiles(await listDir(e.path)); }));
const footers = mapFiles(await listDir(SIG_FOOTERS));
const headers = mapFiles(await listDir(SIG_HEADERS));
res.json({ departments, templates, users, footers, 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 });
}));
// 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})`);
});