Server-Bibliothek
vor 17 Std. 98bce1b0da675715b2e5482c0ad61ba21135f62f
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
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';
  }
}