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>
This commit is contained in:
510
web-editor/server.js
Normal file
510
web-editor/server.js
Normal file
@@ -0,0 +1,510 @@
|
||||
// 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ü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.');
|
||||
}
|
||||
|
||||
// ── 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})`);
|
||||
});
|
||||
Reference in New Issue
Block a user