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