- 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>
581 lines
25 KiB
JavaScript
581 lines
25 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 } };
|
||
}
|
||
|
||
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ür Ihre Nachricht an das <strong>Hotel Park Soltau</strong>. Wir freuen uns über Ihr Interesse und melden uns schnellstmö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ätigen Ihnen hiermit Ihre Buchung im Hotel Park Soltau. Bei Fragen stehen wir Ihnen jederzeit gerne zur Verfü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ühstü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üfen wir die Verfügbarkeit für Ihren gewünschten Zeitraum. Kö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äste,</p>
|
||
<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">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.</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ür Ihre Tagung. Unsere Veranstaltungsräume bieten Platz für bis zu 120 Personen — 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ückgesetzt. Bitte melden Sie sich mit dem temporä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önlicher Hinweis — nur fü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äftsführer: M. Mustermann · Amtsgericht Lü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> — rund um die Uhr für Sie da · Tel. +49 5191 0000-0<br>
|
||
Hotel Park Soltau · Winsener Straß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})`);
|
||
});
|