// 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) => `
${name}
${role}
Hotel Park Soltau · Winsener Straße 111 · 29614 Soltau
`; 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': `

Sehr geehrte Damen und Herren,

vielen Dank für Ihre Nachricht an das Hotel Park Soltau. Wir freuen uns über Ihr Interesse und melden uns schnellstmöglich bei Ihnen.

`, '_gemeinsam/Buchungsbestaetigung.html': `

Guten Tag,

wir bestätigen Ihnen hiermit Ihre Buchung im Hotel Park Soltau. Bei Fragen stehen wir Ihnen jederzeit gerne zur Verfügung.

`, 'Rezeption/Anfrage Verfuegbarkeit.html': `

Guten Tag,

gerne prüfen wir die Verfügbarkeit für Ihren gewünschten Zeitraum. Könnten Sie uns bitte folgende Angaben mitteilen?

`, 'Rezeption/Check-in Informationen.html': `

Liebe Gäste,

Ihr Check-in ist ab 15:00 Uhr möglich. Unsere Rezeption ist rund um die Uhr für Sie besetzt. Kostenfreie Parkplätze stehen direkt am Hotel zur Verfügung.

`, 'Veranstaltungsbuero/Angebot Tagung.html': `

Sehr geehrte Damen und Herren,

anbei erhalten Sie unser unverbindliches Angebot für Ihre Tagung. Unsere Veranstaltungsräume bieten Platz für bis zu 120 Personen — inklusive moderner Tagungstechnik.

`, 'IT/Passwort zuruecksetzen.html': `

Hallo,

Ihr Passwort wurde zurückgesetzt. Bitte melden Sie sich mit dem temporären Kennwort an und vergeben Sie umgehend ein neues.

`, '_benutzer/info@hotel-park-soltau.de/Persoenliche Notiz.html': `

Kurzer persönlicher Hinweis — nur für meinen eigenen Gebrauch sichtbar.

`, 'signatures/footers/_default.html': `
Hotel Park Soltau GmbH · Geschäftsführer: M. Mustermann · Amtsgericht Lüneburg HRB 12345
Tel. +49 5191 0000 · hotel-park-soltau.de
`, 'signatures/footers/Rezeption.html': `
Rezeption — rund um die Uhr für Sie da · Tel. +49 5191 0000-0
Hotel Park Soltau · Winsener Straße 111 · 29614 Soltau
`, '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// 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})`); });