From 98bce1b0da675715b2e5482c0ad61ba21135f62f Mon Sep 17 00:00:00 2001
From: Server-Bibliothek <server-bibliothek@lokal>
Date: Di, 04 Aug 2026 07:00:04 +0200
Subject: [PATCH] Add a desktop client that knows what is installed locally
---
packages/bibliothek-server/src/index.ts | 393 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 393 insertions(+), 0 deletions(-)
diff --git a/packages/bibliothek-server/src/index.ts b/packages/bibliothek-server/src/index.ts
new file mode 100644
index 0000000..a24f6ad
--- /dev/null
+++ b/packages/bibliothek-server/src/index.ts
@@ -0,0 +1,393 @@
+import './warnungen'; // muss vor dem Laden von node:sqlite stehen
+import express from 'express';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as dns from 'node:dns';
+import { EINSTUFUNGEN, PROGRAMMARTEN, type Einstufung, type Programmart, type QuelleTyp, type SoftwareEingabe } from 'bibliothek-types';
+import {
+ initialisiereDatenbank,
+ alleSoftware,
+ softwareNachId,
+ softwareAnlegen,
+ softwareAktualisieren,
+ softwareLoeschen,
+ abgleichSpeichern,
+ zugriffProtokollieren,
+ alleZugriffe,
+ DATEIEN_ORDNER,
+} from './datenbank';
+import { gleicheSoftwareAb, gleicheAlleAb } from './abgleich';
+import { erzeugeZip } from './zip';
+import { pruefeSignatur } from './signatur';
+import { loescheGeheimnisse, sichereGeheimnisOrdner, speichereGeheimnisse, uebersichtGeheimnisse } from './geheimnisse';
+import { erstelleUmzugsPaket, uebernehmeUmzugsPaket } from './umzug';
+import { istEinzeldatei, oberflaechenOrdner } from './pfade';
+import { aktuelleEinstellungen, speichereEinstellungen, effektiverDatenOrdner, effektiverPort, konfigurationsDatei } from './konfiguration';
+
+const PORT = effektiverPort();
+const ABGLEICH_INTERVALL_MS = 24 * 60 * 60 * 1000; // täglicher Auto-Check
+
+initialisiereDatenbank();
+sichereGeheimnisOrdner();
+
+const anwendung = express();
+// Umzugs-Pakete können je nach Kataloggröße etwas umfangreicher sein.
+anwendung.use(express.json({ limit: '25mb' }));
+
+// --- Zugriffs-Herkunft ---
+
+function anfrageIp(anfrage: express.Request): string {
+ const roh = anfrage.ip ?? anfrage.socket.remoteAddress ?? '';
+ return roh.replace(/^::ffff:/, ''); // IPv4-mapped IPv6 auf reine IPv4 normalisieren
+}
+
+function istLokalerZugriff(anfrage: express.Request): boolean {
+ const ip = anfrageIp(anfrage);
+ return ip === '127.0.0.1' || ip === '::1';
+}
+
+/** Middleware: erlaubt die Route nur bei direktem Zugriff am Server (localhost). */
+function nurLokal(anfrage: express.Request, antwort: express.Response, next: express.NextFunction): void {
+ if (!istLokalerZugriff(anfrage)) {
+ antwort.status(403).json({ fehler: 'Diese Aktion ist nur direkt am Server möglich.' });
+ return;
+ }
+ next();
+}
+
+// --- Umgebung & Status ---
+
+anwendung.get('/api/umgebung', (anfrage, antwort) => {
+ antwort.json({ lokal: istLokalerZugriff(anfrage) });
+});
+
+anwendung.get('/api/status', (_anfrage, antwort) => {
+ antwort.json({ status: 'ok', dienst: 'software-bibliothek', softwareAnzahl: alleSoftware().length });
+});
+
+// --- Katalog lesen (auch aus dem Netzwerk erreichbar) ---
+
+anwendung.get('/api/software', (_anfrage, antwort) => {
+ antwort.json(alleSoftware());
+});
+
+anwendung.get('/api/software/:id', (anfrage, antwort) => {
+ const eintrag = softwareNachId(Number(anfrage.params.id));
+ if (!eintrag) {
+ antwort.status(404).json({ fehler: 'Software nicht gefunden' });
+ return;
+ }
+ antwort.json(eintrag);
+});
+
+// --- Datei-Download (auch aus dem Netzwerk, wird protokolliert) ---
+
+anwendung.get('/api/software/:id/download', async (anfrage, antwort) => {
+ const eintrag = softwareNachId(Number(anfrage.params.id));
+ if (!eintrag || !eintrag.dateiname) {
+ antwort.status(404).json({ fehler: 'Für diese Software liegt (noch) keine Datei vor.' });
+ return;
+ }
+ const dateipfad = path.join(DATEIEN_ORDNER, String(eintrag.id), eintrag.dateiname);
+ if (!fs.existsSync(dateipfad)) {
+ antwort.status(404).json({ fehler: 'Die Datei fehlt auf dem Server.' });
+ return;
+ }
+ await protokolliereDownload(anfrage, eintrag.name);
+ antwort.download(dateipfad, eintrag.dateiname);
+});
+
+// --- Sammel-Download mehrerer Programme als ZIP (auch aus dem Netzwerk) ---
+
+anwendung.get('/api/download-mehrere', async (anfrage, antwort) => {
+ const ids = String(anfrage.query.ids ?? '')
+ .split(',')
+ .map((eintrag) => Number(eintrag.trim()))
+ .filter((id) => Number.isInteger(id) && id > 0);
+ if (ids.length === 0) {
+ antwort.status(400).json({ fehler: 'Keine Auswahl übergeben' });
+ return;
+ }
+
+ const eintraege = [];
+ const namen: string[] = [];
+ for (const id of ids) {
+ const software = softwareNachId(id);
+ if (!software?.dateiname) {
+ continue;
+ }
+ const dateipfad = path.join(DATEIEN_ORDNER, String(id), software.dateiname);
+ if (!fs.existsSync(dateipfad)) {
+ continue;
+ }
+ // Je Programm ein eigener Ordner im Archiv — verhindert Namenskollisionen.
+ eintraege.push({ dateiname: `${dateinameSaeubern(software.name)}/${software.dateiname}`, inhalt: fs.readFileSync(dateipfad) });
+ namen.push(software.name);
+ }
+ if (eintraege.length === 0) {
+ antwort.status(404).json({ fehler: 'Zu der Auswahl liegen keine Dateien vor.' });
+ return;
+ }
+
+ await protokolliereDownload(anfrage, `${eintraege.length} Programme: ${namen.join(', ')}`);
+ antwort.type('application/zip');
+ antwort.setHeader('Content-Disposition', 'attachment; filename="software-auswahl.zip"');
+ antwort.send(erzeugeZip(eintraege));
+});
+
+// --- Katalog verwalten (nur lokal am Server) ---
+
+anwendung.post('/api/software', nurLokal, (anfrage, antwort) => {
+ const eingabe = normalisiereEingabe(anfrage.body);
+ if (!eingabe.name) {
+ antwort.status(400).json({ fehler: 'Name ist erforderlich' });
+ return;
+ }
+ const angelegt = softwareAnlegen(eingabe);
+ void gleicheSoftwareAb(angelegt); // Datei im Hintergrund holen, Antwort nicht blockieren
+ antwort.status(201).json(angelegt);
+});
+
+anwendung.put('/api/software/:id', nurLokal, (anfrage, antwort) => {
+ const eingabe = normalisiereEingabe(anfrage.body);
+ if (!eingabe.name) {
+ antwort.status(400).json({ fehler: 'Name ist erforderlich' });
+ return;
+ }
+ const aktualisiert = softwareAktualisieren(Number(anfrage.params.id), eingabe);
+ if (!aktualisiert) {
+ antwort.status(404).json({ fehler: 'Software nicht gefunden' });
+ return;
+ }
+ void gleicheSoftwareAb(aktualisiert); // Quelle könnte geändert sein → neu holen
+ antwort.json(aktualisiert);
+});
+
+anwendung.delete('/api/software/:id', nurLokal, (anfrage, antwort) => {
+ const id = Number(anfrage.params.id);
+ const geloescht = softwareLoeschen(id);
+ if (geloescht) {
+ loescheGeheimnisse(id);
+ }
+ antwort.status(geloescht ? 204 : 404).end();
+});
+
+// --- Bezugsquellen-Abgleich (nur lokal am Server) ---
+
+anwendung.post('/api/software/:id/abgleichen', nurLokal, async (anfrage, antwort) => {
+ const software = softwareNachId(Number(anfrage.params.id));
+ if (!software) {
+ antwort.status(404).json({ fehler: 'Software nicht gefunden' });
+ return;
+ }
+ await gleicheSoftwareAb(software);
+ antwort.json(softwareNachId(software.id));
+});
+
+anwendung.post('/api/abgleich/alle', nurLokal, async (_anfrage, antwort) => {
+ await gleicheAlleAb(alleSoftware());
+ antwort.json(alleSoftware());
+});
+
+// --- Datei von Hand hinterlegen (Quellentyp „manuell", nur lokal am Server) ---
+
+anwendung.post('/api/software/:id/datei', nurLokal, (anfrage, antwort) => {
+ const software = softwareNachId(Number(anfrage.params.id));
+ if (!software) {
+ antwort.status(404).json({ fehler: 'Software nicht gefunden' });
+ return;
+ }
+ // basename schützt davor, über den Dateinamen aus dem Ablageordner auszubrechen.
+ const dateiname = path.basename(String(anfrage.query.dateiname ?? '')).trim();
+ if (!dateiname) {
+ antwort.status(400).json({ fehler: 'Dateiname fehlt' });
+ return;
+ }
+ const zielordner = path.join(DATEIEN_ORDNER, String(software.id));
+ fs.rmSync(zielordner, { recursive: true, force: true }); // nur eine aktuelle Datei je Software
+ fs.mkdirSync(zielordner, { recursive: true });
+
+ const zielpfad = path.join(zielordner, dateiname);
+ const zielDatei = fs.createWriteStream(zielpfad);
+ anfrage.pipe(zielDatei);
+ zielDatei.on('finish', () => {
+ void (async () => {
+ const befund = await pruefeSignatur(zielpfad);
+ abgleichSpeichern(software.id, {
+ version: software.version,
+ dateiname,
+ erfolg: true,
+ meldung: 'Manuell hinterlegt.',
+ geaendert: true,
+ signaturStatus: befund.status,
+ signaturHerausgeber: befund.herausgeber,
+ veroeffentlichtAm: software.veroeffentlichtAm,
+ benoetigteGeheimnisse: software.benoetigteGeheimnisse,
+ });
+ antwort.json(softwareNachId(software.id));
+ })();
+ });
+ zielDatei.on('error', (fehler) => {
+ antwort.status(500).json({ fehler: `Datei konnte nicht gespeichert werden: ${fehler.message}` });
+ });
+});
+
+// --- Bau-Geheimnisse (nur lokal am Server; Werte verlassen den Server nie) ---
+
+anwendung.get('/api/software/:id/geheimnisse', nurLokal, (anfrage, antwort) => {
+ antwort.json(uebersichtGeheimnisse(Number(anfrage.params.id)));
+});
+
+anwendung.put('/api/software/:id/geheimnisse', nurLokal, (anfrage, antwort) => {
+ const eingehende = Array.isArray(anfrage.body) ? anfrage.body : [];
+ antwort.json(speichereGeheimnisse(Number(anfrage.params.id), eingehende));
+});
+
+// --- Umzug auf einen anderen Rechner (nur lokal am Server) ---
+
+anwendung.get('/api/export', nurLokal, (anfrage, antwort) => {
+ const mitGeheimnissen = anfrage.query.geheimnisse === 'ja';
+ const paket = erstelleUmzugsPaket(mitGeheimnissen);
+ const namenszusatz = mitGeheimnissen ? '-mit-schluesseln' : '';
+ antwort.setHeader('Content-Disposition', `attachment; filename="software-bibliothek${namenszusatz}.json"`);
+ antwort.type('application/json').send(JSON.stringify(paket, null, 2));
+});
+
+anwendung.post('/api/import', nurLokal, (anfrage, antwort) => {
+ try {
+ antwort.json(uebernehmeUmzugsPaket(anfrage.body));
+ } catch (fehler) {
+ antwort.status(400).json({ fehler: fehler instanceof Error ? fehler.message : 'Paket konnte nicht eingelesen werden.' });
+ }
+});
+
+// --- Einstellungen (nur lokal am Server) ---
+
+anwendung.get('/api/einstellungen', nurLokal, (_anfrage, antwort) => {
+ antwort.json({
+ einstellungen: aktuelleEinstellungen(),
+ konfigurationsDatei: konfigurationsDatei(),
+ effektiverDatenOrdner: effektiverDatenOrdner(),
+ laufenderPort: PORT,
+ });
+});
+
+anwendung.put('/api/einstellungen', nurLokal, (anfrage, antwort) => {
+ const gespeicherte = speichereEinstellungen(anfrage.body ?? {});
+ antwort.json({
+ einstellungen: gespeicherte,
+ konfigurationsDatei: konfigurationsDatei(),
+ // Nach dem Speichern gilt der neue Ordner erst beim nächsten Start —
+ // die Datenbank ist bereits geöffnet.
+ effektiverDatenOrdner: effektiverDatenOrdner(),
+ laufenderPort: PORT,
+ });
+});
+
+// --- Zugriffsprotokoll (nur lokal am Server) ---
+
+anwendung.get('/api/zugriffslog', nurLokal, (_anfrage, antwort) => {
+ antwort.json(alleZugriffe());
+});
+
+// --- Hilfsfunktionen ---
+
+const ERLAUBTE_QUELLEN: Array<QuelleTyp | ''> = ['', 'direkt', 'github', 'winget', 'svn', 'git', 'manuell'];
+
+/** Säubert die Eingabe und stellt einen gültigen Quell-Typ sicher. */
+function normalisiereEingabe(koerper: Partial<SoftwareEingabe>): SoftwareEingabe {
+ const typ = ERLAUBTE_QUELLEN.includes((koerper.quelleTyp ?? '') as QuelleTyp) ? (koerper.quelleTyp ?? '') : '';
+ return {
+ name: (koerper.name ?? '').trim(),
+ beschreibung: (koerper.beschreibung ?? '').trim(),
+ elternId: Number(koerper.elternId ?? 0) || 0,
+ quelleTyp: typ as QuelleTyp | '',
+ quelleWert: (koerper.quelleWert ?? '').trim(),
+ bauBefehl: (koerper.bauBefehl ?? '').trim(),
+ bauErgebnis: (koerper.bauErgebnis ?? '').trim(),
+ einstufung: EINSTUFUNGEN.some((eintrag) => eintrag.id === koerper.einstufung) ? (koerper.einstufung as Einstufung) : 'lizenzfrei',
+ programmart: PROGRAMMARTEN.some((eintrag) => eintrag.id === koerper.programmart) ? (koerper.programmart as Programmart) : 'unbestimmt',
+ hinweis: (koerper.hinweis ?? '').trim(),
+ erwarteterHerausgeber: (koerper.erwarteterHerausgeber ?? '').trim(),
+ anleitungUrl: (koerper.anleitungUrl ?? '').trim(),
+ };
+}
+
+/** Ersetzt Zeichen, die in Datei-/Ordnernamen nicht zulässig sind. */
+function dateinameSaeubern(name: string): string {
+ return name.replace(/[\\/:*?"<>|]/g, '_').trim() || 'Programm';
+}
+
+/** Schreibt einen Download in das Zugriffsprotokoll (IP, Reverse-DNS-Rechnername, angegebener Name). */
+async function protokolliereDownload(anfrage: express.Request, softwareName: string): Promise<void> {
+ const ip = anfrageIp(anfrage);
+ let rechnername = '';
+ try {
+ const namen = await dns.promises.reverse(ip);
+ rechnername = namen[0] ?? '';
+ } catch {
+ // Kein Reverse-DNS-Eintrag verfügbar – Rechnername bleibt leer.
+ }
+ const angegebenerName = typeof anfrage.query.name === 'string' ? anfrage.query.name : '';
+ zugriffProtokollieren({
+ zeitpunkt: new Date().toISOString(),
+ ipAdresse: ip,
+ rechnername,
+ angegebenerName,
+ aktion: `Heruntergeladen: ${softwareName}`,
+ });
+}
+
+/** Liest ein in die exe eingebettetes Asset als Puffer (undefined, falls nicht vorhanden). */
+function eingebettetesAsset(name: string): Buffer | undefined {
+ try {
+ const sea = require('node:sea') as { getRawAsset(schluessel: string): ArrayBuffer };
+ return Buffer.from(sea.getRawAsset(name));
+ } catch {
+ return undefined; // Asset nicht eingebettet
+ }
+}
+
+/**
+ * Liefert die gebaute Oberfläche aus: als exe aus den eingebetteten Assets,
+ * sonst aus dem dist-Ordner des Client-Pakets. Muss nach den API-Routen stehen.
+ */
+function richteOberflaecheEin(): void {
+ if (istEinzeldatei()) {
+ anwendung.use((anfrage, antwort, next) => {
+ if (anfrage.path.startsWith('/api/')) {
+ next();
+ return;
+ }
+ const gewuenscht = anfrage.path === '/' ? 'index.html' : anfrage.path.replace(/^\//, '');
+ const inhalt = eingebettetesAsset(gewuenscht);
+ if (inhalt) {
+ antwort.type(path.extname(gewuenscht) || '.html').send(inhalt);
+ return;
+ }
+ const startseite = eingebettetesAsset('index.html');
+ if (!startseite) {
+ next();
+ return;
+ }
+ antwort.type('.html').send(startseite);
+ });
+ return;
+ }
+ if (fs.existsSync(oberflaechenOrdner())) {
+ anwendung.use(express.static(oberflaechenOrdner()));
+ }
+}
+
+richteOberflaecheEin();
+
+// Täglicher Hintergrund-Abgleich aller Quellen.
+setInterval(() => {
+ void gleicheAlleAb(alleSoftware());
+}, ABGLEICH_INTERVALL_MS);
+
+anwendung.listen(PORT, () => {
+ console.log(`Software-Bibliothek läuft auf http://localhost:${PORT}`);
+ console.log(`Daten: ${effektiverDatenOrdner()}`);
+ console.log(`Einstellungen: ${konfigurationsDatei()}`);
+});
--
Gitblit v1.9.3