import './warnungen'; // muss vor dem Laden von node:sqlite stehen
|
import express from 'express';
|
import * as fs from 'fs';
|
import * as path from 'path';
|
import * as dns from 'node:dns';
|
import { EINSTUFUNGEN, PROGRAMMARTEN, type Einstufung, type Programmart, type QuelleTyp, type SoftwareEingabe } from 'bibliothek-types';
|
import {
|
initialisiereDatenbank,
|
alleSoftware,
|
softwareNachId,
|
softwareAnlegen,
|
softwareAktualisieren,
|
softwareLoeschen,
|
abgleichSpeichern,
|
zugriffProtokollieren,
|
alleZugriffe,
|
DATEIEN_ORDNER,
|
} from './datenbank';
|
import { gleicheSoftwareAb, gleicheAlleAb } from './abgleich';
|
import { erzeugeZip } from './zip';
|
import { pruefeSignatur } from './signatur';
|
import { loescheGeheimnisse, sichereGeheimnisOrdner, speichereGeheimnisse, uebersichtGeheimnisse } from './geheimnisse';
|
import { erstelleUmzugsPaket, uebernehmeUmzugsPaket } from './umzug';
|
import { istEinzeldatei, oberflaechenOrdner } from './pfade';
|
import { aktuelleEinstellungen, speichereEinstellungen, effektiverDatenOrdner, effektiverPort, konfigurationsDatei } from './konfiguration';
|
|
const PORT = effektiverPort();
|
const ABGLEICH_INTERVALL_MS = 24 * 60 * 60 * 1000; // täglicher Auto-Check
|
|
initialisiereDatenbank();
|
sichereGeheimnisOrdner();
|
|
const anwendung = express();
|
// Umzugs-Pakete können je nach Kataloggröße etwas umfangreicher sein.
|
anwendung.use(express.json({ limit: '25mb' }));
|
|
// --- Zugriffs-Herkunft ---
|
|
function anfrageIp(anfrage: express.Request): string {
|
const roh = anfrage.ip ?? anfrage.socket.remoteAddress ?? '';
|
return roh.replace(/^::ffff:/, ''); // IPv4-mapped IPv6 auf reine IPv4 normalisieren
|
}
|
|
function istLokalerZugriff(anfrage: express.Request): boolean {
|
const ip = anfrageIp(anfrage);
|
return ip === '127.0.0.1' || ip === '::1';
|
}
|
|
/** Middleware: erlaubt die Route nur bei direktem Zugriff am Server (localhost). */
|
function nurLokal(anfrage: express.Request, antwort: express.Response, next: express.NextFunction): void {
|
if (!istLokalerZugriff(anfrage)) {
|
antwort.status(403).json({ fehler: 'Diese Aktion ist nur direkt am Server möglich.' });
|
return;
|
}
|
next();
|
}
|
|
// --- Umgebung & Status ---
|
|
anwendung.get('/api/umgebung', (anfrage, antwort) => {
|
antwort.json({ lokal: istLokalerZugriff(anfrage) });
|
});
|
|
anwendung.get('/api/status', (_anfrage, antwort) => {
|
antwort.json({ status: 'ok', dienst: 'software-bibliothek', softwareAnzahl: alleSoftware().length });
|
});
|
|
// --- Katalog lesen (auch aus dem Netzwerk erreichbar) ---
|
|
anwendung.get('/api/software', (_anfrage, antwort) => {
|
antwort.json(alleSoftware());
|
});
|
|
anwendung.get('/api/software/:id', (anfrage, antwort) => {
|
const eintrag = softwareNachId(Number(anfrage.params.id));
|
if (!eintrag) {
|
antwort.status(404).json({ fehler: 'Software nicht gefunden' });
|
return;
|
}
|
antwort.json(eintrag);
|
});
|
|
// --- Datei-Download (auch aus dem Netzwerk, wird protokolliert) ---
|
|
anwendung.get('/api/software/:id/download', async (anfrage, antwort) => {
|
const eintrag = softwareNachId(Number(anfrage.params.id));
|
if (!eintrag || !eintrag.dateiname) {
|
antwort.status(404).json({ fehler: 'Für diese Software liegt (noch) keine Datei vor.' });
|
return;
|
}
|
const dateipfad = path.join(DATEIEN_ORDNER, String(eintrag.id), eintrag.dateiname);
|
if (!fs.existsSync(dateipfad)) {
|
antwort.status(404).json({ fehler: 'Die Datei fehlt auf dem Server.' });
|
return;
|
}
|
await protokolliereDownload(anfrage, eintrag.name);
|
antwort.download(dateipfad, eintrag.dateiname);
|
});
|
|
// --- Sammel-Download mehrerer Programme als ZIP (auch aus dem Netzwerk) ---
|
|
anwendung.get('/api/download-mehrere', async (anfrage, antwort) => {
|
const ids = String(anfrage.query.ids ?? '')
|
.split(',')
|
.map((eintrag) => Number(eintrag.trim()))
|
.filter((id) => Number.isInteger(id) && id > 0);
|
if (ids.length === 0) {
|
antwort.status(400).json({ fehler: 'Keine Auswahl übergeben' });
|
return;
|
}
|
|
const eintraege = [];
|
const namen: string[] = [];
|
for (const id of ids) {
|
const software = softwareNachId(id);
|
if (!software?.dateiname) {
|
continue;
|
}
|
const dateipfad = path.join(DATEIEN_ORDNER, String(id), software.dateiname);
|
if (!fs.existsSync(dateipfad)) {
|
continue;
|
}
|
// Je Programm ein eigener Ordner im Archiv — verhindert Namenskollisionen.
|
eintraege.push({ dateiname: `${dateinameSaeubern(software.name)}/${software.dateiname}`, inhalt: fs.readFileSync(dateipfad) });
|
namen.push(software.name);
|
}
|
if (eintraege.length === 0) {
|
antwort.status(404).json({ fehler: 'Zu der Auswahl liegen keine Dateien vor.' });
|
return;
|
}
|
|
await protokolliereDownload(anfrage, `${eintraege.length} Programme: ${namen.join(', ')}`);
|
antwort.type('application/zip');
|
antwort.setHeader('Content-Disposition', 'attachment; filename="software-auswahl.zip"');
|
antwort.send(erzeugeZip(eintraege));
|
});
|
|
// --- Katalog verwalten (nur lokal am Server) ---
|
|
anwendung.post('/api/software', nurLokal, (anfrage, antwort) => {
|
const eingabe = normalisiereEingabe(anfrage.body);
|
if (!eingabe.name) {
|
antwort.status(400).json({ fehler: 'Name ist erforderlich' });
|
return;
|
}
|
const angelegt = softwareAnlegen(eingabe);
|
void gleicheSoftwareAb(angelegt); // Datei im Hintergrund holen, Antwort nicht blockieren
|
antwort.status(201).json(angelegt);
|
});
|
|
anwendung.put('/api/software/:id', nurLokal, (anfrage, antwort) => {
|
const eingabe = normalisiereEingabe(anfrage.body);
|
if (!eingabe.name) {
|
antwort.status(400).json({ fehler: 'Name ist erforderlich' });
|
return;
|
}
|
const aktualisiert = softwareAktualisieren(Number(anfrage.params.id), eingabe);
|
if (!aktualisiert) {
|
antwort.status(404).json({ fehler: 'Software nicht gefunden' });
|
return;
|
}
|
void gleicheSoftwareAb(aktualisiert); // Quelle könnte geändert sein → neu holen
|
antwort.json(aktualisiert);
|
});
|
|
anwendung.delete('/api/software/:id', nurLokal, (anfrage, antwort) => {
|
const id = Number(anfrage.params.id);
|
const geloescht = softwareLoeschen(id);
|
if (geloescht) {
|
loescheGeheimnisse(id);
|
}
|
antwort.status(geloescht ? 204 : 404).end();
|
});
|
|
// --- Bezugsquellen-Abgleich (nur lokal am Server) ---
|
|
anwendung.post('/api/software/:id/abgleichen', nurLokal, async (anfrage, antwort) => {
|
const software = softwareNachId(Number(anfrage.params.id));
|
if (!software) {
|
antwort.status(404).json({ fehler: 'Software nicht gefunden' });
|
return;
|
}
|
await gleicheSoftwareAb(software);
|
antwort.json(softwareNachId(software.id));
|
});
|
|
anwendung.post('/api/abgleich/alle', nurLokal, async (_anfrage, antwort) => {
|
await gleicheAlleAb(alleSoftware());
|
antwort.json(alleSoftware());
|
});
|
|
// --- Datei von Hand hinterlegen (Quellentyp „manuell", nur lokal am Server) ---
|
|
anwendung.post('/api/software/:id/datei', nurLokal, (anfrage, antwort) => {
|
const software = softwareNachId(Number(anfrage.params.id));
|
if (!software) {
|
antwort.status(404).json({ fehler: 'Software nicht gefunden' });
|
return;
|
}
|
// basename schützt davor, über den Dateinamen aus dem Ablageordner auszubrechen.
|
const dateiname = path.basename(String(anfrage.query.dateiname ?? '')).trim();
|
if (!dateiname) {
|
antwort.status(400).json({ fehler: 'Dateiname fehlt' });
|
return;
|
}
|
const zielordner = path.join(DATEIEN_ORDNER, String(software.id));
|
fs.rmSync(zielordner, { recursive: true, force: true }); // nur eine aktuelle Datei je Software
|
fs.mkdirSync(zielordner, { recursive: true });
|
|
const zielpfad = path.join(zielordner, dateiname);
|
const zielDatei = fs.createWriteStream(zielpfad);
|
anfrage.pipe(zielDatei);
|
zielDatei.on('finish', () => {
|
void (async () => {
|
const befund = await pruefeSignatur(zielpfad);
|
abgleichSpeichern(software.id, {
|
version: software.version,
|
dateiname,
|
erfolg: true,
|
meldung: 'Manuell hinterlegt.',
|
geaendert: true,
|
signaturStatus: befund.status,
|
signaturHerausgeber: befund.herausgeber,
|
veroeffentlichtAm: software.veroeffentlichtAm,
|
benoetigteGeheimnisse: software.benoetigteGeheimnisse,
|
});
|
antwort.json(softwareNachId(software.id));
|
})();
|
});
|
zielDatei.on('error', (fehler) => {
|
antwort.status(500).json({ fehler: `Datei konnte nicht gespeichert werden: ${fehler.message}` });
|
});
|
});
|
|
// --- Bau-Geheimnisse (nur lokal am Server; Werte verlassen den Server nie) ---
|
|
anwendung.get('/api/software/:id/geheimnisse', nurLokal, (anfrage, antwort) => {
|
antwort.json(uebersichtGeheimnisse(Number(anfrage.params.id)));
|
});
|
|
anwendung.put('/api/software/:id/geheimnisse', nurLokal, (anfrage, antwort) => {
|
const eingehende = Array.isArray(anfrage.body) ? anfrage.body : [];
|
antwort.json(speichereGeheimnisse(Number(anfrage.params.id), eingehende));
|
});
|
|
// --- Umzug auf einen anderen Rechner (nur lokal am Server) ---
|
|
anwendung.get('/api/export', nurLokal, (anfrage, antwort) => {
|
const mitGeheimnissen = anfrage.query.geheimnisse === 'ja';
|
const paket = erstelleUmzugsPaket(mitGeheimnissen);
|
const namenszusatz = mitGeheimnissen ? '-mit-schluesseln' : '';
|
antwort.setHeader('Content-Disposition', `attachment; filename="software-bibliothek${namenszusatz}.json"`);
|
antwort.type('application/json').send(JSON.stringify(paket, null, 2));
|
});
|
|
anwendung.post('/api/import', nurLokal, (anfrage, antwort) => {
|
try {
|
antwort.json(uebernehmeUmzugsPaket(anfrage.body));
|
} catch (fehler) {
|
antwort.status(400).json({ fehler: fehler instanceof Error ? fehler.message : 'Paket konnte nicht eingelesen werden.' });
|
}
|
});
|
|
// --- Einstellungen (nur lokal am Server) ---
|
|
anwendung.get('/api/einstellungen', nurLokal, (_anfrage, antwort) => {
|
antwort.json({
|
einstellungen: aktuelleEinstellungen(),
|
konfigurationsDatei: konfigurationsDatei(),
|
effektiverDatenOrdner: effektiverDatenOrdner(),
|
laufenderPort: PORT,
|
});
|
});
|
|
anwendung.put('/api/einstellungen', nurLokal, (anfrage, antwort) => {
|
const gespeicherte = speichereEinstellungen(anfrage.body ?? {});
|
antwort.json({
|
einstellungen: gespeicherte,
|
konfigurationsDatei: konfigurationsDatei(),
|
// Nach dem Speichern gilt der neue Ordner erst beim nächsten Start —
|
// die Datenbank ist bereits geöffnet.
|
effektiverDatenOrdner: effektiverDatenOrdner(),
|
laufenderPort: PORT,
|
});
|
});
|
|
// --- Zugriffsprotokoll (nur lokal am Server) ---
|
|
anwendung.get('/api/zugriffslog', nurLokal, (_anfrage, antwort) => {
|
antwort.json(alleZugriffe());
|
});
|
|
// --- Hilfsfunktionen ---
|
|
const ERLAUBTE_QUELLEN: Array<QuelleTyp | ''> = ['', 'direkt', 'github', 'winget', 'svn', 'git', 'manuell'];
|
|
/** Säubert die Eingabe und stellt einen gültigen Quell-Typ sicher. */
|
function normalisiereEingabe(koerper: Partial<SoftwareEingabe>): SoftwareEingabe {
|
const typ = ERLAUBTE_QUELLEN.includes((koerper.quelleTyp ?? '') as QuelleTyp) ? (koerper.quelleTyp ?? '') : '';
|
return {
|
name: (koerper.name ?? '').trim(),
|
beschreibung: (koerper.beschreibung ?? '').trim(),
|
elternId: Number(koerper.elternId ?? 0) || 0,
|
quelleTyp: typ as QuelleTyp | '',
|
quelleWert: (koerper.quelleWert ?? '').trim(),
|
bauBefehl: (koerper.bauBefehl ?? '').trim(),
|
bauErgebnis: (koerper.bauErgebnis ?? '').trim(),
|
einstufung: EINSTUFUNGEN.some((eintrag) => eintrag.id === koerper.einstufung) ? (koerper.einstufung as Einstufung) : 'lizenzfrei',
|
programmart: PROGRAMMARTEN.some((eintrag) => eintrag.id === koerper.programmart) ? (koerper.programmart as Programmart) : 'unbestimmt',
|
hinweis: (koerper.hinweis ?? '').trim(),
|
erwarteterHerausgeber: (koerper.erwarteterHerausgeber ?? '').trim(),
|
anleitungUrl: (koerper.anleitungUrl ?? '').trim(),
|
};
|
}
|
|
/** Ersetzt Zeichen, die in Datei-/Ordnernamen nicht zulässig sind. */
|
function dateinameSaeubern(name: string): string {
|
return name.replace(/[\\/:*?"<>|]/g, '_').trim() || 'Programm';
|
}
|
|
/** Schreibt einen Download in das Zugriffsprotokoll (IP, Reverse-DNS-Rechnername, angegebener Name). */
|
async function protokolliereDownload(anfrage: express.Request, softwareName: string): Promise<void> {
|
const ip = anfrageIp(anfrage);
|
let rechnername = '';
|
try {
|
const namen = await dns.promises.reverse(ip);
|
rechnername = namen[0] ?? '';
|
} catch {
|
// Kein Reverse-DNS-Eintrag verfügbar – Rechnername bleibt leer.
|
}
|
const angegebenerName = typeof anfrage.query.name === 'string' ? anfrage.query.name : '';
|
zugriffProtokollieren({
|
zeitpunkt: new Date().toISOString(),
|
ipAdresse: ip,
|
rechnername,
|
angegebenerName,
|
aktion: `Heruntergeladen: ${softwareName}`,
|
});
|
}
|
|
/** Liest ein in die exe eingebettetes Asset als Puffer (undefined, falls nicht vorhanden). */
|
function eingebettetesAsset(name: string): Buffer | undefined {
|
try {
|
const sea = require('node:sea') as { getRawAsset(schluessel: string): ArrayBuffer };
|
return Buffer.from(sea.getRawAsset(name));
|
} catch {
|
return undefined; // Asset nicht eingebettet
|
}
|
}
|
|
/**
|
* Liefert die gebaute Oberfläche aus: als exe aus den eingebetteten Assets,
|
* sonst aus dem dist-Ordner des Client-Pakets. Muss nach den API-Routen stehen.
|
*/
|
function richteOberflaecheEin(): void {
|
if (istEinzeldatei()) {
|
anwendung.use((anfrage, antwort, next) => {
|
if (anfrage.path.startsWith('/api/')) {
|
next();
|
return;
|
}
|
const gewuenscht = anfrage.path === '/' ? 'index.html' : anfrage.path.replace(/^\//, '');
|
const inhalt = eingebettetesAsset(gewuenscht);
|
if (inhalt) {
|
antwort.type(path.extname(gewuenscht) || '.html').send(inhalt);
|
return;
|
}
|
const startseite = eingebettetesAsset('index.html');
|
if (!startseite) {
|
next();
|
return;
|
}
|
antwort.type('.html').send(startseite);
|
});
|
return;
|
}
|
if (fs.existsSync(oberflaechenOrdner())) {
|
anwendung.use(express.static(oberflaechenOrdner()));
|
}
|
}
|
|
richteOberflaecheEin();
|
|
// Täglicher Hintergrund-Abgleich aller Quellen.
|
setInterval(() => {
|
void gleicheAlleAb(alleSoftware());
|
}, ABGLEICH_INTERVALL_MS);
|
|
anwendung.listen(PORT, () => {
|
console.log(`Software-Bibliothek läuft auf http://localhost:${PORT}`);
|
console.log(`Daten: ${effektiverDatenOrdner()}`);
|
console.log(`Einstellungen: ${konfigurationsDatei()}`);
|
});
|