// 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) => `
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.
Guten Tag,
gerne prüfen wir die Verfügbarkeit für Ihren gewünschten Zeitraum. Könnten Sie uns bitte folgende Angaben mitteilen?
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 |
| Rezeption — rund um die Uhr für Sie da · Tel. +49 5191 0000-0 Hotel Park Soltau · Winsener Straße 111 · 29614 Soltau |