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/datenbank.ts |  313 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 313 insertions(+), 0 deletions(-)

diff --git a/packages/bibliothek-server/src/datenbank.ts b/packages/bibliothek-server/src/datenbank.ts
new file mode 100644
index 0000000..c3f188e
--- /dev/null
+++ b/packages/bibliothek-server/src/datenbank.ts
@@ -0,0 +1,313 @@
+import { DatabaseSync } from 'node:sqlite';
+import * as fs from 'fs';
+import * as path from 'path';
+import type { AbgleichErgebnis, GeheimnisBedarf, Software, SoftwareEingabe, ZugriffslogEintrag } from 'bibliothek-types';
+import { effektiverDatenOrdner } from './konfiguration';
+
+// Ablageort aus den Einstellungen (Standard: Ordner „data" neben der Anwendung).
+const datenOrdner = effektiverDatenOrdner();
+
+/** Ablageordner für die bereitgestellten Programm-Dateien (Downloads), je Software ein Unterordner. */
+export const DATEIEN_ORDNER = path.join(datenOrdner, 'files');
+
+/** Arbeitskopien der Quellen, die etwas auschecken/bauen müssen (Git), je Software ein Unterordner. */
+export const QUELLEN_ORDNER = path.join(datenOrdner, 'sources');
+
+/**
+ * Bau-Geheimnisse (z. B. API-Schlüssel), die vor dem Bau in die Arbeitskopie gelegt werden.
+ * Wird nirgends ausgeliefert — die Download-Route bedient nur `files/`.
+ */
+export const GEHEIMNIS_ORDNER = path.join(datenOrdner, 'secrets');
+
+/**
+ * Frühere Fassungen legten die Unterordner unter deutschen Namen an. Beim Start
+ * einmalig umbenennen — sonst stünde eine bestehende Installation ohne ihre
+ * Dateien da. Existieren beide Namen, bleibt der alte unangetastet; das von Hand
+ * zu klären ist sicherer, als stillschweigend zusammenzuführen.
+ */
+function benenneAlteOrdnerUm(): void {
+  for (const [alterName, neuerName] of [
+    ['dateien', 'files'],
+    ['quellen', 'sources'],
+    ['geheimnisse', 'secrets'],
+  ]) {
+    const alterPfad = path.join(datenOrdner, alterName);
+    const neuerPfad = path.join(datenOrdner, neuerName);
+    if (!fs.existsSync(alterPfad)) {
+      continue;
+    }
+    if (fs.existsSync(neuerPfad)) {
+      console.warn(`Ordner „${alterName}" und „${neuerName}" liegen beide vor — bitte von Hand zusammenführen.`);
+      continue;
+    }
+    fs.renameSync(alterPfad, neuerPfad);
+    console.log(`Datenordner umbenannt: ${alterName} → ${neuerName}`);
+  }
+}
+
+benenneAlteOrdnerUm();
+
+fs.mkdirSync(DATEIEN_ORDNER, { recursive: true });
+fs.mkdirSync(QUELLEN_ORDNER, { recursive: true });
+fs.mkdirSync(GEHEIMNIS_ORDNER, { recursive: true });
+
+export const datenbank = new DatabaseSync(path.join(datenOrdner, 'bibliothek.db'));
+datenbank.exec('PRAGMA journal_mode = WAL');
+
+/** Legt die Tabellen an und migriert fehlende Spalten. Bestehende Daten bleiben erhalten. */
+export function initialisiereDatenbank(): void {
+  datenbank.exec(`
+    CREATE TABLE IF NOT EXISTS software (
+      id              INTEGER PRIMARY KEY AUTOINCREMENT,
+      name            TEXT NOT NULL,
+      beschreibung    TEXT NOT NULL DEFAULT '',
+      elternId        INTEGER NOT NULL DEFAULT 0,
+      quelleTyp       TEXT NOT NULL DEFAULT '',
+      quelleWert      TEXT NOT NULL DEFAULT '',
+      bauBefehl       TEXT NOT NULL DEFAULT '',
+      bauErgebnis     TEXT NOT NULL DEFAULT '',
+      einstufung      TEXT NOT NULL DEFAULT 'lizenzfrei',
+      programmart     TEXT NOT NULL DEFAULT 'unbestimmt',
+      hinweis         TEXT NOT NULL DEFAULT '',
+      anleitungUrl    TEXT NOT NULL DEFAULT '',
+      version         TEXT NOT NULL DEFAULT '',
+      dateiname       TEXT NOT NULL DEFAULT '',
+      dateiStandVom   TEXT NOT NULL DEFAULT '',
+      veroeffentlichtAm     TEXT NOT NULL DEFAULT '',
+      signaturStatus  TEXT NOT NULL DEFAULT '',
+      signaturHerausgeber   TEXT NOT NULL DEFAULT '',
+      erwarteterHerausgeber TEXT NOT NULL DEFAULT '',
+      benoetigteGeheimnisse TEXT NOT NULL DEFAULT '[]',
+      letzterAbgleich TEXT NOT NULL DEFAULT '',
+      abgleichErfolg  INTEGER NOT NULL DEFAULT 0,
+      abgleichMeldung TEXT NOT NULL DEFAULT '',
+      erstelltAm      TEXT NOT NULL,
+      aktualisiertAm  TEXT NOT NULL
+    );
+
+    CREATE TABLE IF NOT EXISTS zugriffslog (
+      id               INTEGER PRIMARY KEY AUTOINCREMENT,
+      zeitpunkt        TEXT NOT NULL,
+      ipAdresse        TEXT NOT NULL DEFAULT '',
+      rechnername      TEXT NOT NULL DEFAULT '',
+      angegebenerName  TEXT NOT NULL DEFAULT '',
+      aktion           TEXT NOT NULL DEFAULT ''
+    );
+  `);
+
+  // Migration bestehender Datenbanken: neue Spalten ergänzen, falls sie fehlen.
+  const neueSpalten: Array<[string, string]> = [
+    ['quelleTyp', "TEXT NOT NULL DEFAULT ''"],
+    ['quelleWert', "TEXT NOT NULL DEFAULT ''"],
+    ['elternId', 'INTEGER NOT NULL DEFAULT 0'],
+    ['dateiStandVom', "TEXT NOT NULL DEFAULT ''"],
+    ['veroeffentlichtAm', "TEXT NOT NULL DEFAULT ''"],
+    ['signaturStatus', "TEXT NOT NULL DEFAULT ''"],
+    ['signaturHerausgeber', "TEXT NOT NULL DEFAULT ''"],
+    ['erwarteterHerausgeber', "TEXT NOT NULL DEFAULT ''"],
+    ['benoetigteGeheimnisse', "TEXT NOT NULL DEFAULT '[]'"],
+    ['bauBefehl', "TEXT NOT NULL DEFAULT ''"],
+    ['bauErgebnis', "TEXT NOT NULL DEFAULT ''"],
+    ['einstufung', "TEXT NOT NULL DEFAULT 'lizenzfrei'"],
+    ['programmart', "TEXT NOT NULL DEFAULT 'unbestimmt'"],
+    ['hinweis', "TEXT NOT NULL DEFAULT ''"],
+    ['anleitungUrl', "TEXT NOT NULL DEFAULT ''"],
+    ['letzterAbgleich', "TEXT NOT NULL DEFAULT ''"],
+    ['abgleichErfolg', 'INTEGER NOT NULL DEFAULT 0'],
+    ['abgleichMeldung', "TEXT NOT NULL DEFAULT ''"],
+  ];
+  for (const [spalte, definition] of neueSpalten) {
+    spalteErgaenzenFallsFehlt('software', spalte, definition);
+  }
+
+  // Ablösung des früheren Ja/Nein-Feldes durch die dreiteilige Einstufung.
+  if (spalteVorhanden('software', 'lizenzpflichtig')) {
+    datenbank.exec("UPDATE software SET einstufung = 'lizenzpflichtig' WHERE lizenzpflichtig = 1");
+    datenbank.exec('ALTER TABLE software DROP COLUMN lizenzpflichtig');
+  }
+
+  // Kategorien haben sich als überflüssig erwiesen — die Einstufung gliedert den Katalog.
+  if (spalteVorhanden('software', 'kategorie')) {
+    datenbank.exec('ALTER TABLE software DROP COLUMN kategorie');
+  }
+}
+
+function spalteVorhanden(tabelle: string, spalte: string): boolean {
+  const vorhandene = datenbank.prepare(`PRAGMA table_info(${tabelle})`).all() as Array<{ name: string }>;
+  return vorhandene.some((eintrag) => eintrag.name === spalte);
+}
+
+function spalteErgaenzenFallsFehlt(tabelle: string, spalte: string, definition: string): void {
+  if (!spalteVorhanden(tabelle, spalte)) {
+    datenbank.exec(`ALTER TABLE ${tabelle} ADD COLUMN ${spalte} ${definition}`);
+  }
+}
+
+// --- Software-Katalog ---
+
+/** Wandelt eine DB-Zeile in ein Software-Objekt (SQLite kennt kein boolean → INTEGER 0/1). */
+function zeileZuSoftware(zeile: Record<string, unknown>): Software {
+  return {
+    ...zeile,
+    abgleichErfolg: Boolean(zeile.abgleichErfolg),
+    benoetigteGeheimnisse: alsGeheimnisBedarf(zeile.benoetigteGeheimnisse),
+  } as Software;
+}
+
+/** Die angeforderten Geheimnisse liegen als JSON-Text in der Spalte. */
+function alsGeheimnisBedarf(rohwert: unknown): GeheimnisBedarf[] {
+  if (typeof rohwert !== 'string' || !rohwert.trim()) {
+    return [];
+  }
+  try {
+    const gelesen = JSON.parse(rohwert) as GeheimnisBedarf[];
+    return Array.isArray(gelesen) ? gelesen : [];
+  } catch {
+    return [];
+  }
+}
+
+export function alleSoftware(): Software[] {
+  const zeilen = datenbank.prepare('SELECT * FROM software ORDER BY name COLLATE NOCASE').all() as Array<Record<string, unknown>>;
+  return zeilen.map(zeileZuSoftware);
+}
+
+export function softwareNachId(id: number): Software | undefined {
+  const zeile = datenbank.prepare('SELECT * FROM software WHERE id = ?').get(id) as Record<string, unknown> | undefined;
+  return zeile ? zeileZuSoftware(zeile) : undefined;
+}
+
+export function softwareAnlegen(eingabe: SoftwareEingabe): Software {
+  const jetzt = new Date().toISOString();
+  const ergebnis = datenbank
+    .prepare(
+      `INSERT INTO software (name, beschreibung, elternId, quelleTyp, quelleWert, bauBefehl, bauErgebnis, einstufung, programmart, hinweis, anleitungUrl, erwarteterHerausgeber, erstelltAm, aktualisiertAm)
+       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+    )
+    .run(
+      eingabe.name,
+      eingabe.beschreibung,
+      eingabe.elternId,
+      eingabe.quelleTyp,
+      eingabe.quelleWert,
+      eingabe.bauBefehl,
+      eingabe.bauErgebnis,
+      eingabe.einstufung,
+      eingabe.programmart,
+      eingabe.hinweis,
+      eingabe.anleitungUrl,
+      eingabe.erwarteterHerausgeber,
+      jetzt,
+      jetzt
+    );
+  return softwareNachId(Number(ergebnis.lastInsertRowid)) as Software;
+}
+
+export function softwareAktualisieren(id: number, eingabe: SoftwareEingabe): Software | undefined {
+  if (!softwareNachId(id)) {
+    return undefined;
+  }
+  const jetzt = new Date().toISOString();
+  datenbank
+    .prepare(
+      `UPDATE software SET name = ?, beschreibung = ?, elternId = ?, quelleTyp = ?, quelleWert = ?,
+       bauBefehl = ?, bauErgebnis = ?, einstufung = ?, programmart = ?, hinweis = ?, anleitungUrl = ?,
+       erwarteterHerausgeber = ?, aktualisiertAm = ?
+       WHERE id = ?`
+    )
+    .run(
+      eingabe.name,
+      eingabe.beschreibung,
+      eingabe.elternId,
+      eingabe.quelleTyp,
+      eingabe.quelleWert,
+      eingabe.bauBefehl,
+      eingabe.bauErgebnis,
+      eingabe.einstufung,
+      eingabe.programmart,
+      eingabe.hinweis,
+      eingabe.anleitungUrl,
+      eingabe.erwarteterHerausgeber,
+      jetzt,
+      id
+    );
+  return softwareNachId(id);
+}
+
+/**
+ * Löscht eine Software samt ihrer Zusatzinhalte (ein Zusatzinhalt ohne sein Tool
+ * wäre sinnlos) und räumt die zugehörigen Ordner mit ab — sonst bleiben die
+ * bereitgestellten Dateien und Arbeitskopien für immer liegen.
+ */
+export function softwareLoeschen(id: number): boolean {
+  for (const zusatzinhalt of datenbank.prepare('SELECT id FROM software WHERE elternId = ?').all(id) as Array<{ id: number }>) {
+    entferneOrdnerVon(Number(zusatzinhalt.id));
+  }
+  datenbank.prepare('DELETE FROM software WHERE elternId = ?').run(id);
+  const ergebnis = datenbank.prepare('DELETE FROM software WHERE id = ?').run(id);
+  if (Number(ergebnis.changes) > 0) {
+    entferneOrdnerVon(id);
+    return true;
+  }
+  return false;
+}
+
+/** Entfernt bereitgestellte Datei und Arbeitskopie einer Software. */
+export function entferneOrdnerVon(id: number): void {
+  fs.rmSync(path.join(DATEIEN_ORDNER, String(id)), { recursive: true, force: true });
+  fs.rmSync(path.join(QUELLEN_ORDNER, String(id)), { recursive: true, force: true });
+}
+
+/** Schreibt das Ergebnis eines Bezugsquellen-Abgleichs zurück (Version, Datei, Status). */
+export function abgleichSpeichern(id: number, ergebnis: AbgleichErgebnis): void {
+  const jetzt = new Date().toISOString();
+  // Der Dateistand wird nur fortgeschrieben, wenn wirklich eine neue Datei kam —
+  // eine reine Prüfung ohne Änderung ist kein Update.
+  const bisheriger = softwareNachId(id)?.dateiStandVom ?? '';
+  datenbank
+    .prepare(
+      `UPDATE software SET version = ?, dateiname = ?, dateiStandVom = ?, veroeffentlichtAm = ?, signaturStatus = ?, signaturHerausgeber = ?,
+       benoetigteGeheimnisse = ?, letzterAbgleich = ?, abgleichErfolg = ?, abgleichMeldung = ?, aktualisiertAm = ?
+       WHERE id = ?`
+    )
+    .run(
+      ergebnis.version,
+      ergebnis.dateiname,
+      ergebnis.geaendert ? jetzt : bisheriger,
+      ergebnis.veroeffentlichtAm,
+      ergebnis.signaturStatus,
+      ergebnis.signaturHerausgeber,
+      JSON.stringify(ergebnis.benoetigteGeheimnisse),
+      jetzt,
+      ergebnis.erfolg ? 1 : 0,
+      ergebnis.meldung,
+      jetzt,
+      id
+    );
+}
+
+/**
+ * Übernimmt beim Umzug bekannte Zusatzangaben, ohne einen Abgleich vorzutäuschen:
+ * `letzterAbgleich` bleibt leer, damit der Eintrag als „noch nicht geholt" gilt
+ * und nicht als Problemfall erscheint.
+ */
+export function umzugsAngabenSetzen(id: number, veroeffentlichtAm: string, benoetigteGeheimnisse: GeheimnisBedarf[]): void {
+  datenbank
+    .prepare('UPDATE software SET veroeffentlichtAm = ?, benoetigteGeheimnisse = ? WHERE id = ?')
+    .run(veroeffentlichtAm, JSON.stringify(benoetigteGeheimnisse), id);
+}
+
+// --- Zugriffsprotokoll ---
+
+export function zugriffProtokollieren(eintrag: Omit<ZugriffslogEintrag, 'id'>): void {
+  datenbank
+    .prepare(
+      `INSERT INTO zugriffslog (zeitpunkt, ipAdresse, rechnername, angegebenerName, aktion)
+       VALUES (?, ?, ?, ?, ?)`
+    )
+    .run(eintrag.zeitpunkt, eintrag.ipAdresse, eintrag.rechnername, eintrag.angegebenerName, eintrag.aktion);
+}
+
+export function alleZugriffe(): ZugriffslogEintrag[] {
+  return datenbank.prepare('SELECT * FROM zugriffslog ORDER BY id DESC').all() as unknown as ZugriffslogEintrag[];
+}

--
Gitblit v1.9.3