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