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[];
|
}
|