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/bezugsquellen/basis.ts | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 159 insertions(+), 0 deletions(-)
diff --git a/packages/bibliothek-server/src/bezugsquellen/basis.ts b/packages/bibliothek-server/src/bezugsquellen/basis.ts
new file mode 100644
index 0000000..3f81807
--- /dev/null
+++ b/packages/bibliothek-server/src/bezugsquellen/basis.ts
@@ -0,0 +1,159 @@
+import * as fs from 'fs';
+import * as path from 'path';
+
+/** Alles, was eine Bezugsquelle für einen Abgleich braucht. */
+export interface Abgleichauftrag {
+ /** Quell-spezifische Angabe: URL, `besitzer/repo`, Paket-ID, Repo-URL … */
+ wert: string;
+ /** Bisher bekannte Versions-/Stand-Kennung. */
+ aktuelleVersion: string;
+ /** Ordner, in dem die bereitgestellte Datei liegen soll. */
+ zielordner: string;
+ /** Arbeitskopie für Quellen, die etwas auschecken/bauen müssen. */
+ arbeitsordner: string;
+ /** Befehl zum Bauen (nur für die Git-Quelle). */
+ bauBefehl: string;
+ /** Pfad des Bau-Ergebnisses relativ zur Arbeitskopie (nur für die Git-Quelle). */
+ bauErgebnis: string;
+ /** Größe der bisher bereitgestellten Datei — für die Plausibilitätsprüfung. */
+ vorherigeGroesse: number;
+ /** Hinterlegte Bau-Geheimnisse, die dem Bau als Umgebungsvariablen mitgegeben werden. */
+ geheimnisse: Record<string, string>;
+ /** SSH-Aufruf für Git-Zugriffe aus den Einstellungen (leer = SSH-Konfiguration entscheidet). */
+ gitSshBefehl: string;
+}
+
+/** Ein Geheimnis, das ein Repository für seinen Bau anfordert. */
+export interface GeheimnisBedarf {
+ name: string;
+ beschreibung: string;
+}
+
+/** Ergebnis eines Quellen-Abgleichs. */
+export interface QuellenAbgleich {
+ erfolg: boolean;
+ /** Wurde eine neue Datei geholt (true) oder war schon alles aktuell (false)? */
+ geaendert: boolean;
+ /** Neue Versions-/Stand-Kennung. */
+ version: string;
+ /** Name der aktuell vorliegenden Datei. */
+ dateiname: string;
+ meldung: string;
+ /** Veröffentlichungsdatum beim Anbieter (ISO), falls die Quelle es kennt. */
+ veroeffentlichtAm?: string;
+ /** Vom Repository angeforderte Bau-Geheimnisse (nur die Git-Quelle liefert das). */
+ benoetigteGeheimnisse?: GeheimnisBedarf[];
+}
+
+/**
+ * Eine Bezugsquelle prüft ihre Quelle und stellt bei Bedarf die aktuelle Datei bereit.
+ * Jede neue Quellenart implementiert nur dieses Interface.
+ */
+export interface Bezugsquelle {
+ gleicheAb(auftrag: Abgleichauftrag): Promise<QuellenAbgleich>;
+}
+
+export function abgleichMisserfolg(version: string, meldung: string): QuellenAbgleich {
+ return { erfolg: false, geaendert: false, version, dateiname: '', meldung };
+}
+
+/** Wandelt eine Datumsangabe der Quelle in ISO um (leer, wenn unbrauchbar). */
+export function alsIsoDatum(rohwert: string | null | undefined): string {
+ if (!rohwert) {
+ return '';
+ }
+ const zeitpunkt = new Date(rohwert);
+ return Number.isNaN(zeitpunkt.getTime()) ? '' : zeitpunkt.toISOString();
+}
+
+// --- Plausibilitätsprüfung ---
+
+/** Unterhalb dieser Größe gilt eine Datei als verdächtig, wenn sie zudem stark geschrumpft ist. */
+const VERDAECHTIG_KLEIN_BYTES = 100 * 1024;
+
+/**
+ * Prüft eine frisch geholte Datei auf typische Anzeichen eines toten oder
+ * übernommenen Links (Fehler-/Anmeldeseite statt Datei, leere oder plötzlich
+ * winzige Datei). Liefert einen Beanstandungstext oder `undefined`, wenn alles passt.
+ */
+export function inhaltBeanstanden(inhalt: Buffer, dateiname: string, inhaltstyp: string | null, vorherigeGroesse: number): string | undefined {
+ if (inhalt.length === 0) {
+ return 'Die Quelle hat eine leere Datei geliefert.';
+ }
+
+ const istWebseiteErwartet = /\.(html?|htm)$/i.test(dateiname);
+ if (!istWebseiteErwartet) {
+ const anfang = inhalt.subarray(0, 200).toString('latin1').trimStart().toLowerCase();
+ if (anfang.startsWith('<!doctype html') || anfang.startsWith('<html')) {
+ return 'Die Quelle hat eine Webseite statt der Datei geliefert — der Link ist vermutlich veraltet oder verlangt eine Anmeldung.';
+ }
+ if (inhaltstyp && inhaltstyp.toLowerCase().includes('text/html')) {
+ return `Die Quelle hat eine Webseite geliefert (${inhaltstyp}) — der Link ist vermutlich veraltet.`;
+ }
+ }
+
+ // Ein plötzlicher Einbruch auf eine winzige Datei deutet auf eine Fehlerseite hin.
+ if (vorherigeGroesse > 0 && inhalt.length < vorherigeGroesse / 2 && inhalt.length < VERDAECHTIG_KLEIN_BYTES) {
+ return `Die geholte Datei ist auffällig klein (${inhalt.length} statt bisher ${vorherigeGroesse} Bytes) — bitte die Quelle prüfen.`;
+ }
+
+ return undefined;
+}
+
+// --- Dateiablage: erst vollständig vorbereiten, dann ersetzen ---
+
+function temporaerenOrdnerAnlegen(zielordner: string): string {
+ const temporaer = `${zielordner}.neu`;
+ fs.rmSync(temporaer, { recursive: true, force: true });
+ fs.mkdirSync(temporaer, { recursive: true });
+ return temporaer;
+}
+
+/**
+ * Ersetzt den Inhalt des Zielordners durch den des temporären Ordners.
+ * Der alte Stand wird erst angefasst, wenn der neue vollständig und geprüft ist —
+ * ein fehlgeschlagener Abgleich kann die funktionierende Datei damit nicht zerstören.
+ */
+function uebernehmeOrdner(temporaer: string, zielordner: string): void {
+ fs.rmSync(zielordner, { recursive: true, force: true });
+ fs.renameSync(temporaer, zielordner);
+}
+
+export function verwerfeOrdner(temporaer: string): void {
+ fs.rmSync(temporaer, { recursive: true, force: true });
+}
+
+/** Legt eine Datei als neuen, alleinigen Inhalt des Zielordners ab (alter Stand bleibt bis zuletzt erhalten). */
+export function ersetzeDateiSicher(zielordner: string, dateiname: string, inhalt: Buffer): void {
+ const temporaer = temporaerenOrdnerAnlegen(zielordner);
+ fs.writeFileSync(path.join(temporaer, dateiname), inhalt);
+ uebernehmeOrdner(temporaer, zielordner);
+}
+
+/**
+ * Für Quellen, die über ein Kommandozeilen-Werkzeug in einen Ordner schreiben:
+ * liefert einen leeren temporären Ordner und übernimmt ihn erst nach erfolgreicher Prüfung.
+ */
+export function mitTemporaeremOrdner(zielordner: string): { ordner: string; uebernehmen: () => void; verwerfen: () => void } {
+ const temporaer = temporaerenOrdnerAnlegen(zielordner);
+ return {
+ ordner: temporaer,
+ uebernehmen: () => uebernehmeOrdner(temporaer, zielordner),
+ verwerfen: () => verwerfeOrdner(temporaer),
+ };
+}
+
+/** Prüft, ob im Zielordner bereits eine bestimmte Datei liegt. */
+export function dateiVorhanden(zielordner: string, dateiname: string): boolean {
+ return !!dateiname && fs.existsSync(path.join(zielordner, dateiname));
+}
+
+/** Leitet einen Dateinamen aus einer URL ab (letztes Pfadsegment ohne Query). */
+export function dateinameAusUrl(url: string): string {
+ try {
+ const segment = decodeURIComponent(new URL(url).pathname.split('/').pop() ?? '');
+ return segment || 'download.bin';
+ } catch {
+ return 'download.bin';
+ }
+}
--
Gitblit v1.9.3