3 Dateien geändert
63 Dateien hinzugefügt
23 Dateien gelöscht
16436 ■■■■ Geänderte Dateien
.gitignore 31 ●●●●● Patch | Ansicht | Raw | Blame | Historie
.npmrc 1 ●●●● Patch | Ansicht | Raw | Blame | Historie
.prettierrc.json 4 ●●●● Patch | Ansicht | Raw | Blame | Historie
ARCHITEKTUR.md 397 ●●●●● Patch | Ansicht | Raw | Blame | Historie
CHANGELOG.md 148 ●●●●● Patch | Ansicht | Raw | Blame | Historie
README.md 248 ●●●●● Patch | Ansicht | Raw | Blame | Historie
Update-Quellen-Uebersicht.md 89 ●●●●● Patch | Ansicht | Raw | Blame | Historie
app.py 1031 ●●●●● Patch | Ansicht | Raw | Blame | Historie
automatische_pruefung.py 98 ●●●●● Patch | Ansicht | Raw | Blame | Historie
automatisierung_einrichten.bat 40 ●●●●● Patch | Ansicht | Raw | Blame | Historie
bauen-exe.cjs 73 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/assets/icon.ico Patch | Ansicht | Raw | Blame | Historie
desktop/backend/__init__.py 6 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/backend/__main__.py 52 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/backend/api.py 180 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/backend/bibliothek.py 85 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/backend/konfiguration.py 74 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/backend/werkzeuge.py 153 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/build_exe.ps1 76 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/frontend/package.json 29 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/frontend/src/bruecke.ts 69 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/frontend/src/components/client-app.ts 284 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/frontend/src/components/client-zeile.ts 135 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/frontend/src/global.css 200 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/frontend/src/index.html 11 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/frontend/src/index.ts 15 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/frontend/src/raw.d.ts 5 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/frontend/src/theme.ts 58 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/frontend/tsconfig.json 15 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/frontend/webpack.config.js 56 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/generate_icon.py 40 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/requirements.txt 6 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/run_app.py 10 ●●●●● Patch | Ansicht | Raw | Blame | Historie
firewall-freigabe-einrichten.bat 30 ●●●●● Patch | Ansicht | Raw | Blame | Historie
install.bat 40 ●●●●● Patch | Ansicht | Raw | Blame | Historie
installers/LIESMICH.txt 1 ●●●● Patch | Ansicht | Raw | Blame | Historie
package-lock.json 6554 ●●●●● Patch | Ansicht | Raw | Blame | Historie
package.json 22 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-client/package.json 28 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-client/src/api-client.ts 130 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-client/src/components/bibliothek-app.ts 345 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-client/src/components/bibliothek-einstellungen.ts 224 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-client/src/components/bibliothek-formular.ts 441 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-client/src/components/bibliothek-zeile.ts 235 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-client/src/global.css 336 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-client/src/globals.d.ts 8 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-client/src/index.html 11 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-client/src/index.ts 19 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-client/src/theme.ts 58 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-client/tsconfig.json 15 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-client/webpack.config.js 61 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-server/package.json 21 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-server/src/abgleich.ts 108 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-server/src/bezugsquellen/basis.ts 159 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-server/src/bezugsquellen/direkt-link.ts 40 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-server/src/bezugsquellen/git-build.ts 244 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-server/src/bezugsquellen/github.ts 95 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-server/src/bezugsquellen/index.ts 25 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-server/src/bezugsquellen/svn.ts 53 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-server/src/bezugsquellen/winget.ts 81 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-server/src/datenbank.ts 313 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-server/src/geheimnisse.ts 127 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-server/src/index.ts 393 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-server/src/konfiguration.ts 107 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-server/src/pfade.ts 45 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-server/src/signatur.ts 66 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-server/src/umzug.ts 131 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-server/src/warnungen.ts 14 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-server/src/zip.ts 107 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-server/tsconfig.json 14 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-types/package.json 10 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-types/src/index.ts 160 ●●●●● Patch | Ansicht | Raw | Blame | Historie
packages/bibliothek-types/tsconfig.json 16 ●●●●● Patch | Ansicht | Raw | Blame | Historie
pruefe_updates.bat 10 ●●●●● Patch | Ansicht | Raw | Blame | Historie
requirements.txt 1 ●●●● Patch | Ansicht | Raw | Blame | Historie
software-bibliothek.json 5 ●●●●● Patch | Ansicht | Raw | Blame | Historie
start.bat 17 ●●●●● Patch | Ansicht | Raw | Blame | Historie
static/style.css 578 ●●●●● Patch | Ansicht | Raw | Blame | Historie
templates/assignment_form.html 91 ●●●●● Patch | Ansicht | Raw | Blame | Historie
templates/base.html 50 ●●●●● Patch | Ansicht | Raw | Blame | Historie
templates/dashboard.html 72 ●●●●● Patch | Ansicht | Raw | Blame | Historie
templates/einrichten.html 97 ●●●●● Patch | Ansicht | Raw | Blame | Historie
templates/protokoll_detail.html 63 ●●●●● Patch | Ansicht | Raw | Blame | Historie
templates/protokolle.html 48 ●●●●● Patch | Ansicht | Raw | Blame | Historie
templates/software_detail.html 168 ●●●●● Patch | Ansicht | Raw | Blame | Historie
templates/software_form.html 176 ●●●●● Patch | Ansicht | Raw | Blame | Historie
templates/zugriff_eingeschraenkt.html 27 ●●●●● Patch | Ansicht | Raw | Blame | Historie
templates/zugriffslog.html 56 ●●●●● Patch | Ansicht | Raw | Blame | Historie
updater.py 371 ●●●●● Patch | Ansicht | Raw | Blame | Historie
.gitignore
@@ -1,15 +1,26 @@
# Echte Datenbank und Nutzdaten - nicht versionieren, ist Bestand des jeweiligen Servers
data/bibliothek.db
data/doku_bilder/
# Von Nutzern abgelegte Installer-Dateien (Pfad ist frei waehlbar, dieser Ordner ist nur ein Vorschlag)
installers/*
!installers/LIESMICH.txt
# Python
# Abhaengigkeiten
node_modules
.venv
__pycache__/
*.pyc
# Betriebssystem
# Build-Ausgaben
dist
build
build-exe
exe/
*.spec
# Logs
*.log
# Laufzeit-Daten: Datenbank und geholte Programm-Dateien
# (gehoeren dem jeweiligen Server, nicht ins Repo)
data/
# Editor / Betriebssystem / Tooling
.idea
.vscode
.claude
Thumbs.db
.DS_Store
.npmrc
Neue Datei
@@ -0,0 +1 @@
@dh-software:registry=https://npm.furnco.de
.prettierrc.json
Neue Datei
@@ -0,0 +1,4 @@
{
  "printWidth": 200,
  "singleQuote": true
}
ARCHITEKTUR.md
@@ -1,272 +1,207 @@
# Architektur-Dokumentation â€“ Server-Bibliothek
# Architektur
Diese Datei ist für die technische Ãœbergabe gedacht: sie erklärt, wie das
Projekt aufgebaut ist, welche Entscheidungen warum getroffen wurden, und was
bei einer Anpassung an eine andere Infrastruktur zu beachten ist.
Für die Bedienungsanleitung (Endnutzer-Sicht) siehe `README.md`.
Für die Entwicklungshistorie (was wurde wann gebaut) siehe `CHANGELOG.md`.
Die technische Innensicht: wie das Projekt aufgebaut ist, welche Entscheidungen warum so
getroffen wurden und wo die Grenzen liegen. Für Betrieb und Bedienung siehe
[README.md](README.md).
---
## 1. Tech-Stack
## 1. Grundriss
- **Python 3.8+**, **Flask** (Web-Framework) â€“ einzige externe Abhängigkeit
  laut `requirements.txt`
- **SQLite** (`data/bibliothek.db`) â€“ eine einzelne Datei, kein Datenbankserver
- **Server-seitig gerenderte HTML-Templates** (Jinja2), kein JavaScript-Framework,
  kein Build-Prozess â€“ nur Vanilla-JS für Interaktivität (Checklisten,
  Formular-Verhalten)
- Läuft als einfacher Flask-Entwicklungsserver (`app.run(host="0.0.0.0", ...)`)
  â€“ **nicht produktionsgehärtet**, siehe Abschnitt 7 "Bekannte Grenzen"
npm-Workspaces mit drei Paketen. Die Typen liegen bewusst dazwischen: Server und
Oberfläche teilen sich einen Vertrag, den der Ãœbersetzer prüft â€” Ã¤ndert sich ein Feld,
bricht die Ãœbersetzung auf beiden Seiten.
Bewusste Design-Entscheidung: keine Frameworks/Build-Tools, damit eine Person
ohne Programmiererfahrung (Zielgruppe dieses Projekts) das System mit einem
Doppelklick starten kann, ohne `npm install` o. Ã„.
| Paket | Rolle |
|---|---|
| `bibliothek-types` | Gemeinsame Typen. Kennt weder Server noch Browser |
| `bibliothek-server` | Express, SQLite, Bezugsquellen, Zugriffsgrenze |
| `bibliothek-client` | Oberfläche aus `@dh-software/dh-components` (Lit) |
## 2. Projektstruktur
TypeScript durchgehend im `strict`-Modus.
```
server-bibliothek/
├── app.py                          Haupt-Flask-Anwendung, alle Routen
├── updater.py                      Update-Check-Logik (winget/GitHub/Direkt-Link)
├── automatische_pruefung.py        Eigenständiges Skript für geplante Update-Checks
├── requirements.txt                Python-Abhängigkeiten (nur Flask)
├── data/
│   â”œâ”€â”€ bibliothek.db               SQLite-Datenbank (wird beim ersten Start angelegt)
│   â””── doku_bilder/<software_id>/  Hochgeladene Doku-Screenshots
├── installers/                     Standard-Ablageort für Installer-Dateien
│                                   (Nutzer koennen aber JEDEN Pfad eintragen,
│                                   dieser Ordner ist nur ein Vorschlag)
├── static/style.css                Gesamtes Styling (ein File, keine Build-Pipeline)
├── templates/                      Jinja2-HTML-Templates
├── install.bat                     Einmalige Einrichtung (Python-Abhaengigkeiten)
├── start.bat                       Startet den Webserver
├── pruefe_updates.bat              Manueller Update-Check-Lauf
├── automatisierung_einrichten.bat  Richtet taeglichen Update-Check per Windows-Aufgabenplanung ein
├── firewall-freigabe-einrichten.bat Oeffnet Port 5000 in der Windows-Firewall
├── Update-Quellen-Uebersicht.md    Nachschlagehilfe fuer winget-IDs/GitHub-Pfade
├── README.md                       Bedienungsanleitung
├── ARCHITEKTUR.md                  Diese Datei
└── CHANGELOG.md                    Entwicklungshistorie
### Warum Node und nicht Flask
Der Ã¼bernommene Prototyp war Flask. Der Umbau auf Node hatte zwei Gründe: Die Oberfläche
sollte auf dem hauseigenen Komponenten-Satz aufsetzen, und der Auslieferungsstand sollte
eine einzelne Programmdatei ohne installierte Laufzeitumgebung sein. Beides zieht Node
nach sich.
### Warum keine Datenbankbibliothek
Ab Node 22 bringt die Laufzeitumgebung SQLite mit (`node:sqlite`). Damit entfällt eine
native Abhängigkeit, die für jede Node-Version neu Ã¼bersetzt werden müsste â€” genau daran
war ein erster Versuch mit `better-sqlite3` gescheitert. Journalmodus WAL, damit Lesen
während eines laufenden Abgleichs nicht blockiert.
---
## 2. Bezugsquellen
Das Herzstück. Jede Art, an eine Datei zu kommen, ist eine Klasse hinter derselben
Schnittstelle:
```ts
interface Bezugsquelle {
  gleicheAb(auftrag: Abgleichauftrag): Promise<QuellenAbgleich>;
}
```
## 3. Datenbankschema
Alle Tabellen werden in `init_db()` (app.py) angelegt. Migrationen (neue
Spalten) laufen automatisch beim Start via `ALTER TABLE ... ADD COLUMN`,
bestehende Daten bleiben dabei erhalten.
### `software` â€“ der Tool-Katalog
| Spalte | Typ | Bedeutung |
|---|---|---|
| id | INTEGER PK | |
| name | TEXT | Anzeigename, wird auch für Dateinamen-Ableitung sanitisiert |
| kategorie | TEXT | Freitext, für Gruppierung in Checklisten |
| version | TEXT | Bei "direkt"-Update-Quelle: technische Kennung (ETag), keine echte Versionsnummer |
| installer_pfad | TEXT | Ordner **oder** Datei-Pfad, siehe Abschnitt 4 "Ordner-Konvention" |
| install_parameter | TEXT | Silent-Install-Flags, z. B. `/S` oder `/quiet /norestart` |
| dokumentation | TEXT | Freitext-Anleitung |
| letzte_pruefung | TEXT (Datum) | Für die Status-Anzeige (Aktuell/Bitte prüfen/Nie geprüft) |
| erstellt_am | TEXT (Zeitstempel) | |
| auto_update | INTEGER (0/1) | Automatische Aktualisierung aktiv? |
| update_quelle | TEXT | `winget` \| `github` \| `direkt` \| leer |
| update_wert | TEXT | Paket-ID / `besitzer/projekt` / URL, je nach Quelle |
### `zuweisungen` â€“ einzelne Tool-Zuweisungen (Zeilen eines Protokolls)
| Spalte | Bedeutung |
| Typ | Vorgehen |
|---|---|
| id, software_id | |
| protokoll_id | FK auf `protokolle` (siehe unten) |
| mitarbeiter, rechnername | Denormalisiert hier UND im Protokoll gespeichert (Absicht: die Installationsverlauf-Ansicht pro Software bleibt dadurch einfach) |
| installierte_version, installiert_von, installiert_am, notiz | |
| `direkt` | HTTP-Abruf eines festen Links |
| `github` | Neuestes Release Ã¼ber die GitHub-Schnittstelle, passendes Asset wählen |
| `winget` | `winget download`, danach `winget show` für das Veröffentlichungsdatum |
| `svn` | `svn export` einer Datei |
| `git` | Repository klonen bzw. aktualisieren, bauen, Ergebnis Ã¼bernehmen |
| `manuell` | Kein Abruf â€” die Datei wird einmalig hinterlegt und bleibt liegen |
### `protokolle` â€“ ein Eintrag pro Rechner-Einrichtung
| Spalte | Bedeutung |
|---|---|
| id, mitarbeiter, rechnername, datum, erstellt_von, notiz, erstellt_zeitpunkt | |
Eine neue Quelle anzubinden heißt: eine Klasse schreiben und in der Ãœbersicht eintragen.
Der Rest â€” Abgleich, Prüfung, Speicherung, Anzeige â€” bleibt unberührt.
Ein Protokoll fasst mehrere `zuweisungen`-Zeilen zusammen (1 Protokoll : n
Zuweisungen), damit bei z. B. 40 Mitarbeitern Ã— 40 Tools keine unübersichtliche
flache Liste entsteht, sondern eine Ãœbersicht pro Person/Rechner.
### Nichts wird ersetzt, bevor es geprüft ist
### `update_log` â€“ Protokoll jedes automatischen Update-Checks
| Spalte | Bedeutung |
|---|---|
| id, software_id, zeitpunkt, alte_version, neue_version, erfolg (0/1), meldung | |
Der teuerste Fehler wäre, eine funktionierende Datei gegen Müll zu tauschen, weil eine
Quelle stillschweigend etwas anderes liefert als erwartet. Deshalb schreibt keine Quelle
direkt in den Zielordner:
### `doku_bilder` â€“ hochgeladene Screenshots zur Dokumentation
| Spalte | Bedeutung |
|---|---|
| id, software_id, dateiname, hochgeladen_am | Datei liegt unter `data/doku_bilder/<software_id>/<dateiname>` |
1. Herunterladen bzw. bauen in `<zielordner>.neu`
2. `inhaltBeanstanden()` prüft: leer? HTML statt Programmdatei (Signatur und
   Inhaltstyp)? plötzlich unter der Hälfte der bisherigen Größe und kleiner als 100 KB?
3. Erst wenn nichts zu beanstanden ist, wird getauscht â€” sonst verworfen
## 4. Kernkonzept: Ordner-Konvention (`updater.py`)
Der Eintrag meldet dann das Problem, behält aber seine bisherige Datei. Eine tote Quelle
kostet Aktualität, nie den Download.
Das Feld `installer_pfad` kann zwei Formen haben:
- **Datei-Pfad** (endet auf `.exe`/`.msi`/etc.): Ã¤ltere/explizite Schreibweise
- **Ordner-Pfad** (kein Dateiname): empfohlene, aktuelle Schreibweise
### Selbstgebaute Werkzeuge
Die Funktion `updater.effektiver_ablageordner(installer_pfad, name)` ist die
zentrale Logik: sie sorgt **immer** dafür, dass jedes Tool in einem eigenen
Unterordner landet (benannt nach dem sanitisierten Tool-Namen), selbst wenn
im Feld nur ein geteilter Basisordner steht oder ein alter kompletter
Dateipfad. Das verhindert, dass mehrere Tools sich einen Ordner teilen und
Dateien "nackt" nebeneinander landen (siehe CHANGELOG, Runden r6–r8, dort
wurde das mehrfach nachgeschärft, nachdem reale Testfälle Lücken zeigten).
Bei `git` beschreibt das Repository seinen eigenen Bau in `software-bibliothek.json`:
`updater.verwaltete_datei_pfad(installer_pfad, name)` findet die aktuell
abgelegte Datei (falls vorhanden), inkl. Rückwärtskompatibilität zu Dateien,
die noch am alten, nicht verschachtelten Ort liegen (Übergangslogik).
```json
{
  "buildCommand": "…",
  "artifact": "dist/MeinWerkzeug.exe",
  "secrets": [{ "name": "…", "description": "…" }]
}
```
**Wichtig für Weiterentwicklung:** Jede neue Stelle im Code, die mit
Installer-Dateien arbeitet, MUSS Ã¼ber diese beiden Funktionen gehen, statt
`installer_pfad` direkt zu verwenden â€“ sonst wird die
Unterordner-Garantie umgangen.
Damit muss die Bibliothek nichts Ã¼ber einzelne Werkzeuge wissen â€” ein neues anzubinden
erfordert keine Ã„nderung an ihr. Gebaut wird nur, wenn sich der Commit geändert hat.
Zeitgrenze 30 Minuten, weil Bauvorgänge mit vielen Abhängigkeiten lange dauern.
## 5. Automatische Aktualisierung (drei Quellen)
Die Kennung für den Zugriff kommt aus den Einstellungen (`GIT_SSH_COMMAND`), nicht aus der
URL. So bleibt der Katalogeintrag frei von Kontoangaben, und ein Kontowechsel ist eine
einzige Ã„nderung statt einer pro Eintrag.
`updater.pruefe_software(name, quelle, update_wert, aktuelle_version, installer_pfad)`
ist der Einstiegspunkt, delegiert an:
### Bau-Geheimnisse
- **`pruefe_winget`** â€“ ruft `winget show`/`winget download` auf (nur unter
  Windows verfügbar; auf Nicht-Windows-Systemen liefert es einen sauberen
  Fehler statt eines Absturzes)
- **`pruefe_github`** â€“ nutzt die Ã¶ffentliche GitHub-API
  (`api.github.com/repos/.../releases/latest`), sucht ein `.exe`/`.msi`-Asset
- **`pruefe_direkt`** â€“ fester Download-Link, Versionsvergleich Ã¼ber
  HTTP-`ETag`/`Last-Modified`-Header statt echter Versionsnummer
Werte liegen getrennt vom Katalog in `secrets/<id>.json` und werden beim Bau als
Umgebungsvariablen gesetzt â€” sie berühren die Platte der Arbeitskopie nie. Das Verfahren
im Ganzen beschreibt der Skill `build-secrets` im Repository `dh-claude-rules`.
Alle drei prüfen zusätzlich, ob die Datei **physisch noch existiert** â€“ auch
bei gleicher Versionsnummer wird neu heruntergeladen, falls die Datei manuell
gelöscht wurde (Bug wurde in Runde r5 gefunden und behoben).
Geschützt sind sie durch Dateiberechtigungen (`icacls`, beschränkt auf SYSTEM,
Administratoren und das Dienstkonto, bei jedem Start neu gesetzt), durch die
Zugriffsgrenze und dadurch, dass sie in keiner aus dem Netzwerk lesbaren Schnittstelle
auftauchen. Eine Verschlüsselung wäre unter Windows nur Ã¼ber die Datenschutz-Schnittstelle
des Betriebssystems sinnvoll; die ist aus der Laufzeitumgebung nicht ohne Zusatzpaket
erreichbar.
Vor jedem Ersetzen einer Datei wird Ã¼ber `sichern_und_ersetzen()` eine
Sicherheitskopie in einen `_sicherungen`-Unterordner gelegt.
---
## 6. "Rechner einrichten" â€“ Verteilungs-Mechanismus
## 3. Zugriffsgrenze
Route `/einrichten` (app.py) erzeugt bei Bedarf ein individuelles
Installations-Paket (ZIP mit `einrichten.ps1` + `Rechner-einrichten-starten.bat`
**+ den echten Installer-Dateien/ZIPs selbst** unter `tools/<id>.<endung>`),
statt vorab für jede Mitarbeiter/Tool-Kombination ein festes Skript zu pflegen.
Kein Anmeldeverfahren, sondern eine Grenze an der Netzwerkschnittstelle: Eine Middleware
prüft, ob die Anfrage von `localhost` kommt. Verwaltende Routen tragen sie, lesende nicht.
**Wichtige Design-Entscheidung (Runde r16):** Die Installer-Dateien werden
bereits beim Erzeugen des Pakets (auf dem PC, der die Checkliste ausfüllt)
eingebettet, nicht erst beim Ausführen auf dem Zielrechner nachgeladen. Ein
früherer Ansatz lud die Dateien per `Invoke-WebRequest` zur Laufzeit vom
Server nach â€“ das erforderte Netzwerkzugriff des Zielrechners auf den Server
in genau dem Moment der Einrichtung, was in der Praxis nicht immer gegeben
ist (z. B. Rechner noch nicht ans Firmennetz angebunden). Jetzt braucht der
Zielrechner für die eigentliche Installation **kein Netzwerk mehr** â€“ nur die
abschließende, optionale Meldung ans zentrale Protokoll (`/zuweisungen/neu`)
versucht noch einen Netzwerkzugriff, scheitert dieser, bleibt trotzdem das
lokale Protokoll (`protokoll.log`) vollständig.
- Frei erreichbar: Katalog lesen, einzeln oder als ZIP herunterladen, Umgebung abfragen
- Nur lokal: Anlegen, Ã„ndern, Löschen, Abgleich anstoßen, Datei hinterlegen, Geheimnisse,
  Export/Import, Einstellungen, Zugriffsprotokoll
Ablauf beim Ausführen auf dem Zielrechner:
1. `.bat` kopiert den **kompletten entpackten Ordner** (inkl. `tools/`-
   Unterordner mit den echten Installer-Dateien) nach `C:\ClientBibliothek`
   (per `xcopy /E /I /Y`)
2. Self-Elevation (Admin-Rechte-Anfrage) Ã¼ber `Start-Process -Verb RunAs`
3. Automatische Windows-Defender-Ausnahme für `C:\ClientBibliothek`
   (`Add-MpPreference -ExclusionPath`) â€“ ein einziger Pfad statt vieler
   Einzelausnahmen
4. Pro ausgewähltem Tool: entweder stille Installation aus der **lokal
   mitgelieferten** Datei unter `tools/<id>.<endung>` (mit hinterlegtem
   Silent-Parameter, `.msi` läuft Ã¼ber `msiexec`) oder â€“ bei Tools ohne
   einzelne Installer-Datei ("Ordner-Typ", z. B. portable Programme wie
   Blender) â€“ die mitgelieferte ZIP-Datei wird nach `C:\ClientBibliothek\pakete\`
   kopiert, **bewusst ohne automatisches Entpacken** (Entscheidung in Runde
   r14: der Zielort für portable Tools variiert, das soll eine Person bewusst
   entscheiden)
5. Lokales Protokoll unter `C:\ClientBibliothek\protokoll.log`
6. Optionaler POST an `/zuweisungen/neu` auf dem Server, um das zentrale
   Protokoll zu befüllen (Rechnername wird dabei Ã¼ber `$env:COMPUTERNAME`
   automatisch erkannt) â€“ best effort, kein Abbruch bei Fehlschlag
Die Oberfläche fragt beim Start `/api/umgebung` und blendet aus, was ohnehin abgewiesen
würde. Das ist Bequemlichkeit, keine Absicherung â€” die liegt beim Server.
Die Ordner-Struktur unter `C:\ClientBibliothek`:
- `\tools` â€“ die mitgelieferten Installer-Dateien/ZIPs (Quelle für die
  Installation, kommen direkt aus dem heruntergeladenen Paket)
- `\pakete` â€“ ZIPs von portablen Tools, bleiben bewusst liegen
- `\protokoll.log` â€“ lokale Mitschrift
Das passt zum Auftrag („Vollzugriff nur am Server") und kommt ohne Benutzerverwaltung aus.
Der Preis: Wer am Server sitzt, darf alles. Rollen und Rechte sind im Ticket bewusst
zurückgestellt.
Serverseitig teilen sich `/einrichten` (POST) und `/software/<id>/download`
die Hilfsfunktion `_zippe_ordner_in_bytes()` für das ZIP-Packen eines
Ordners, um Code-Duplikation zu vermeiden.
---
**Der Client-Ordner-Pfad ist eine Konstante** (`CLIENT_ORDNER` in `app.py`,
aktuell `C:\ClientBibliothek`) â€“ zentral anpassbar, falls die Infrastruktur
einen anderen Standardpfad vorgibt.
## 4. Oberfläche
## 6a. Zugriffsbeschränkung: lokal (Server) vs. Netzwerk
Aufgebaut aus `@dh-software/dh-components`, damit das Werkzeug aussieht wie der Rest des
Hauses. Zwei Besonderheiten:
Seit Runde r17 unterscheidet die Anwendung, ob eine Anfrage direkt vom Server
selbst kommt (z. B. per RDP-Sitzung, im Browser dort `localhost`/`127.0.0.1`
aufgerufen) oder Ã¼ber das Netzwerk von einem anderen Rechner.
**Light DOM statt Shadow DOM.** Die Komponenten werden Ã¼ber `createRenderRoot()` ins Light
DOM gerendert, sonst greifen die globalen `::part()`-Regeln des Themes nicht.
- `ist_lokaler_zugriff()` prüft `request.remote_addr` gegen `127.0.0.1`/`::1`
- `@app.before_request`-Hook `_zugriff_beschraenken()` blockiert für
  Netzwerk-Zugriffe alle Routen außer denen in `OEFFENTLICHE_ENDPUNKTE`
  (aktuell nur `einrichten` und `static`) mit einer 403-Seite
- Die Seite `/einrichten` zeigt sich bei Netzwerkzugriff unter anderem Namen
  ("Software anfordern" statt "Rechner einrichten") und mit Hinweis auf die
  Protokollierung
- Jeder Aufruf/Download Ã¼ber `/einrichten` wird in der Tabelle `zugriffslog`
  festgehalten (Zeitpunkt, IP-Adresse, per Reverse-DNS ermittelter
  Rechnername falls auflösbar, selbst angegebener Name, Aktion) â€“ einsehbar
  unter `/zugriffslog` (selbst wieder nur lokal erreichbar)
**Themewechsel zur Laufzeit.** Zwei `<style>`-Elemente mit den Farbdefinitionen für hell
und dunkel, umgeschaltet Ã¼ber `.disabled`. Drei Modi: System, hell, dunkel. Nach dem
Vorbild des Meeting-Transkriptors, damit sich die Werkzeuge gleich anfühlen.
**Wichtige Einschränkung:** Ein echter Windows-Benutzername lässt sich Ã¼ber
reines HTTP **nicht** zuverlässig ermitteln â€“ dafür wäre eine
NTLM/Kerberos-Integration nötig (z. B. Ã¼ber einen IIS-Reverse-Proxy vor
Flask, der die Windows-Anmeldung Ã¼bernimmt und den Benutzernamen per Header
an die Flask-App durchreicht). Das ist in diesem Prototyp **nicht**
umgesetzt. Aktuell basiert die Identifizierung auf der IP-Adresse, einem per
Reverse-DNS aufgelösten Rechnernamen (funktioniert nur, wenn im
Firmennetzwerk DNS-Einträge für Client-Rechner existieren) und dem selbst im
Formular angegebenen Namen (nicht verifiziert). Für eine belastbarere
Identifizierung müsste diese IIS/NTLM-Integration nachgerüstet werden.
Der Katalog ist eine kompakte Liste statt Karten â€” er soll dreistellige Zahlen an
Einträgen aushalten. Reiter trennen lizenzfrei, lizenzpflichtig und intern; die
Mehrfachauswahl wirkt Ã¼ber Reitergrenzen hinweg und lädt als ZIP.
**Anpassung an andere Infrastruktur:** Sollte der Server z. B. hinter einem
Reverse-Proxy laufen, liefert `request.remote_addr` ggf. die Proxy-IP statt
der echten Client-IP â€“ in dem Fall müssten `X-Forwarded-For`-Header
ausgewertet werden (aktuell nicht implementiert, da kein Proxy im Einsatz).
Zusatzinhalte (Sprachpakete, Erweiterungen) hängen Ã¼ber `elternId` an ihrem Werkzeug und
stehen eingerückt darunter, ohne eigene Trennlinie â€” Werkzeug und Zubehör lesen sich als
ein Block.
## 7. Bekannte Grenzen / nicht vollständig getestet
---
- **Keine echte Authentifizierung, nur Herkunfts-Beschränkung.** Seit r17
  ist die Verwaltung (Katalog, Protokolle, Zugriffsprotokoll) nur noch vom
  Server selbst aus erreichbar (siehe Abschnitt 6a) â€“ das ist aber eine
  Netzwerk-/IP-basierte Einschränkung, **kein Login mit Benutzername/Passwort**.
  Wer direkt am Server sitzt (oder sich als diesen ausgibt, z. B. Ã¼ber
  IP-Spoofing im selben Netzwerksegment), hat vollen Zugriff. Für ein
  belastbares Sicherheitskonzept mit echten Benutzerkonten wäre ein
  richtiges Login-System nötig.
- **Kein HTTPS/TLS.** Die Kommunikation läuft unverschlüsselt Ã¼ber HTTP. Für
  ein internes, abgeschottetes Netzwerk meist unkritisch, sollte aber vor
  einem größeren Rollout bewertet werden.
- **Flask-Entwicklungsserver**, kein WSGI-Produktivserver (z. B. Waitress,
  gunicorn). Für kleine interne Nutzung ausreichend, bei mehr gleichzeitigen
  Nutzern ggf. wechseln.
- **PowerShell-Teile des Verteilungs-Skripts** (Self-Elevation, `msiexec`,
  `Add-MpPreference`) konnten in der Entwicklungsumgebung (Linux-Sandbox)
  nicht Ende-zu-Ende ausgeführt werden, nur mit dem echten PowerShell-Parser
  auf Syntaxfehler geprüft, plus die reinen Netzwerk-Teile
  (Invoke-WebRequest/Invoke-RestMethod, Expand-Archive) wurden live gegen den
  echten Server getestet. Vor größerem Rollout: an 1–2 unkritischen Rechnern
  gegentesten.
- **winget-Pfad der automatischen Aktualisierung** konnte aus demselben
  Grund nicht direkt ausgeführt werden (winget existiert nur unter Windows).
  Der GitHub- und Direkt-Link-Pfad wurden dagegen live gegen echte
  Schnittstellen getestet.
- **Keine mehrsprachige Oberfläche** â€“ komplett auf Deutsch hartkodiert.
## 5. Auslieferung als eine Datei
## 8. Konfigurationspunkte für die Anpassung
`npm run build:exe` in vier Schritten:
| Was | Wo |
|---|---|
| Client-Ordner auf Zielrechnern | `CLIENT_ORDNER` in `app.py` |
| Port des Webservers | `app.run(..., port=5000)` am Ende von `app.py`, sowie in den `.bat`-Dateien/Firewall-Skript |
| Schwelle "Bitte prüfen"-Warnung | `TAGE_BIS_WARNUNG` in `app.py` (aktuell 30 Tage) |
| Erlaubte Bildformate für Doku-Upload | `ERLAUBTE_BILD_ENDUNGEN` in `app.py` |
| Max. Uploadgröße | `app.config["MAX_CONTENT_LENGTH"]` in `app.py` |
| Versionsmarker (Diagnose, ob neue Version läuft) | `APP_VERSION` in `app.py` |
1. TypeScript Ã¼bersetzen
2. Server mit esbuild zu einer CommonJS-Datei bündeln
3. Die Oberfläche als Einzelressourcen in die SEA-Konfiguration eintragen
4. Node-Programmdatei kopieren und den Blob per `postject` hineinschreiben
**Warum Einzelressourcen und nicht ein eingebettetes Bündel:** Ein früher Versuch hat das
JavaScript-Bündel als Zeichenkette in die Server-Datei eingebettet. Die eingebettete
Schriftart enthält Bytefolgen, die als UTF-8 ungültig sind â€” sie wurden zu U+FFFD und das
Bündel damit unbrauchbar. Die Seite blieb weiß, ohne Fehlermeldung. Aufgefallen ist es
erst beim Vergleich der ausgelieferten Bytes mit der Datei auf der Platte. Ãœber
`sea.getRawAsset()` kommen die Ressourcen unverändert an.
**Warum esbuild und nicht webpack:** webpack scheitert an einer Abhängigkeit in der
Server-Kette, deren `tsconfig.json` per `extends` auf ein nicht aufgelöstes Paket zeigt.
esbuild kümmert das nicht.
Der ZIP-Erzeuger für Mehrfach-Downloads ist von Hand geschrieben (nur speichernd, ohne
Kompression â€” Installer sind ohnehin komprimiert). Das spart eine Abhängigkeit in einem
Bündel, das ohne Fremdcode auskommen soll.
---
## 6. Datenmodell
Eine Tabelle `software` und eine Tabelle für das Zugriffsprotokoll. Neue Spalten kommen
über eine Liste hinzu, die beim Start mit dem tatsächlichen Schema abgeglichen wird â€”
fehlende werden ergänzt. Damit Ã¼bersteht eine bestehende Installation ein Update ohne
Handarbeit.
Ordnernamen im Datenverzeichnis haben denselben Weg: Eine Umbenennungsliste beim Start
zieht Ã¤ltere Installationen nach.
Zur Version: Angezeigt wird das **Veröffentlichungsdatum des Anbieters**, nicht der
Abrufzeitpunkt. Nur wenn eine Quelle keins liefert, wird ersatzweise der Abrufzeitpunkt
genannt und als solcher benannt â€” die Frage â€žist das aktuell?" beantwortet das Datum des
Anbieters, unser Abrufzeitpunkt sagt darüber nichts.
---
## 7. Grenzen
- **Kein HTTPS.** Damit fehlt dem Browser der sichere Kontext, und die Schnittstellen für
  Dateizugriff (`showDirectoryPicker`, `crypto.subtle`) stehen an der Netzwerkadresse nicht
  zur Verfügung. Nachgemessen: an `localhost` vorhanden, an der IP nicht.
- **Kein Dienstbetrieb.** Die Programmdatei muss von Hand gestartet werden.
- **Keine automatisierten Tests.** Geprüft wurde bisher von Hand gegen die echten Quellen.
- **Keine Rollen.** Wer am Server sitzt, darf alles.
- **Ein hinterlegtes Geheimnis lässt sich Ã¼ber die Oberfläche nicht einzeln löschen** â€”
  ein leeres Feld bedeutet â€žunverändert lassen". Nur Ã¼ber die Schnittstelle mit einer
  Liste, die den Namen weglässt.
- **Bauvorgänge laufen mit den Rechten des Dienstkontos.** Wer einen Bau-Befehl hinterlegen
  kann, kann Code auf dem Server ausführen. Das setzt Vertrauen in die angebundenen
  Repositories voraus.
CHANGELOG.md
Datei wurde gelöscht
README.md
@@ -1,166 +1,140 @@
# Server-Bibliothek â€“ Prototyp
# Software-Bibliothek
> **Für eine technische Ãœbergabe (z. B. Git-Repository):** Diese Datei ist
> die Bedienungsanleitung. Die technische Architektur (Datenbankschema,
> Kernkonzepte, bekannte Grenzen) steht in `ARCHITEKTUR.md`, die
> Entwicklungshistorie in `CHANGELOG.md`.
Zentrale Softwareverteilung für den Betrieb: Ein Server hält Installer und interne
Werkzeuge auf dem aktuellsten Stand, alle anderen laden sie Ã¼ber den Browser herunter.
Ein einfaches internes Web-Tool für euren Windows-11-Server:
- Katalog aller Software-Tools mit Version, Installer-Pfad und Dokumentation
- Status-Anzeige: **Aktuell / Bitte prüfen / Nie geprüft** (Warnung ab 30 Tagen ohne Prüfung)
- Protokoll: welcher Mitarbeiter hat welche Software auf welchem Rechner bekommen
Die Bibliothek holt sich die Dateien selbst â€” aus GitHub-Releases, Ã¼ber winget, aus SVN,
von einem Direktlink oder indem sie ein Git-Repository klont und baut. Es wird nichts von
Hand hochgeladen, und niemand muss mehr suchen, ob eine Version noch aktuell ist.
Läuft komplett lokal auf eurem Server, keine Cloud, keine Installation von Zusatzsoftware
außer Python. Die Daten liegen in einer einzigen Datei (`data/bibliothek.db`).
Die technische Innensicht steht in [ARCHITEKTUR.md](ARCHITEKTUR.md).
---
## 1. Einmalige Einrichtung auf dem Server
## Betrieb
### Schritt 1 â€“ Ordner kopieren
Kopiert den kompletten Ordner `server-bibliothek` auf den Windows-11-Server,
z. B. nach `C:\server-bibliothek`.
### Die fertige Programmdatei
### Schritt 2 â€“ Python installieren (falls noch nicht vorhanden)
1. Ã–ffnet im Browser: https://www.python.org/downloads/
2. Ladet die neueste Version für Windows herunter und startet die Installation.
3. **Wichtig:** Auf der ersten Seite des Installers unten das Kästchen
   **"Add python.exe to PATH"** anhaken, bevor ihr auf "Install Now" klickt.
Der Auslieferungsstand ist eine einzelne `software-bibliothek.exe`. Sie braucht kein
installiertes Node, keine Laufzeitumgebung und keine Installation â€” ablegen, starten,
fertig. Beim ersten Start legt sie ihren Datenordner an und meldet im Fenster, wo er liegt.
### Schritt 3 â€“ Einrichtung starten
Im Ordner `C:\server-bibliothek` die Datei **`install.bat`** doppelklicken.
Es Ã¶ffnet sich ein schwarzes Fenster, das die benötigte Bibliothek installiert.
Am Ende steht "Fertig!" â€“ das Fenster kann dann geschlossen werden.
(Falls Python noch fehlt, sagt dir das Fenster das und verlinkt die Download-Seite.)
## 2. Programm starten
Im Ordner `C:\server-bibliothek` die Datei **`start.bat`** doppelklicken.
Es Ã¶ffnet sich ein Fenster mit der Meldung, dass der Server läuft.
Dieses Fenster muss offen bleiben, solange das Tool genutzt werden soll â€“
es ist quasi der "Motor", der die Web-Seite im Hintergrund am Laufen hält.
Jetzt im Browser Ã¶ffnen:
```
http://localhost:5000
Software-Bibliothek läuft auf http://localhost:3000
Daten:         C:\ProgramData\Software-Bibliothek\data
Einstellungen: C:\ProgramData\Software-Bibliothek\konfiguration.json
```
Von **anderen Rechnern im selben Netzwerk** erreicht ihr es Ã¼ber die IP-Adresse
des Servers, z. B. `http://192.168.1.50:5000` (steht auch direkt im
Konsolenfenster von `start.bat`, in der Zeile die NICHT mit `127.0.0.1`
beginnt).
### Wo die Daten liegen
**Klappt der Zugriff von einem anderen Rechner nicht?** Das liegt fast immer
an der Windows-Firewall, die eingehende Verbindungen auf Port 5000
standardmäßig blockiert. Einmalig `firewall-freigabe-einrichten.bat`
doppelklicken â€“ richtet die nötige Firewall-Regel automatisch ein.
Standard ist `%ProgramData%\Software-Bibliothek` â€” bewusst außerhalb des Programmordners,
damit ein Austausch der Programmdatei nichts mitreißt. Ein anderer Ort lässt sich in den
Einstellungen hinterlegen (Zahnrad oben rechts, nur direkt am Server erreichbar).
Zum Beenden: das Konsolenfenster einfach schließen (oder `STRG+C` drücken).
| Unterordner | Inhalt |
|---|---|
| `files/<id>/` | Die bereitgestellten Programmdateien â€” das, was heruntergeladen wird |
| `sources/<id>/` | Arbeitskopien der Git-Repositories, die selbst gebaut werden |
| `secrets/<id>.json` | Hinterlegte API-Schlüssel; Zugriff auf SYSTEM, Administratoren und das Dienstkonto beschränkt |
| `bibliothek.db` | Katalog und Zugriffsprotokoll (SQLite) |
Für die Datensicherung genügt dieser Ordner.
### Wer was darf
Die Grenze verläuft an der Netzwerkschnittstelle, nicht an einer Anmeldung:
- **Direkt am Server** (`localhost`) â€” vollständige Verwaltung: Einträge anlegen, Ã¤ndern,
  löschen, Quellen prüfen, Schlüssel hinterlegen, Einstellungen, Zugriffsprotokoll.
- **Aus dem Netzwerk** â€” Katalog ansehen und herunterladen. Jede verwaltende Route
  antwortet mit 403.
Jeder Download wird protokolliert: Zeitpunkt, IP, aufgelöster Rechnername, Programm.
### Aktualität
Einmal täglich prüft die Bibliothek alle Quellen selbsttätig; â€žAlle prüfen" stößt es von
Hand an. Liefert eine Quelle nichts Brauchbares â€” Seite tot, HTML statt Programmdatei,
plötzlich winzige Datei â€” bleibt die zuletzt bereitgestellte Datei unangetastet und der
Eintrag meldet das Problem. Ein kaputter Link führt nie dazu, dass ein funktionierender
Download verschwindet.
---
## 3. Bedienung
## Entwicklung
- **Software-Katalog** (Startseite): alle Tools mit Status auf einen Blick.
  Ãœber "+ Neues Tool anlegen" ein neues Tool mit Installer-Pfad, Silent-Parametern
  und Doku-Text hinterlegen.
- Auf einem Tool auf **Details** klicken â†’ dort steht die komplette Anleitung,
  außerdem der Button **"Jetzt als geprüft markieren"** â€“ das solltet ihr klicken,
  wann immer ihr den Installer manuell aktualisiert habt. Danach zählt der
  30-Tage-Timer wieder von vorn.
- **Zuweisungen**: Protokolliert dort jede Installation (Mitarbeiter, Rechnername,
  installierte Version, Datum). Ãœber die Filterfelder lässt sich nach Mitarbeiter
  oder Rechnername suchen.
Node 22 oder neuer (die eingebaute SQLite-Schnittstelle wird gebraucht), npm-Workspaces.
```bash
npm install
```
Server und Oberfläche laufen getrennt:
```bash
npm run dev:server
```
```bash
npm run dev:client
```
Der Server bedient `http://localhost:3000` und liefert dort auch die gebaute Oberfläche
aus. Der Entwicklungsserver auf `http://localhost:5000` bringt Hot Reload mit und leitet
`/api` an Port 3000 weiter â€” Ã„nderungen an der Oberfläche sind dort sofort sichtbar, auf
Port 3000 erst nach einem Bau.
### Aufteilung
| Paket | Inhalt |
|---|---|
| `packages/bibliothek-types` | Gemeinsame Typen â€” der Vertrag zwischen Server und Oberfläche |
| `packages/bibliothek-server` | Express, SQLite, Bezugsquellen, Zugriffsgrenze |
| `packages/bibliothek-client` | Oberfläche aus dh-components (Lit) |
### Bauen
```bash
npm run build
```
```bash
npm run build:exe
```
Der zweite Befehl erzeugt die eigenständige Programmdatei: TypeScript Ã¼bersetzen, den
Server mit esbuild bündeln, die Oberfläche als Ressourcen einbetten und beides Ã¼ber Nodes
Single-Executable-Verfahren in eine `.exe` schreiben.
---
## 3a. Rechner einrichten (Verteilung an neue Rechner)
## Ein internes Werkzeug anbinden
Menüpunkt "Rechner einrichten": Häkchen bei den benötigten Tools setzen,
Mitarbeiter eintragen, "Installations-Paket herunterladen" klicken. Das
erzeugte ZIP enthält die ausgewählten Installer-Dateien **direkt mit** (kein
Nachladen vom Server nötig).
Werkzeuge, die selbst gebaut werden, beschreiben ihren Bau im eigenen Repository â€” die
Bibliothek muss nichts Ã¼ber sie wissen. Dafür liegt eine `software-bibliothek.json` im
Wurzelverzeichnis:
Auf dem **neuen Rechner**: ZIP entpacken, `Rechner-einrichten-starten.bat`
doppelklicken. Das Skript:
- fragt bei Bedarf automatisch nach Admin-Rechten
- installiert jedes Tool still mit dem hinterlegten Silent-Parameter
- trägt automatisch eine Windows-Defender-Ausnahme für `C:\ClientBibliothek`
  ein (dort landen alle Dateien gesammelt)
- meldet das Ergebnis am Ende automatisch ans zentrale Protokoll zurück
  (Rechnername wird dabei selbst erkannt)
```json
{
  "buildCommand": "npm install && npm run build-win",
  "artifact": "dist/MeinWerkzeug.exe"
}
```
Tools ohne einzelne Installer-Datei (z. B. bereits entpackte/portable Tools
wie Blender) werden als ZIP nach `C:\ClientBibliothek\pakete\` gelegt â€“
bewusst ohne automatisches Entpacken, das entscheidet ihr von Fall zu Fall
selbst.
Braucht der Bau einen API-Schlüssel, wird er dort deklariert und sein Wert in der
Bibliothek hinterlegt; beim Bau steht er als Umgebungsvariable bereit. Das vollständige
Verfahren beschreibt der Skill `build-secrets` im Repository `dh-claude-rules`.
⚠️ Die Windows-spezifischen Teile (Admin-Rechte-Anfrage, stille Installation)
wurden nicht auf einem echten Windows-Rechner entwickelt und getestet, nur
mit dem echten PowerShell-Parser auf Syntaxfehler geprüft sowie die
Netzwerk-/Datei-Logik separat verifiziert. Bei größerem Rollout zuerst an
1–2 unkritischen Rechnern gegentesten.
**Zugriff aus dem Netzwerk:** Ruft jemand die Bibliothek nicht direkt am
Server, sondern Ã¼ber das Netzwerk auf (z. B. von einem anderen PC), sieht
diese Person nur diese eingeschränkte Seite (dort "Software anfordern"
genannt) â€“ der Software-Katalog, die Protokolle usw. bleiben dem Server
selbst vorbehalten (z. B. per RDP-Sitzung, dort im Browser "localhost"
statt der Netzwerk-Adresse aufrufen). Jeder Zugriff/Download aus dem
Netzwerk wird im Zugriffsprotokoll (Menüpunkt "Zugriffsprotokoll",
ebenfalls nur direkt am Server einsehbar) mit IP-Adresse und angegebenem
Namen festgehalten.
Im Katalog wird als Quelle **nur die Repository-URL ohne Benutzernamen** eingetragen â€” die
Kennung kommt aus den Einstellungen der Bibliothek, damit ein Kontowechsel nicht jeden
Eintrag berührt.
---
## 4. Automatische Aktualisierung
## Offen
Bei jedem Tool kann beim Anlegen/Bearbeiten "Automatische Aktualisierung
aktivieren" angehakt werden. Dann sucht das Tool selbst nach neuen Versionen
und **ersetzt die Installer-Datei automatisch, ohne Rückfrage**. Dabei:
- wird die alte Datei vorher immer in einen Unterordner `_sicherungen`
  kopiert (Sicherheitsnetz, falls mal etwas schiefgeht)
- wird jeder Check protokolliert (sichtbar auf der Detailseite des Tools
  unter "Update-Protokoll")
- bleibt ein Tool bei "Bitte prüfen" stehen, wenn eine Prüfung fehlschlägt
  (z. B. Server nicht erreichbar) â€“ Fehler werden nie stillschweigend
  Ã¼bergangen
Als Quelle stehen drei Möglichkeiten zur Wahl: **winget** (für die meisten
bekannten Programme, siehe `Update-Quellen-Uebersicht.md`), **GitHub-Projekt**
(für Open-Source-Tools) oder ein **direkter Download-Link**. Tools ohne
Häkchen bleiben komplett unangetastet â€“ ideal für interne oder bewusst
fest angepinnte Software.
**Update jetzt manuell auslösen:**
- Für ein einzelnes Tool: auf der Detailseite "Jetzt automatisch prüfen & aktualisieren"
- Für alle Tools auf einmal: Button oben im Software-Katalog, oder Doppelklick auf `pruefe_updates.bat`
**Täglich automatisch im Hintergrund laufen lassen (empfohlen):**
Einmalig `automatisierung_einrichten.bat` doppelklicken. Das richtet in der
Windows-Aufgabenplanung einen täglichen Lauf um 03:00 Uhr nachts ein â€“ dann
kümmert sich das Tool von selbst darum, auch wenn niemand die Weboberfläche
geöffnet hat. Falls eine Fehlermeldung kommt: Rechtsklick auf die Datei â†’
"Als Administrator ausführen".
⚠️ **Hinweis zum winget-Pfad:** Dieser Teil konnte in meiner Entwicklungsumgebung
nicht direkt getestet werden, da winget nur unter Windows existiert (der
GitHub-Pfad wurde dagegen live gegen die echte GitHub-Schnittstelle getestet).
Probiert den winget-Weg daher am besten zuerst an einem unkritischen Tool aus,
bevor ihr euch vollständig darauf verlasst.
## 5. Dauerhafter Betrieb der Weboberfläche
Aktuell müsst ihr `start.bat` manuell starten, wenn ihr den Katalog im Browser
ansehen wollt â€“ die automatische Update-Prüfung (siehe oben) läuft davon
unabhängig auch ohne offene Weboberfläche. Falls gewünscht, kann auch die
Weboberfläche selbst automatisch mit dem Server starten â€“ dafür einfach
melden, wenn ihr so weit seid.
## 6. Datensicherung
Alle Daten liegen in der Datei `data\bibliothek.db`. Diese Datei ab und zu zu
sichern (z. B. in euer normales Backup einschließen) reicht als Absicherung
gegen Datenverlust vollständig aus.
- HTTPS; solange die Verbindung unverschlüsselt ist, bleiben dem Browser die Schnittstellen
  für Dateizugriff verwehrt
- Betrieb als Windows-Dienst mit Autostart
- Automatisierte Tests
- Rollen und Rechte (im Ticket bewusst zurückgestellt)
Update-Quellen-Uebersicht.md
Datei wurde gelöscht
app.py
Datei wurde gelöscht
automatische_pruefung.py
Datei wurde gelöscht
automatisierung_einrichten.bat
Datei wurde gelöscht
bauen-exe.cjs
Neue Datei
@@ -0,0 +1,73 @@
/**
 * Baut aus dem gebündelten Server und der gebauten Oberfläche eine einzelne
 * ausführbare Datei (Node SEA). Die Dateien der Oberfläche werden als Assets
 * eingebettet und zur Laufzeit binärsicher aus der exe ausgeliefert.
 *
 * Voraussetzung: `npm run build` und `npm run bundle -w packages/bibliothek-server`.
 */
const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const EXE_NAME = 'Software-Bibliothek.exe';
const SENTINEL = 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2';
const wurzel = __dirname;
const oberflaechenOrdner = path.join(wurzel, 'packages', 'bibliothek-client', 'dist');
const bundleOrdner = path.join(wurzel, 'packages', 'bibliothek-server', 'build-exe');
const konfigPfad = path.join(bundleOrdner, 'sea-config.json');
const blobPfad = path.join(bundleOrdner, 'sea-prep.blob');
const ausgabeOrdner = path.join(wurzel, 'exe');
const exePfad = path.join(ausgabeOrdner, EXE_NAME);
function schritt(beschreibung, aktion) {
  process.stdout.write(`${beschreibung} â€¦ `);
  const ergebnis = aktion();
  console.log(ergebnis ?? 'ok');
}
schritt('Oberflaechen-Dateien einsammeln', () => {
  const dateien = fs.readdirSync(oberflaechenOrdner).filter((name) => fs.statSync(path.join(oberflaechenOrdner, name)).isFile());
  if (!dateien.includes('index.html')) {
    throw new Error(`In ${oberflaechenOrdner} fehlt index.html â€” wurde der Client gebaut?`);
  }
  const assets = {};
  for (const name of dateien) {
    assets[name] = path.relative(wurzel, path.join(oberflaechenOrdner, name)).replace(/\\/g, '/');
  }
  fs.writeFileSync(
    konfigPfad,
    JSON.stringify(
      {
        main: path.relative(wurzel, path.join(bundleOrdner, 'server.cjs')).replace(/\\/g, '/'),
        output: path.relative(wurzel, blobPfad).replace(/\\/g, '/'),
        disableExperimentalSEAWarning: true,
        assets,
      },
      null,
      2
    )
  );
  return `${dateien.length} Datei(en)`;
});
schritt('Blob erzeugen (Server + eingebettete Oberflaeche)', () => {
  execFileSync(process.execPath, ['--experimental-sea-config', konfigPfad], { stdio: 'pipe', cwd: wurzel });
});
schritt('Node-Binary kopieren', () => {
  fs.mkdirSync(ausgabeOrdner, { recursive: true });
  fs.copyFileSync(process.execPath, exePfad);
});
schritt('Code in die exe injizieren', () => {
  execFileSync('npx', ['--yes', 'postject', exePfad, 'NODE_SEA_BLOB', blobPfad, '--sentinel-fuse', SENTINEL], {
    stdio: 'pipe',
    shell: process.platform === 'win32',
  });
});
const groesseMb = (fs.statSync(exePfad).size / 1024 / 1024).toFixed(1);
console.log(`\nFertig: ${exePfad} (${groesseMb} MB)`);
console.log('Zum Betrieb: exe starten, dann http://localhost:3000 aufrufen.');
console.log('Die Nutzdaten (Datenbank + geholte Installer) landen im Ordner "data" neben der exe.');
desktop/assets/icon.ico
desktop/backend/__init__.py
Neue Datei
@@ -0,0 +1,6 @@
"""Backend des Software-Bibliothek-Clients.
Der Client zeigt den Katalog der Bibliothek, legt heruntergeladene Programme an
einen festen Ort und meldet, wenn ein Werkzeug dort veraltet ist. Anders als die
Weboberflaeche darf er ins Dateisystem sehen â€” das ist sein einziger Daseinszweck.
"""
desktop/backend/__main__.py
Neue Datei
@@ -0,0 +1,52 @@
"""Startpunkt: oeffnet ein natives Fenster und verbindet es mit der Api.
Start:  py -m backend        (aus dem Ordner `desktop`, im aktivierten venv)
Das Fenster nutzt das WebView2 von Windows â€” dieselbe Engine, die der Browser
ohnehin mitbringt. Es wird also kein zweites Chromium ausgeliefert.
"""
from __future__ import annotations
import sys
from pathlib import Path
import webview
from .api import Api
# Im Betrieb aus dem Quelltext liegt die gebaute Oberflaeche unter
# <projekt>/frontend/dist; in der PyInstaller-exe im Bundle-Verzeichnis.
if getattr(sys, "frozen", False):
    _BASIS = Path(sys._MEIPASS)  # type: ignore[attr-defined]
else:
    _BASIS = Path(__file__).resolve().parent.parent
FRONTEND_INDEX = _BASIS / "frontend" / "dist" / "index.html"
_PLATZHALTER_HTML = """
<!doctype html><html lang="de"><body style="font-family:sans-serif;padding:2rem">
<h1>Oberfläche noch nicht gebaut</h1>
<p>Im Ordner <code>desktop/frontend</code> einmal <code>npm install</code> und
<code>npm run build</code> ausführen, dann erneut starten.</p>
</body></html>
"""
def main() -> None:
    api = Api()
    gemeinsam = {
        "js_api": api,
        "width": 1100,
        "height": 780,
        "min_size": (820, 600),
    }
    if FRONTEND_INDEX.exists():
        webview.create_window("Software-Bibliothek", url=str(FRONTEND_INDEX), **gemeinsam)
    else:
        webview.create_window("Software-Bibliothek", html=_PLATZHALTER_HTML, **gemeinsam)
    webview.start(http_server=True)
if __name__ == "__main__":
    main()
desktop/backend/api.py
Neue Datei
@@ -0,0 +1,180 @@
"""Bruecke zwischen Oberflaeche (pywebview) und Backend.
Jede oeffentliche Methode ist aus dem Frontend als
`window.pywebview.api.<methode>(...)` erreichbar und liefert JSON-taugliche Werte.
Fehler werden nicht geworfen, sondern als `{ok: false, meldung: ...}` gemeldet â€”
ein Ausnahmefehler ueber die Bruecke kaeme in der Oberflaeche nur als
unverstaendlicher Text an.
"""
from __future__ import annotations
import subprocess
import tempfile
from dataclasses import asdict
from pathlib import Path
from typing import Any
from . import bibliothek, konfiguration
from .werkzeuge import Werkzeugordner, Zustand
# Installationsprogramme kommen nicht zwischen die eigenstaendigen Werkzeuge â€”
# sie werden einmal ausgefuehrt und sind danach entbehrlich.
UNTERORDNER_INSTALLER = "Installationsprogramme"
# Vorerst melden nur unsere eigenen Werkzeuge Updates. Fremdprogramme kuemmern
# sich teils selbst darum, und wo nicht, fehlt uns ein verlaesslicher Weg, ihre
# installierte Fassung zu erkennen. Bewusst eng gehalten, leicht zu weiten.
EINSTUFUNG_MIT_UPDATEPRUEFUNG = "intern"
class Api:
    def __init__(self) -> None:
        self._einstellungen = konfiguration.laden()
        self._ordner = Werkzeugordner(self._einstellungen.werkzeugOrdner)
        self._ordner.altlasten_aufraeumen()
    # --- Einstellungen ---
    def einstellungen(self) -> dict[str, Any]:
        return {
            "einstellungen": asdict(self._einstellungen),
            "vorgaben": {
                "serverAdresse": konfiguration.STANDARD_SERVER,
                "werkzeugOrdner": konfiguration.STANDARD_WERKZEUG_ORDNER,
            },
        }
    def einstellungen_speichern(self, serverAdresse: str, werkzeugOrdner: str) -> dict[str, Any]:
        self._einstellungen = konfiguration.speichern(
            konfiguration.Einstellungen(serverAdresse=serverAdresse, werkzeugOrdner=werkzeugOrdner)
        )
        self._ordner = Werkzeugordner(self._einstellungen.werkzeugOrdner)
        return {"ok": True, **self.einstellungen()}
    # --- Katalog ---
    def katalog(self) -> dict[str, Any]:
        """Katalog der Bibliothek, angereichert um den Befund auf diesem Rechner."""
        try:
            eintraege = bibliothek.katalog(self._einstellungen.serverAdresse)
        except bibliothek.BibliothekNichtErreichbar as fehler:
            return {"ok": False, "meldung": str(fehler), "eintraege": []}
        angereichert = []
        for eintrag in eintraege:
            eigenstaendig = eintrag.get("programmart") == "eigenstaendig"
            befund = (
                self._ordner.befund(int(eintrag["id"]), str(eintrag.get("version") or ""))
                if eigenstaendig
                else None
            )
            angereichert.append(
                {
                    **eintrag,
                    "lokal": asdict(befund) if befund else None,
                    "ueberwacht": eigenstaendig
                    and eintrag.get("einstufung") == EINSTUFUNG_MIT_UPDATEPRUEFUNG,
                }
            )
        return {"ok": True, "eintraege": angereichert, "ordner": str(self._ordner.ordner)}
    def veraltete(self) -> list[dict[str, str]]:
        """Kurzfassung fuer die Meldung beim Start: was ist ueberwacht und nicht aktuell."""
        stand = self.katalog()
        if not stand["ok"]:
            return []
        return [
            {"name": eintrag["name"], "zustand": eintrag["lokal"]["zustand"]}
            for eintrag in stand["eintraege"]
            if eintrag["ueberwacht"]
            and eintrag["lokal"]
            and eintrag["lokal"]["zustand"] in (Zustand.VERALTET.value, Zustand.FREMD.value)
        ]
    # --- Holen ---
    def herunterladen(self, software_id: int, dateiname: str, version: str, programmart: str) -> dict[str, Any]:
        """Holt eine Datei und legt sie am passenden Ort ab.
        Eigenstaendige Programme kommen in den Werkzeug-Ordner und werden im Stand
        vermerkt; Installationsprogramme daneben in einen eigenen Unterordner, ohne
        Vermerk â€” was installiert ist, sagt der Ordner ohnehin nicht.
        """
        if not dateiname:
            return {"ok": False, "meldung": "Für diesen Eintrag liegt keine Datei bereit."}
        eigenstaendig = programmart == "eigenstaendig"
        with tempfile.TemporaryDirectory(prefix="bibliothek-") as zwischenlager:
            zwischenziel = Path(zwischenlager) / dateiname
            try:
                bibliothek.datei_holen(self._einstellungen.serverAdresse, int(software_id), zwischenziel)
            except bibliothek.BibliothekNichtErreichbar as fehler:
                return {"ok": False, "meldung": str(fehler)}
            try:
                if eigenstaendig:
                    abgelegt = self._ordner.ablegen(int(software_id), zwischenziel, dateiname, version)
                else:
                    ziel = self._ordner.ordner / UNTERORDNER_INSTALLER / dateiname
                    ziel.parent.mkdir(parents=True, exist_ok=True)
                    ziel.unlink(missing_ok=True)
                    zwischenziel.replace(ziel)
                    abgelegt = ziel
            except OSError as fehler:
                return {"ok": False, "meldung": f"Ablegen fehlgeschlagen: {fehler}"}
        return {
            "ok": True,
            "pfad": str(abgelegt),
            "meldung": (
                f"Abgelegt unter {abgelegt}. Lief das Programm gerade, wirkt die neue "
                "Fassung nach dem nächsten Start."
                if eigenstaendig
                else f"Abgelegt unter {abgelegt}. Nach der Installation kann die Datei gelöscht werden."
            ),
        }
    # --- Windows-Handreichungen ---
    def ordner_oeffnen(self, unterordner: str = "") -> dict[str, Any]:
        ziel = self._ordner.ordner / unterordner if unterordner else self._ordner.ordner
        ziel.mkdir(parents=True, exist_ok=True)
        subprocess.Popen(["explorer", str(ziel)])  # noqa: S603,S607 - fester Befehl
        return {"ok": True}
    def verknuepfung_anlegen(self, pfad: str, name: str) -> dict[str, Any]:
        """Legt eine Verknuepfung auf dem Desktop an â€” das kann kein Browser."""
        ziel = Path(pfad)
        if not ziel.exists():
            return {"ok": False, "meldung": "Die Datei liegt nicht (mehr) an diesem Ort."}
        skript = (
            "$verknuepfung = (New-Object -ComObject WScript.Shell)."
            "CreateShortcut([Environment]::GetFolderPath('Desktop') + '\\' + $env:VERKNUEPFUNG_NAME + '.lnk');"
            "$verknuepfung.TargetPath = $env:VERKNUEPFUNG_ZIEL;"
            "$verknuepfung.WorkingDirectory = Split-Path $env:VERKNUEPFUNG_ZIEL;"
            "$verknuepfung.Save()"
        )
        try:
            subprocess.run(  # noqa: S603
                ["powershell", "-NoProfile", "-NonInteractive", "-Command", skript],
                check=True,
                capture_output=True,
                timeout=30,
                env={**_umgebung(), "VERKNUEPFUNG_ZIEL": str(ziel), "VERKNUEPFUNG_NAME": _dateiname_saeubern(name)},
            )
        except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as fehler:
            return {"ok": False, "meldung": f"Verknüpfung fehlgeschlagen: {fehler}"}
        return {"ok": True, "meldung": f"Verknüpfung â€ž{name}“ liegt auf dem Desktop."}
def _umgebung() -> dict[str, str]:
    import os
    return dict(os.environ)
def _dateiname_saeubern(name: str) -> str:
    verboten = '\\/:*?"<>|'
    gesaeubert = "".join(zeichen for zeichen in name if zeichen not in verboten).strip()
    return gesaeubert or "Programm"
desktop/backend/bibliothek.py
Neue Datei
@@ -0,0 +1,85 @@
"""Zugriff auf den Server der Software-Bibliothek.
Nur die beiden Routen, die aus dem Netzwerk erreichbar sind: Katalog lesen und
Datei holen. Die Verwaltung bleibt der Weboberflaeche am Server vorbehalten.
Bewusst nur mit der Standardbibliothek â€” jede Abhaengigkeit mehr muss beim Bau
der exe mitgeschleppt werden, und fuer zwei GET-Aufrufe lohnt das nicht.
"""
from __future__ import annotations
import json
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Callable
ZEITGRENZE_KATALOG_S = 15
ZEITGRENZE_DOWNLOAD_S = 600
class BibliothekNichtErreichbar(Exception):
    """Der Server antwortet nicht oder liefert etwas Unerwartetes."""
def _lesbarer_fehler(adresse: str, fehler: Exception) -> BibliothekNichtErreichbar:
    if isinstance(fehler, urllib.error.HTTPError):
        return BibliothekNichtErreichbar(f"{adresse} antwortet mit HTTP {fehler.code}.")
    if isinstance(fehler, urllib.error.URLError):
        return BibliothekNichtErreichbar(f"{adresse} ist nicht erreichbar: {fehler.reason}")
    return BibliothekNichtErreichbar(f"{adresse}: {fehler}")
def katalog(server_adresse: str) -> list[dict[str, Any]]:
    """Holt alle Katalogeintraege."""
    adresse = f"{server_adresse.rstrip('/')}/api/software"
    try:
        with urllib.request.urlopen(adresse, timeout=ZEITGRENZE_KATALOG_S) as antwort:
            gelesen = json.loads(antwort.read().decode("utf-8"))
    except Exception as fehler:  # noqa: BLE001 - jede Ursache wird zur selben Meldung
        raise _lesbarer_fehler(server_adresse, fehler) from fehler
    if not isinstance(gelesen, list):
        raise BibliothekNichtErreichbar(f"{server_adresse} liefert keinen Katalog.")
    return gelesen
def datei_holen(
    server_adresse: str,
    software_id: int,
    ziel: Path,
    fortschritt: Callable[[int, int], None] | None = None,
) -> Path:
    """Laedt die bereitgestellte Datei nach `ziel`.
    Geschrieben wird zuerst nach `<ziel>.teil` und erst nach vollstaendigem
    Empfang umbenannt. Ein Abbruch mitten im Download darf keine halbe Datei
    hinterlassen, die spaeter fuer ein fertiges Programm gehalten wird.
    """
    adresse = f"{server_adresse.rstrip('/')}/api/software/{software_id}/download"
    unfertig = ziel.with_name(ziel.name + ".teil")
    ziel.parent.mkdir(parents=True, exist_ok=True)
    try:
        with urllib.request.urlopen(adresse, timeout=ZEITGRENZE_DOWNLOAD_S) as antwort:
            gesamt = int(antwort.headers.get("Content-Length") or 0)
            geladen = 0
            with unfertig.open("wb") as datei:
                while True:
                    brocken = antwort.read(256 * 1024)
                    if not brocken:
                        break
                    datei.write(brocken)
                    geladen += len(brocken)
                    if fortschritt:
                        fortschritt(geladen, gesamt)
    except Exception as fehler:  # noqa: BLE001
        unfertig.unlink(missing_ok=True)
        raise _lesbarer_fehler(server_adresse, fehler) from fehler
    if unfertig.stat().st_size == 0:
        unfertig.unlink(missing_ok=True)
        raise BibliothekNichtErreichbar("Der Server hat eine leere Datei geliefert.")
    ziel.unlink(missing_ok=True)
    unfertig.replace(ziel)
    return ziel
desktop/backend/konfiguration.py
Neue Datei
@@ -0,0 +1,74 @@
"""Einstellungen des Clients: Wo steht die Bibliothek, wohin kommen die Werkzeuge.
Bewusst einstellbar statt eingebaut: Die Bibliothek zieht spaeter auf einen anderen
Rechner um, und dann soll nicht jeder Arbeitsplatz eine neue exe brauchen.
"""
from __future__ import annotations
import json
import os
from dataclasses import asdict, dataclass
from pathlib import Path
# Vorgabe, solange nichts eingestellt wurde. Der Rechnername ist dem einer IP
# vorzuziehen â€” er ueberlebt einen Wechsel der Adresse.
STANDARD_SERVER = "http://dhs-main-dt:3000"
# Fester Ort fuer eigenstaendige Programme. Unter C:\ vererbt Windows das
# Aenderungsrecht an angemeldete Benutzer, ein Update braucht also keine
# Administratorrechte â€” anders als unter C:\Programme.
STANDARD_WERKZEUG_ORDNER = r"C:\D+H\Software"
@dataclass
class Einstellungen:
    serverAdresse: str = STANDARD_SERVER
    werkzeugOrdner: str = STANDARD_WERKZEUG_ORDNER
def konfigurations_verzeichnis() -> Path:
    """Je Benutzer, nicht je Installation â€” die exe darf ausgetauscht werden."""
    basis = os.environ.get("LOCALAPPDATA") or str(Path.home())
    return Path(basis) / "Software-Bibliothek-Client"
def _konfigurationsdatei() -> Path:
    return konfigurations_verzeichnis() / "konfiguration.json"
def laden() -> Einstellungen:
    """Liest die Einstellungen; bei jedem Problem gelten die Vorgaben.
    Eine kaputte Datei darf den Start nicht verhindern â€” der Nutzer soll die
    Adresse in der Oberflaeche korrigieren koennen, statt vor einem toten
    Fenster zu sitzen.
    """
    datei = _konfigurationsdatei()
    if not datei.exists():
        return Einstellungen()
    try:
        gelesen = json.loads(datei.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return Einstellungen()
    if not isinstance(gelesen, dict):
        return Einstellungen()
    return Einstellungen(
        serverAdresse=str(gelesen.get("serverAdresse") or STANDARD_SERVER).strip(),
        werkzeugOrdner=str(gelesen.get("werkzeugOrdner") or STANDARD_WERKZEUG_ORDNER).strip(),
    )
def speichern(einstellungen: Einstellungen) -> Einstellungen:
    """Schreibt die Einstellungen und liefert den bereinigten Stand zurueck."""
    bereinigt = Einstellungen(
        serverAdresse=(einstellungen.serverAdresse or "").strip().rstrip("/") or STANDARD_SERVER,
        werkzeugOrdner=(einstellungen.werkzeugOrdner or "").strip() or STANDARD_WERKZEUG_ORDNER,
    )
    verzeichnis = konfigurations_verzeichnis()
    verzeichnis.mkdir(parents=True, exist_ok=True)
    _konfigurationsdatei().write_text(
        json.dumps(asdict(bereinigt), indent=2, ensure_ascii=False),
        encoding="utf-8",
    )
    return bereinigt
desktop/backend/werkzeuge.py
Neue Datei
@@ -0,0 +1,153 @@
"""Der Werkzeug-Ordner auf diesem Rechner: lesen, ablegen, vergleichen.
Das ist der Teil, den eine Weboberflaeche nicht kann, und der einzige Grund,
warum es diesen Client gibt.
Der Kniff gegen jedes Raten: Wir legen die Dateien selbst ab und merken uns dabei
ihre Pruefsumme. Beim naechsten Start wird nachgerechnet â€” stimmt sie, wissen wir
sicher, welche Fassung dort liegt. Stimmt sie nicht, hat jemand von Hand
eingegriffen, und genau das wird gemeldet statt geschaetzt.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
# Liegt im Werkzeug-Ordner selbst. Damit zieht der Stand mit, wenn jemand den
# Ordner verschiebt oder auf einen anderen Rechner kopiert.
STANDSDATEI = ".bibliothek-stand.json"
# Endung fuer die verdraengte Vorgaengerdatei. Ein laufendes Programm laesst sich
# unter Windows umbenennen, aber nicht ueberschreiben â€” daher dieser Umweg.
ALTLAST_ENDUNG = ".alt"
class Zustand(str, Enum):
    FEHLT = "fehlt"
    """Nichts abgelegt â€” das Werkzeug ist auf diesem Rechner nicht vorhanden."""
    AKTUELL = "aktuell"
    """Liegt da und entspricht genau dem, was die Bibliothek anbietet."""
    VERALTET = "veraltet"
    """Liegt da, aber die Bibliothek bietet inzwischen eine andere Fassung an."""
    FREMD = "fremd"
    """Liegt da, stammt aber nicht von uns â€” von Hand ersetzt oder veraendert."""
@dataclass
class Befund:
    zustand: Zustand
    dateiname: str = ""
    abgelegteVersion: str = ""
    pfad: str = ""
def pruefsumme(datei: Path) -> str:
    """SHA-256 im Strom â€” die Dateien sind teils dreistellig viele Megabyte gross."""
    rechner = hashlib.sha256()
    with datei.open("rb") as offen:
        for brocken in iter(lambda: offen.read(1024 * 1024), b""):
            rechner.update(brocken)
    return rechner.hexdigest()
class Werkzeugordner:
    def __init__(self, ordner: str | Path) -> None:
        self.ordner = Path(ordner)
    # --- Stand ---
    def _standsdatei(self) -> Path:
        return self.ordner / STANDSDATEI
    def _stand_lesen(self) -> dict[str, dict[str, str]]:
        datei = self._standsdatei()
        if not datei.exists():
            return {}
        try:
            gelesen = json.loads(datei.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            return {}
        return gelesen if isinstance(gelesen, dict) else {}
    def _stand_schreiben(self, stand: dict[str, dict[str, str]]) -> None:
        self.ordner.mkdir(parents=True, exist_ok=True)
        self._standsdatei().write_text(
            json.dumps(stand, indent=2, ensure_ascii=False), encoding="utf-8"
        )
    # --- Abfrage ---
    def befund(self, software_id: int, angeboteneVersion: str) -> Befund:
        """Was liegt hier, und passt es zu dem, was die Bibliothek anbietet?"""
        vermerk = self._stand_lesen().get(str(software_id))
        if not vermerk:
            return Befund(Zustand.FEHLT)
        datei = self.ordner / vermerk.get("dateiname", "")
        if not vermerk.get("dateiname") or not datei.exists():
            return Befund(Zustand.FEHLT)
        try:
            tatsaechlich = pruefsumme(datei)
        except OSError:
            return Befund(Zustand.FREMD, vermerk.get("dateiname", ""), pfad=str(datei))
        if tatsaechlich != vermerk.get("pruefsumme"):
            return Befund(Zustand.FREMD, vermerk["dateiname"], vermerk.get("version", ""), str(datei))
        zustand = Zustand.AKTUELL if vermerk.get("version") == angeboteneVersion else Zustand.VERALTET
        return Befund(zustand, vermerk["dateiname"], vermerk.get("version", ""), str(datei))
    # --- Ablegen ---
    def ablegen(self, software_id: int, quelle: Path, dateiname: str, version: str) -> Path:
        """Legt eine geholte Datei ab und vermerkt ihre Pruefsumme.
        Eine vorhandene gleichnamige Datei wird zuerst umbenannt statt geloescht:
        Laeuft das Programm gerade, laesst Windows das Umbenennen zu, das
        Ueberschreiben nicht. Der Rest wird beim naechsten Lauf weggeraeumt.
        """
        self.ordner.mkdir(parents=True, exist_ok=True)
        ziel = self.ordner / dateiname
        if ziel.exists():
            altlast = ziel.with_name(ziel.name + ALTLAST_ENDUNG)
            altlast.unlink(missing_ok=True)
            ziel.replace(altlast)
        quelle.replace(ziel)
        stand = self._stand_lesen()
        stand[str(software_id)] = {
            "dateiname": dateiname,
            "version": version,
            "pruefsumme": pruefsumme(ziel),
        }
        self._stand_schreiben(stand)
        return ziel
    def vergessen(self, software_id: int) -> None:
        """Nimmt einen Eintrag aus dem Stand â€” die Datei selbst bleibt liegen."""
        stand = self._stand_lesen()
        if stand.pop(str(software_id), None) is not None:
            self._stand_schreiben(stand)
    def altlasten_aufraeumen(self) -> int:
        """Loescht verdraengte Vorgaengerdateien, sofern sie niemand mehr haelt."""
        if not self.ordner.exists():
            return 0
        entfernt = 0
        for datei in self.ordner.glob("*" + ALTLAST_ENDUNG):
            try:
                datei.unlink()
                entfernt += 1
            except OSError:
                continue  # laeuft noch â€” beim naechsten Mal wieder versuchen
        return entfernt
desktop/build_exe.ps1
Neue Datei
@@ -0,0 +1,76 @@
# Baut EINE eigenstaendige Windows-.exe des Software-Bibliothek-Clients.
#
# Laeuft aus einem FRISCHEN Git-Klon ohne Vorbereitung: die virtuelle Umgebung wird
# angelegt, Python- und npm-Abhaengigkeiten werden installiert, die Oberflaeche
# gebaut und alles zu einer Datei gepackt. Damit ist dieses Skript der einzige
# Bau-Befehl, den die Software-Bibliothek braucht (siehe software-bibliothek.json).
$ErrorActionPreference = "Stop"
$desktop = $PSScriptRoot
$projekt = Split-Path $desktop -Parent
Set-Location $desktop
$python = "$desktop\.venv\Scripts\python.exe"
# 1. Virtuelle Umgebung sicherstellen. Ein frischer Klon hat keine; der Starter 'py'
#    ist auf Windows der zuverlaessigste Weg (nicht der Store-Stub).
if (-not (Test-Path $python)) {
  Write-Host "Virtuelle Umgebung fehlt - wird angelegt ..."
  $angelegt = $false
  foreach ($starter in @(@("py", @("-3")), @("python", @()), @("python3", @()))) {
    $befehl = $starter[0]
    if (-not (Get-Command $befehl -ErrorAction SilentlyContinue)) { continue }
    & $befehl @($starter[1] + @("-m", "venv", "$desktop\.venv"))
    if ($LASTEXITCODE -eq 0 -and (Test-Path $python)) { $angelegt = $true; break }
  }
  if (-not $angelegt) { throw "Keine Python-Installation gefunden (py / python / python3)." }
}
# 2. Python-Abhaengigkeiten
Write-Host "Python-Abhaengigkeiten ..."
& $python -m pip install --upgrade pip --quiet
& $python -m pip install -r "$desktop\requirements.txt" --quiet
& $python -m pip install pyinstaller --quiet
if ($LASTEXITCODE -ne 0) { throw "pip fehlgeschlagen." }
# 3. Oberflaeche bauen. Sie liegt als npm-Workspace im Projekt-Wurzelverzeichnis
#    und teilt sich die Typen mit dem Server - deshalb wird dort installiert.
Write-Host "Oberflaeche bauen ..."
Push-Location $projekt
npm install --no-audit --no-fund
if ($LASTEXITCODE -ne 0) { Pop-Location; throw "npm install fehlgeschlagen." }
npm run build:desktop-ui
if ($LASTEXITCODE -ne 0) { Pop-Location; throw "Bau der Oberflaeche fehlgeschlagen." }
Pop-Location
if (-not (Test-Path "$desktop\frontend\dist\index.html")) {
  throw "Die gebaute Oberflaeche fehlt unter frontend\dist."
}
# 4. Alte Bau-Artefakte weg, damit nichts von einem frueheren Lauf ueberlebt.
Remove-Item "$desktop\build", "$desktop\dist" -Recurse -Force -ErrorAction SilentlyContinue
# 5. Eine Datei daraus machen. Die Oberflaeche wird als Datenverzeichnis
#    mitgepackt; __main__.py findet sie zur Laufzeit unter sys._MEIPASS.
#
#    `--collect-all` ist bei den drei webview-Paketen Pflicht: pywebview zeigt das
#    Fenster unter Windows ueber WebView2, und das spricht es via pythonnet in .NET
#    an. Diese Bestandteile findet PyInstaller nicht durch Nachverfolgen der
#    Importe â€” ohne sie startet die exe und beendet sich sofort wieder.
Write-Host "PyInstaller ..."
& $python -m PyInstaller `
  --noconfirm `
  --onefile `
  --windowed `
  --name "Software-Bibliothek" `
  --icon "$desktop\assets\icon.ico" `
  --distpath "$desktop\dist" --workpath "$desktop\build" `
  --add-data "$desktop\frontend\dist;frontend/dist" `
  --collect-all webview `
  --collect-all pythonnet `
  --collect-all clr_loader `
  "$desktop\run_app.py"
if ($LASTEXITCODE -ne 0) { throw "PyInstaller fehlgeschlagen." }
$ergebnis = "$desktop\dist\Software-Bibliothek.exe"
if (-not (Test-Path $ergebnis)) { throw "Erwartete Datei fehlt: $ergebnis" }
Write-Host "Fertig: $ergebnis ($([math]::Round((Get-Item $ergebnis).Length / 1MB, 1)) MB)"
desktop/frontend/package.json
Neue Datei
@@ -0,0 +1,29 @@
{
  "name": "bibliothek-desktop-frontend",
  "version": "0.1.0",
  "type": "module",
  "private": true,
  "scripts": {
    "build": "webpack --node-env=production",
    "build:dev": "webpack --node-env=development",
    "watch": "webpack --watch --node-env=development"
  },
  "dependencies": {
    "@dh-software/dh-components": "^3.0.23",
    "@dh-software/dh-components-themes": "^1.0.21",
    "@dh-software/furnview-icons": "^1.0.21",
    "@dh-software/lit-extensions": "^0.0.14",
    "bibliothek-types": "^0.1.0",
    "construct-style-sheets-polyfill": "^3.1.0",
    "lit": "^3.3.2"
  },
  "devDependencies": {
    "css-loader": "^7.1.2",
    "html-webpack-plugin": "^5.6.3",
    "style-loader": "^4.0.0",
    "ts-loader": "^9.5.2",
    "typescript": "^5.7.3",
    "webpack": "^5.97.1",
    "webpack-cli": "^6.0.1"
  }
}
desktop/frontend/src/bruecke.ts
Neue Datei
@@ -0,0 +1,69 @@
/**
 * Typisierter Zugang zur Python-Seite.
 *
 * pywebview hängt seine Brücke erst ein, wenn das Fenster bereit ist, und meldet
 * das Ã¼ber das Ereignis `pywebviewready`. Jeder Aufruf davor liefe ins Leere â€”
 * deshalb wartet `bruecke()` einmalig darauf.
 */
import type { Software } from 'bibliothek-types';
/** Zustand einer Datei im Werkzeug-Ordner, ermittelt Ã¼ber ihre Prüfsumme. */
export type LokalerZustand = 'fehlt' | 'aktuell' | 'veraltet' | 'fremd';
export interface LokalerBefund {
  zustand: LokalerZustand;
  dateiname: string;
  abgelegteVersion: string;
  pfad: string;
}
/** Katalogeintrag, angereichert um das, was auf diesem Rechner liegt. */
export interface EintragMitBefund extends Software {
  /** Nur bei eigenständigen Programmen gefüllt â€” bei Installern sagt der Ordner nichts. */
  lokal: LokalerBefund | null;
  /** Fließt dieser Eintrag in die Update-Meldung ein? */
  ueberwacht: boolean;
}
export interface Einstellungen {
  serverAdresse: string;
  werkzeugOrdner: string;
}
interface PywebviewApi {
  einstellungen(): Promise<{ einstellungen: Einstellungen; vorgaben: Einstellungen }>;
  einstellungen_speichern(
    serverAdresse: string,
    werkzeugOrdner: string
  ): Promise<{ ok: boolean; einstellungen: Einstellungen; vorgaben: Einstellungen }>;
  katalog(): Promise<{ ok: boolean; meldung?: string; eintraege: EintragMitBefund[]; ordner?: string }>;
  herunterladen(
    softwareId: number,
    dateiname: string,
    version: string,
    programmart: string
  ): Promise<{ ok: boolean; meldung: string; pfad?: string }>;
  ordner_oeffnen(unterordner?: string): Promise<{ ok: boolean }>;
  verknuepfung_anlegen(pfad: string, name: string): Promise<{ ok: boolean; meldung?: string }>;
}
declare global {
  interface Window {
    pywebview?: { api: PywebviewApi };
  }
}
let bereit: Promise<PywebviewApi> | undefined;
export function bruecke(): Promise<PywebviewApi> {
  if (!bereit) {
    bereit = new Promise((aufloesen) => {
      if (window.pywebview?.api) {
        aufloesen(window.pywebview.api);
        return;
      }
      window.addEventListener('pywebviewready', () => aufloesen(window.pywebview!.api), { once: true });
    });
  }
  return bereit;
}
desktop/frontend/src/components/client-app.ts
Neue Datei
@@ -0,0 +1,284 @@
import { LitElement, html, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { EINSTUFUNGEN, type Einstufung } from 'bibliothek-types';
import { bruecke, type EintragMitBefund, type Einstellungen } from '../bruecke';
import { gemerkterModus, setzeTheme, type ThemeModus } from '../theme';
import './client-zeile';
interface Auswahloption {
  dataId: string;
  showContent: string;
  selected?: boolean;
}
const THEME_OPTIONEN: Array<{ id: ThemeModus; text: string }> = [
  { id: 'system', text: 'System' },
  { id: 'hell', text: 'Hell' },
  { id: 'dunkel', text: 'Dunkel' },
];
/** Wurzel-Komponente des Clients. */
@customElement('client-app')
export class ClientApp extends LitElement {
  @state() private eintraege: EintragMitBefund[] = [];
  @state() private aktiveEinstufung: Einstufung = 'lizenzfrei';
  @state() private suchbegriff = '';
  @state() private ladefehler = '';
  @state() private meldung = '';
  @state() private laeuftFuer = 0;
  @state() private einstellungenOffen = false;
  @state() private einstellungen?: Einstellungen;
  @state() private serverAdresse = '';
  @state() private werkzeugOrdner = '';
  @state() private themeOptionen: Auswahloption[] = [];
  // Light DOM, damit das globale dh-components-Theme greift.
  createRenderRoot() {
    return this;
  }
  connectedCallback(): void {
    super.connectedCallback();
    const modus = gemerkterModus();
    this.themeOptionen = THEME_OPTIONEN.map((eintrag) => ({
      dataId: eintrag.id,
      showContent: eintrag.text,
      selected: eintrag.id === modus,
    }));
    void this.alesLaden();
  }
  render() {
    return html`
      <div class="rahmen">
        <header class="kopf">
          <h1>Software-Bibliothek</h1>
          <div class="kopf-aktionen">
            <dh-select
              class="theme-select"
              withoutEmptyDefault
              notSearchable
              .selectOptions=${this.themeOptionen}
              @changeSelectOption=${this.beiTheme}
            ></dh-select>
            <dh-button icon-button variant="standard" title="Werkzeug-Ordner Ã¶ffnen" @click=${this.beiOrdnerOeffnen}>
              <span slot="img" class="fv-icon-folder"></span>
            </dh-button>
            <dh-button icon-button variant="standard" title="Einstellungen" @click=${this.beiEinstellungenOeffnen}>
              <span slot="img" class="fv-icon-settings"></span>
            </dh-button>
          </div>
        </header>
        ${this.ladefehler ? html`<p class="warnung">${this.ladefehler}</p>` : nothing}
        ${this.meldung ? html`<p class="hinweis">${this.meldung}</p>` : nothing}
        ${this.rendereUpdateHinweis()}
        ${this.einstellungenOffen ? this.rendereEinstellungen() : this.rendereKatalog()}
      </div>
    `;
  }
  // --- Teilansichten ---
  /** Der Grund, warum es diesen Client gibt: ungefragt sagen, was veraltet ist. */
  private rendereUpdateHinweis() {
    const veraltet = this.eintraege.filter(
      (eintrag) => eintrag.ueberwacht && (eintrag.lokal?.zustand === 'veraltet' || eintrag.lokal?.zustand === 'fremd')
    );
    if (veraltet.length === 0) {
      return nothing;
    }
    return html`<p class="update-hinweis">
      ${veraltet.length === 1 ? 'Ein Werkzeug ist nicht auf dem neuesten Stand' : `${veraltet.length} Werkzeuge sind nicht auf dem neuesten Stand`}:
      ${veraltet.map((eintrag) => eintrag.name).join(', ')}.
    </p>`;
  }
  private rendereKatalog() {
    if (this.eintraege.length === 0) {
      return html`<p class="leer">${this.ladefehler ? '' : 'Katalog wird geladen â€¦'}</p>`;
    }
    return html`
      <dh-input class="suche" type="text" placeholder="Suchen" .value=${this.suchbegriff} @input=${this.beiSuche}></dh-input>
      <dh-tab stretch @clickTab=${this.beiReiterWechsel}>
        ${EINSTUFUNGEN.map(
          (eintrag) => html`<div
            slot="tab-head"
            content=${eintrag.id}
            class=${eintrag.id === this.aktiveEinstufung ? 'selected' : ''}
          >
            ${eintrag.text} (${this.mitEinstufung(eintrag.id).length})
          </div>`
        )}
        ${EINSTUFUNGEN.map(
          (eintrag) => html`<div
            slot="tab-content"
            tab=${eintrag.id}
            class=${eintrag.id === this.aktiveEinstufung ? '' : 'hidden'}
          >
            ${eintrag.id === this.aktiveEinstufung ? this.rendereListe() : nothing}
          </div>`
        )}
      </dh-tab>
    `;
  }
  private rendereListe() {
    const gefiltert = this.gefiltert();
    if (gefiltert.length === 0) {
      return html`<p class="leer">
        ${this.suchbegriff ? html`Keine Treffer für â€ž${this.suchbegriff}".` : 'In dieser Einstufung ist nichts hinterlegt.'}
      </p>`;
    }
    return html`<div class="liste">
      ${gefiltert.map(
        (eintrag) => html`<client-zeile
          .eintrag=${eintrag}
          ?laeuft=${this.laeuftFuer === eintrag.id}
          @holen=${this.beiHolen}
          @verknuepfen=${this.beiVerknuepfen}
        ></client-zeile>`
      )}
    </div>`;
  }
  private rendereEinstellungen() {
    return html`
      <dh-card variant="outlined" no-hover-effect no-click-effect>
        <span slot="header">Einstellungen</span>
        <dh-form-grid slot="content">
          <dh-form-field label="Adresse der Bibliothek">
            <dh-input type="text" .value=${this.serverAdresse} @input=${this.beiServerAdresse}></dh-input>
            <span class="feldhinweis">
              Rechnername statt IP-Adresse ist die bessere Wahl â€” er Ã¼berlebt einen Wechsel der Adresse.
            </span>
          </dh-form-field>
          <dh-form-field label="Werkzeug-Ordner">
            <dh-input type="text" .value=${this.werkzeugOrdner} @input=${this.beiWerkzeugOrdner}></dh-input>
            <span class="feldhinweis">
              Hierhin kommen eigenständige Programme; Installationsprogramme in den Unterordner
              â€žInstallationsprogramme".
            </span>
          </dh-form-field>
        </dh-form-grid>
        <div slot="actions">
          <dh-button buttonText="Speichern" variant="filled" @click=${this.beiEinstellungenSpeichern}></dh-button>
          <dh-button buttonText="Abbrechen" variant="standard" @click=${this.beiEinstellungenSchliessen}></dh-button>
        </div>
      </dh-card>
    `;
  }
  // --- Daten ---
  private async alesLaden(): Promise<void> {
    const api = await bruecke();
    const stand = await api.einstellungen();
    this.einstellungen = stand.einstellungen;
    this.serverAdresse = stand.einstellungen.serverAdresse;
    this.werkzeugOrdner = stand.einstellungen.werkzeugOrdner;
    await this.katalogLaden();
  }
  private async katalogLaden(): Promise<void> {
    const api = await bruecke();
    const antwort = await api.katalog();
    if (!antwort.ok) {
      this.ladefehler = `${antwort.meldung} â€” Adresse in den Einstellungen prüfen.`;
      this.eintraege = [];
      return;
    }
    this.ladefehler = '';
    this.eintraege = antwort.eintraege;
  }
  private mitEinstufung(einstufung: Einstufung): EintragMitBefund[] {
    return this.eintraege.filter((eintrag) => eintrag.einstufung === einstufung && !eintrag.elternId);
  }
  private gefiltert(): EintragMitBefund[] {
    const suche = this.suchbegriff.trim().toLowerCase();
    const inDerEinstufung = this.mitEinstufung(this.aktiveEinstufung);
    if (!suche) {
      return inDerEinstufung;
    }
    return inDerEinstufung.filter(
      (eintrag) =>
        eintrag.name.toLowerCase().includes(suche) || eintrag.beschreibung.toLowerCase().includes(suche)
    );
  }
  // --- Ereignisse ---
  private beiTheme(ereignis: CustomEvent<{ selectedOption: { dataId: string } }>): void {
    setzeTheme(ereignis.detail.selectedOption.dataId as ThemeModus);
  }
  private beiSuche(ereignis: CustomEvent<string>): void {
    this.suchbegriff = ereignis.detail;
  }
  /** dh-tab liefert die Kennung des Reiters unmittelbar als `detail`. */
  private beiReiterWechsel(ereignis: CustomEvent<string>): void {
    this.aktiveEinstufung = ereignis.detail as Einstufung;
  }
  private async beiHolen(ereignis: CustomEvent<EintragMitBefund>): Promise<void> {
    const eintrag = ereignis.detail;
    this.laeuftFuer = eintrag.id;
    this.meldung = `${eintrag.name} wird geholt â€¦`;
    const api = await bruecke();
    const ergebnis = await api.herunterladen(eintrag.id, eintrag.dateiname, eintrag.version, eintrag.programmart);
    this.meldung = ergebnis.meldung;
    this.laeuftFuer = 0;
    await this.katalogLaden();
  }
  private async beiVerknuepfen(ereignis: CustomEvent<EintragMitBefund>): Promise<void> {
    const eintrag = ereignis.detail;
    if (!eintrag.lokal?.pfad) {
      return;
    }
    const api = await bruecke();
    const ergebnis = await api.verknuepfung_anlegen(eintrag.lokal.pfad, eintrag.name);
    this.meldung = ergebnis.meldung ?? '';
  }
  private async beiOrdnerOeffnen(): Promise<void> {
    const api = await bruecke();
    await api.ordner_oeffnen();
  }
  private beiEinstellungenOeffnen(): void {
    this.serverAdresse = this.einstellungen?.serverAdresse ?? '';
    this.werkzeugOrdner = this.einstellungen?.werkzeugOrdner ?? '';
    this.einstellungenOffen = true;
  }
  private beiEinstellungenSchliessen(): void {
    this.einstellungenOffen = false;
  }
  private beiServerAdresse(ereignis: CustomEvent<string>): void {
    this.serverAdresse = ereignis.detail;
  }
  private beiWerkzeugOrdner(ereignis: CustomEvent<string>): void {
    this.werkzeugOrdner = ereignis.detail;
  }
  private async beiEinstellungenSpeichern(): Promise<void> {
    const api = await bruecke();
    const ergebnis = await api.einstellungen_speichern(this.serverAdresse, this.werkzeugOrdner);
    this.einstellungen = ergebnis.einstellungen;
    this.einstellungenOffen = false;
    this.meldung = 'Einstellungen gespeichert.';
    await this.katalogLaden();
  }
}
declare global {
  interface HTMLElementTagNameMap {
    'client-app': ClientApp;
  }
}
desktop/frontend/src/components/client-zeile.ts
Neue Datei
@@ -0,0 +1,135 @@
import { LitElement, html, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { event, type EventRef } from '@dh-software/lit-extensions';
import type { EintragMitBefund, LokalerZustand } from '../bruecke';
/** Was der jeweilige Zustand für den Nutzer bedeutet. */
const ZUSTAND_TEXT: Record<LokalerZustand, string> = {
  fehlt: 'nicht auf diesem Rechner',
  aktuell: 'aktuell',
  veraltet: 'veraltet',
  fremd: 'von Hand verändert',
};
const ZUSTAND_KLASSE: Record<LokalerZustand, string> = {
  fehlt: 'zustand-fehlt',
  aktuell: 'zustand-aktuell',
  veraltet: 'zustand-veraltet',
  fremd: 'zustand-fremd',
};
/**
 * Eine Zeile des Katalogs im Client. Anders als in der Weboberfläche steht hier,
 * was auf diesem Rechner liegt â€” das ist der ganze Unterschied.
 */
@customElement('client-zeile')
export class ClientZeile extends LitElement {
  @property({ attribute: false }) eintrag!: EintragMitBefund;
  @property({ type: Boolean }) laeuft = false;
  @event({ bubbles: true, composed: true }, 'holen')
  private holen!: EventRef<EintragMitBefund>;
  @event({ bubbles: true, composed: true }, 'verknuepfen')
  private verknuepfen!: EventRef<EintragMitBefund>;
  // Light DOM, damit das globale dh-components-Theme greift.
  createRenderRoot() {
    return this;
  }
  render() {
    const eintrag = this.eintrag;
    return html`
      <div class="zeile">
        <div class="zeile-info">
          <span class="zeile-name">
            ${eintrag.name}
            ${eintrag.einstufung === 'lizenzpflichtig'
              ? html`<span class="marke lizenz-marke">lizenzpflichtig</span>`
              : nothing}
            ${eintrag.einstufung === 'intern' ? html`<span class="marke intern-marke">intern</span>` : nothing}
          </span>
          <span class="zeile-meta">${this.rendereStand()}${this.rendereZustand()}</span>
          ${eintrag.hinweis ? html`<span class="zeile-hinweis">${eintrag.hinweis}</span>` : nothing}
        </div>
        <div class="zeile-aktionen">${this.rendereAktion()}${this.rendereVerknuepfung()}</div>
      </div>
    `;
  }
  private rendereStand() {
    const zeitpunkt = this.eintrag.veroeffentlichtAm || this.eintrag.dateiStandVom;
    if (!zeitpunkt) {
      return nothing;
    }
    const datum = new Date(zeitpunkt);
    if (Number.isNaN(datum.getTime())) {
      return nothing;
    }
    return html`Stand ${datum.toLocaleDateString('de-DE')}`;
  }
  /** Nur bei eigenständigen Programmen aussagekräftig â€” Installer verraten hier nichts. */
  private rendereZustand() {
    const befund = this.eintrag.lokal;
    if (!befund) {
      return nothing;
    }
    return html` &nbsp;·&nbsp;
      <span class=${ZUSTAND_KLASSE[befund.zustand]}>${ZUSTAND_TEXT[befund.zustand]}</span>`;
  }
  /**
   * Ein rundes Symbol wie in der Weboberfläche. Was der Klick bewirkt, steht
   * ohnehin schon als Zustand in der Zeile â€” die Beschriftung wäre eine
   * Wiederholung. Steht ein Update an, ist der Knopf gefüllt statt flach.
   */
  private rendereAktion() {
    const eintrag = this.eintrag;
    if (!eintrag.dateiname) {
      return html`<span class="zeile-hinweis">keine Datei hinterlegt</span>`;
    }
    const zustand = eintrag.lokal?.zustand;
    const stehtAn = zustand === 'veraltet' || zustand === 'fremd';
    const zweck = stehtAn ? 'aktualisieren' : zustand === 'aktuell' ? 'erneut holen' : 'holen';
    return html`<dh-button
      icon-button
      variant=${stehtAn ? 'filled' : 'standard'}
      title=${`${eintrag.name} ${zweck}`}
      ?disabled=${this.laeuft}
      @click=${this.beiHolen}
    >
      <span slot="img" class="fv-icon-download"></span>
    </dh-button>`;
  }
  private rendereVerknuepfung() {
    const befund = this.eintrag.lokal;
    if (!befund || befund.zustand === 'fehlt') {
      return nothing;
    }
    return html`<dh-button
      icon-button
      variant="standard"
      title="Verknüpfung auf dem Desktop anlegen"
      @click=${this.beiVerknuepfen}
    >
      <span slot="img" class="fv-icon-link"></span>
    </dh-button>`;
  }
  private beiHolen(): void {
    this.holen.dispatch(this.eintrag);
  }
  private beiVerknuepfen(): void {
    this.verknuepfen.dispatch(this.eintrag);
  }
}
declare global {
  interface HTMLElementTagNameMap {
    'client-zeile': ClientZeile;
  }
}
desktop/frontend/src/global.css
Neue Datei
@@ -0,0 +1,200 @@
html,
body {
  margin: 0;
  min-height: 100%;
  background: var(--md-sys-color-surface, #ffffff);
  color: var(--md-sys-color-on-surface, #1a1a1a);
  font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
}
.rahmen {
  padding: 20px 24px 32px;
  max-width: 1000px;
  margin: 0 auto;
}
.kopf {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 16px;
}
.kopf h1 {
  font-size: 1.3rem;
  margin: 0;
}
.kopf-aktionen {
  display: flex;
  gap: 8px;
  align-items: center;
}
.theme-select {
  min-width: 140px;
}
.hinweis {
  background: var(--md-sys-color-secondary-container, #e6e0ec);
  color: var(--md-sys-color-on-secondary-container, #1d1b20);
  padding: 12px 16px;
  border-radius: 8px;
  margin-bottom: 16px;
}
.warnung {
  padding: 12px 16px;
  margin-bottom: 16px;
  border-radius: 8px;
  border: 1px solid var(--md-sys-color-error, #b00020);
  background: var(--md-sys-color-error-container, rgba(176, 0, 32, 0.12));
  color: var(--md-sys-color-on-error-container, #410002);
}
/* Die Meldung, für die es den Client gibt â€” deutlicher als ein normaler Hinweis. */
.update-hinweis {
  padding: 12px 16px;
  margin-bottom: 16px;
  border-radius: 8px;
  border-left: 4px solid var(--md-sys-color-primary, #ff8014);
  background: var(--md-sys-color-surface-container-high, #eeeeee);
  color: var(--md-sys-color-on-surface, #1a1a1a);
}
.suche {
  display: block;
  max-width: 400px;
  margin-bottom: 12px;
}
dh-tab {
  display: block;
}
[slot='tab-content'].hidden {
  display: none;
}
.leer {
  padding: 16px 4px;
  opacity: 0.7;
}
.liste {
  border: 1px solid var(--md-sys-color-outline-variant, #cac4d0);
  border-radius: 8px;
  overflow: hidden;
}
.liste > client-zeile {
  display: block;
  border-bottom: 1px solid var(--md-sys-color-outline-variant, #e7e0ec);
}
.liste > client-zeile:last-child {
  border-bottom: none;
}
.zeile {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 8px 16px;
}
.zeile-info {
  display: flex;
  flex-direction: column;
  min-width: 0;
  flex: 1 1 auto;
  overflow: hidden;
}
.zeile-info > * {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.zeile-name {
  font-weight: 600;
  display: flex;
  align-items: center;
  gap: 8px;
}
.zeile-meta {
  font-size: 0.85em;
  opacity: 0.7;
}
.zeile-hinweis {
  font-size: 0.85em;
  color: var(--md-sys-color-on-surface-variant, #49454f);
  font-style: italic;
}
/* Feste Spalten, damit die Schaltflächen Ã¼ber alle Zeilen untereinander stehen. */
.zeile-aktionen {
  display: grid;
  grid-template-columns: 48px 48px;
  gap: 8px;
  align-items: center;
  justify-items: center;
  flex-shrink: 0;
}
.marke {
  flex-shrink: 0;
  font-size: 0.72em;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 2px 8px;
  border-radius: 999px;
}
.intern-marke {
  background: var(--md-sys-color-primary-container, #ffe4cc);
  color: var(--md-sys-color-on-primary-container, #4a1e00);
}
.lizenz-marke {
  background: var(--md-sys-color-tertiary-container, #ffd9a0);
  color: var(--md-sys-color-on-tertiary-container, #2b1700);
}
.zustand-aktuell {
  color: var(--md-sys-color-primary, #ff8014);
}
.zustand-veraltet,
.zustand-fremd {
  color: var(--md-sys-color-error, #b00020);
  font-weight: 500;
}
.zustand-fehlt {
  font-style: italic;
}
.feldhinweis {
  display: block;
  font-size: 0.8em;
  opacity: 0.7;
  margin-top: 4px;
}
dh-card {
  display: block;
}
[slot='actions'] {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
}
desktop/frontend/src/index.html
Neue Datei
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="de">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Software-Bibliothek</title>
  </head>
  <body>
    <client-app></client-app>
  </body>
</html>
desktop/frontend/src/index.ts
Neue Datei
@@ -0,0 +1,15 @@
import 'construct-style-sheets-polyfill';
// Theme in fester Reihenfolge: Tokens -> Styles -> Komponenten.
import { initTheme } from './theme';
import '@dh-software/dh-components-themes/styles.css';
import '@dh-software/dh-components';
import './global.css';
import { injectStylesheets } from '@dh-software/furnview-icons';
injectStylesheets(true);
initTheme();
import './components/client-app';
desktop/frontend/src/raw.d.ts
Neue Datei
@@ -0,0 +1,5 @@
/** Import mit `?raw` liefert den Dateiinhalt als Zeichenkette (webpack asset/source). */
declare module '*?raw' {
  const inhalt: string;
  export default inhalt;
}
desktop/frontend/src/theme.ts
Neue Datei
@@ -0,0 +1,58 @@
/**
 * Theme-Umschaltung (System/hell/dunkel) auf Basis der Design-System-Defines.
 * Beide Defines werden als <style> eingehängt; je nach Modus ist genau eines
 * aktiv (das andere per `disabled` abgeschaltet). â€žSystem" folgt der
 * Windows-Einstellung (prefers-color-scheme).
 *
 * Gleiches Vorgehen wie in der Weboberfläche und im meeting-transkriptor, damit
 * die Bedienung Ã¼ber unsere Anwendungen hinweg einheitlich bleibt.
 */
// Relativer Pfad (umgeht das exports-Feld des Pakets, das ?raw sonst blockt).
import hellCss from '../../../node_modules/@dh-software/dh-components-themes/build/defines.orange-light.css?raw';
import dunkelCss from '../../../node_modules/@dh-software/dh-components-themes/build/defines.orange-dark.css?raw';
export type ThemeModus = 'system' | 'hell' | 'dunkel';
const SPEICHER_SCHLUESSEL = 'software-bibliothek-client-theme';
let hellStil: HTMLStyleElement;
let dunkelStil: HTMLStyleElement;
let aktuellerModus: ThemeModus = 'system';
const dunkelMedia = window.matchMedia('(prefers-color-scheme: dark)');
function anwenden(): void {
  const dunkel = aktuellerModus === 'dunkel' || (aktuellerModus === 'system' && dunkelMedia.matches);
  hellStil.disabled = dunkel;
  dunkelStil.disabled = !dunkel;
  document.documentElement.style.colorScheme = dunkel ? 'dark' : 'light';
  document.documentElement.dataset.theme = dunkel ? 'dunkel' : 'hell';
}
/** Liest den zuletzt gewählten Modus (Standard: System). */
export function gemerkterModus(): ThemeModus {
  const gemerkt = localStorage.getItem(SPEICHER_SCHLUESSEL);
  return gemerkt === 'hell' || gemerkt === 'dunkel' || gemerkt === 'system' ? gemerkt : 'system';
}
/** Hängt beide Theme-Defines ein und aktiviert den gemerkten Modus. */
export function initTheme(): void {
  hellStil = document.createElement('style');
  hellStil.textContent = hellCss;
  dunkelStil = document.createElement('style');
  dunkelStil.textContent = dunkelCss;
  document.head.append(hellStil, dunkelStil);
  dunkelMedia.addEventListener('change', () => {
    if (aktuellerModus === 'system') {
      anwenden();
    }
  });
  aktuellerModus = gemerkterModus();
  anwenden();
}
/** Setzt den Modus und merkt ihn für den nächsten Aufruf. */
export function setzeTheme(modus: ThemeModus): void {
  aktuellerModus = modus;
  localStorage.setItem(SPEICHER_SCHLUESSEL, modus);
  anwenden();
}
desktop/frontend/tsconfig.json
Neue Datei
@@ -0,0 +1,15 @@
{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["DOM", "DOM.Iterable", "ES2021"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "experimentalDecorators": true,
    "useDefineForClassFields": false,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "exclude": ["node_modules", "dist"]
}
desktop/frontend/webpack.config.js
Neue Datei
@@ -0,0 +1,56 @@
import path from 'path';
import { fileURLToPath } from 'url';
import HtmlWebpackPlugin from 'html-webpack-plugin';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const isProduction = process.env.NODE_ENV === 'production';
export default {
  entry: './src/index.ts',
  mode: isProduction ? 'production' : 'development',
  devtool: isProduction ? false : 'inline-source-map',
  experiments: {
    topLevelAwait: true,
  },
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: 'ts-loader',
        exclude: /node_modules\/(?!@dh-software)/,
      },
      {
        test: /\.css$/i,
        resourceQuery: { not: [/raw/] },
        use: ['style-loader', 'css-loader'],
      },
      {
        // Import mit ?raw liefert den CSS-Quelltext als String (Theme-Umschaltung).
        resourceQuery: /raw/,
        type: 'asset/source',
      },
      {
        test: /\.(svg|ttf|woff|woff2|png|jpg|gif|ico)$/i,
        type: 'asset',
      },
    ],
  },
  resolve: {
    extensions: ['.ts', '.js'],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: 'src/index.html',
      // Das Fenster laedt die Datei direkt vom Dateisystem â€” relative Pfade.
      publicPath: '',
    }),
  ],
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'index.[contenthash].js',
    clean: true,
  },
  stats: {
    errorDetails: true,
  },
};
desktop/generate_icon.py
Neue Datei
@@ -0,0 +1,40 @@
"""Erzeugt das App-Icon als assets/icon.ico.
Pillow wird nur zum Erzeugen gebraucht, nicht zur Laufzeit â€” deshalb steht es
bewusst nicht in requirements.txt:
    .venv\\Scripts\\python.exe -m pip install pillow
    .venv\\Scripts\\python.exe generate_icon.py
Das Ergebnis gehoert ins Repo: Ein frischer Klon soll bauen koennen, ohne
Pillow zu installieren.
"""
from pathlib import Path
from PIL import Image, ImageDraw
GROESSE = 256
ORANGE = (255, 128, 20, 255)  # Markenfarbe --md-sys-color-primary
WEISS = (255, 255, 255, 255)
bild = Image.new("RGBA", (GROESSE, GROESSE), (0, 0, 0, 0))
zeichnung = ImageDraw.Draw(bild)
# Hintergrund: abgerundetes Quadrat in Markenorange â€” wie beim Transkriptor,
# damit unsere Werkzeuge in der Taskleiste als Familie erkennbar sind.
zeichnung.rounded_rectangle((8, 8, 248, 248), radius=52, fill=ORANGE)
# Pfeil nach unten: das Zeichen fuer â€žholen", dasselbe wie in der Oberflaeche.
zeichnung.rounded_rectangle((112, 44, 144, 132), radius=10, fill=WEISS)
zeichnung.polygon([(80, 118), (176, 118), (128, 186)], fill=WEISS)
# Ablage darunter â€” angedeutet als offene Schale, nicht als geschlossener Kasten.
zeichnung.rounded_rectangle((66, 198, 190, 216), radius=9, fill=WEISS)
zeichnung.rounded_rectangle((66, 160, 84, 216), radius=9, fill=WEISS)
zeichnung.rounded_rectangle((172, 160, 190, 216), radius=9, fill=WEISS)
ziel = Path(__file__).resolve().parent / "assets" / "icon.ico"
ziel.parent.mkdir(parents=True, exist_ok=True)
bild.save(ziel, sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)])
print(f"geschrieben: {ziel}")
desktop/requirements.txt
Neue Datei
@@ -0,0 +1,6 @@
# Abhaengigkeiten des Software-Bibliothek-Clients.
#
# Bewusst genau eine: Der Rest â€” Katalog holen, Dateien ablegen, Pruefsummen
# rechnen â€” laeuft mit der Standardbibliothek. Jedes Paket mehr muesste beim Bau
# der exe mitgeschleppt werden.
pywebview>=5.3  # natives Fenster (WebView2 unter Windows) + Bruecke zur Oberflaeche
desktop/run_app.py
Neue Datei
@@ -0,0 +1,10 @@
"""Direkter Startpunkt (auch Einstiegspunkt fuer den exe-Bau).
Entwicklung:  .venv\\Scripts\\python.exe run_app.py
oder:         .venv\\Scripts\\python.exe -m backend
"""
from backend.__main__ import main
if __name__ == "__main__":
    main()
firewall-freigabe-einrichten.bat
Datei wurde gelöscht
install.bat
Datei wurde gelöscht
installers/LIESMICH.txt
Datei wurde gelöscht
package-lock.json
Neue Datei
Zu viele Änderungen; Diff wird nicht angezeigt
package.json
Neue Datei
@@ -0,0 +1,22 @@
{
  "name": "software-bibliothek",
  "version": "0.1.0",
  "description": "Interne Software-Bibliothek â€” Katalog, Datei-Download, Zugriffsprotokoll (D+H)",
  "private": true,
  "workspaces": [
    "./packages/*",
    "./desktop/frontend"
  ],
  "scripts": {
    "build": "npm run build -w packages/bibliothek-types && npm run build -w packages/bibliothek-server && npm run build -w packages/bibliothek-client",
    "build:exe": "npm run build && npm run bundle -w packages/bibliothek-server && node bauen-exe.cjs",
    "build:desktop-ui": "npm run build -w packages/bibliothek-types && npm run build -w desktop/frontend",
    "dev:server": "npm run dev -w packages/bibliothek-server",
    "dev:client": "npm run dev -w packages/bibliothek-client"
  },
  "devDependencies": {
    "postject": "^1.0.0-alpha.6",
    "prettier": "^3.4.2",
    "typescript": "^5.7.2"
  }
}
packages/bibliothek-client/package.json
Neue Datei
@@ -0,0 +1,28 @@
{
  "name": "bibliothek-client",
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "build": "webpack --node-env=production",
    "dev": "webpack serve --node-env=development --host 0.0.0.0"
  },
  "dependencies": {
    "@dh-software/dh-components": "^3.0.23",
    "@dh-software/dh-components-themes": "^1.0.21",
    "@dh-software/furnview-icons": "^1.0.21",
    "@dh-software/lit-extensions": "^0.0.14",
    "construct-style-sheets-polyfill": "^3.1.0",
    "lit": "^3.3.2"
  },
  "devDependencies": {
    "css-loader": "^7.1.2",
    "html-webpack-plugin": "^5.6.3",
    "sass": "^1.83.0",
    "sass-loader": "^16.0.4",
    "style-loader": "^4.0.0",
    "ts-loader": "^9.5.2",
    "webpack": "^5.97.1",
    "webpack-cli": "^6.0.1",
    "webpack-dev-server": "^5.2.0"
  }
}
packages/bibliothek-client/src/api-client.ts
Neue Datei
@@ -0,0 +1,130 @@
import type { Einstellungen, EinstellungenAnsicht, GeheimnisUebersicht, Software, SoftwareEingabe } from 'bibliothek-types';
/** Wertet eine Antwort aus, wirft bei Fehler mit der Server-Meldung. */
async function jsonOderFehler<T>(antwort: Response): Promise<T> {
  if (!antwort.ok) {
    const daten = (await antwort.json().catch(() => ({}))) as { fehler?: string };
    throw new Error(daten.fehler ?? `Fehler ${antwort.status}`);
  }
  return antwort.json() as Promise<T>;
}
/** Lädt den kompletten Software-Katalog vom Server. */
export async function ladeKatalog(): Promise<Software[]> {
  return jsonOderFehler(await fetch('/api/software'));
}
/** Fragt, ob der Zugriff direkt am Server (lokal) erfolgt â€” steuert die Sichtbarkeit der Verwaltung. */
export async function ladeUmgebung(): Promise<{ lokal: boolean }> {
  return jsonOderFehler(await fetch('/api/umgebung'));
}
export async function softwareAnlegen(eingabe: SoftwareEingabe): Promise<Software> {
  return jsonOderFehler(
    await fetch('/api/software', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(eingabe),
    })
  );
}
export async function softwareAktualisieren(id: number, eingabe: SoftwareEingabe): Promise<Software> {
  return jsonOderFehler(
    await fetch(`/api/software/${id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(eingabe),
    })
  );
}
export async function softwareLoeschen(id: number): Promise<void> {
  const antwort = await fetch(`/api/software/${id}`, { method: 'DELETE' });
  if (!antwort.ok) {
    throw new Error(`Löschen fehlgeschlagen (Status ${antwort.status})`);
  }
}
/** Hinterlegt eine Datei von Hand (Quellentyp â€žmanuell"). */
export async function dateiHochladen(id: number, datei: File): Promise<Software> {
  return jsonOderFehler(
    await fetch(`/api/software/${id}/datei?dateiname=${encodeURIComponent(datei.name)}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/octet-stream' },
      body: datei,
    })
  );
}
/** Löst einen sofortigen Abgleich der Bezugsquelle einer Software aus. */
export async function abgleichen(id: number): Promise<Software> {
  return jsonOderFehler(await fetch(`/api/software/${id}/abgleichen`, { method: 'POST' }));
}
/** Gleicht alle Einträge mit gesetzter Quelle ab. */
export async function alleAbgleichen(): Promise<Software[]> {
  return jsonOderFehler(await fetch('/api/abgleich/alle', { method: 'POST' }));
}
/** Stand der Bau-Geheimnisse einer Software (nur Namen, nie die Werte). */
export async function ladeGeheimnisse(id: number): Promise<GeheimnisUebersicht[]> {
  return jsonOderFehler(await fetch(`/api/software/${id}/geheimnisse`));
}
/** Speichert Bau-Geheimnisse. Ein leerer Wert lässt den bisherigen unverändert. */
export async function speichereGeheimnisse(id: number, werte: Array<{ name: string; wert: string }>): Promise<GeheimnisUebersicht[]> {
  return jsonOderFehler(
    await fetch(`/api/software/${id}/geheimnisse`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(werte),
    })
  );
}
/** URL für den Umzugs-Export (Katalog + Einstellungen, optional mit Geheimnissen). */
export function exportUrl(mitGeheimnissen: boolean): string {
  return `/api/export${mitGeheimnissen ? '?geheimnisse=ja' : ''}`;
}
/** Liest ein Umzugs-Paket ein und ersetzt damit den bisherigen Katalog. */
export async function importiereUmzugsPaket(paket: unknown): Promise<{ uebernommen: number; mitGeheimnissen: number; hinweis: string }> {
  return jsonOderFehler(
    await fetch('/api/import', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(paket),
    })
  );
}
/** Lädt die Einstellungen samt der daraus abgeleiteten Pfade (nur lokal erlaubt). */
export async function ladeEinstellungen(): Promise<EinstellungenAnsicht> {
  return jsonOderFehler(await fetch('/api/einstellungen'));
}
export async function speichereEinstellungen(einstellungen: Einstellungen): Promise<EinstellungenAnsicht> {
  return jsonOderFehler(
    await fetch('/api/einstellungen', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(einstellungen),
    })
  );
}
/** URL zum Herunterladen mehrerer Programme als ZIP. */
export function downloadMehrereUrl(ids: number[], angegebenerName?: string): string {
  const parameter = new URLSearchParams({ ids: ids.join(',') });
  if (angegebenerName) {
    parameter.set('name', angegebenerName);
  }
  return `/api/download-mehrere?${parameter.toString()}`;
}
/** URL zum Herunterladen der Datei einer Software (optional mit angegebenem Namen fürs Protokoll). */
export function downloadUrl(id: number, angegebenerName?: string): string {
  const basis = `/api/software/${id}/download`;
  return angegebenerName ? `${basis}?name=${encodeURIComponent(angegebenerName)}` : basis;
}
packages/bibliothek-client/src/components/bibliothek-app.ts
Neue Datei
@@ -0,0 +1,345 @@
import { LitElement, html, nothing } from 'lit';
import { customElement, query, state } from 'lit/decorators.js';
import type { DhInput, DhSelect } from '@dh-software/dh-components';
import { EINSTUFUNGEN, type Einstufung, type Software } from 'bibliothek-types';
import { ladeKatalog, ladeUmgebung, softwareLoeschen, abgleichen, alleAbgleichen, downloadUrl, downloadMehrereUrl } from '../api-client';
import { gemerkterModus, setzeTheme, type ThemeModus } from '../theme';
import './bibliothek-zeile';
import './bibliothek-formular';
import './bibliothek-einstellungen';
function themeOptionen(aktuell: ThemeModus) {
  // Frische Kopie je dh-select (die Komponente mutiert die Options-Objekte).
  return [
    { dataId: 'system', showContent: 'System', selected: aktuell === 'system' },
    { dataId: 'hell', showContent: 'Hell', selected: aktuell === 'hell' },
    { dataId: 'dunkel', showContent: 'Dunkel', selected: aktuell === 'dunkel' },
  ];
}
/** Wurzel-Komponente: Katalog als kompakte, durchsuchbare Liste; Verwaltung + Abgleich nur lokal. */
@customElement('bibliothek-app')
export class BibliothekApp extends LitElement {
  @query('#theme-select') private themeSelect?: DhSelect;
  @state() private katalog: Software[] = [];
  @state() private lokal = false;
  @state() private ladefehler = '';
  @state() private formularOffen = false;
  @state() private einstellungenOffen = false;
  @state() private zuBearbeiten?: Software;
  @state() private suchbegriff = '';
  @state() private beschaeftigt = false;
  @state() private themeModus: ThemeModus = gemerkterModus();
  @state() private aktiveEinstufung: Einstufung = 'lizenzfrei';
  /** Ids der für den Sammel-Download angehakten Einträge â€” bleibt Ã¼ber Reiter hinweg bestehen. */
  @state() private auswahl: ReadonlySet<number> = new Set();
  // Light DOM, damit die globalen dh-components-Theme-Regeln greifen.
  createRenderRoot() {
    return this;
  }
  connectedCallback(): void {
    super.connectedCallback();
    this.datenLaden();
  }
  protected firstUpdated(): void {
    if (this.themeSelect) {
      (this.themeSelect as unknown as { selectOptions: ReturnType<typeof themeOptionen> }).selectOptions = themeOptionen(this.themeModus);
    }
  }
  render() {
    return html`
      <div class="bibliothek-app-rahmen">
        <header class="kopf">
          <h1>Software-Bibliothek</h1>
          <div class="kopf-aktionen">
            <dh-select
              id="theme-select"
              class="theme-select"
              withoutEmptyDefault
              notSearchable
              @changeSelectOption=${this.beiThemeWechsel}
            ></dh-select>
            ${this.lokal && !this.formularOffen && !this.einstellungenOffen
              ? html`
                  <dh-button buttonText="Alle prüfen" variant="standard" ?disabled=${this.beschaeftigt} @click=${this.allePruefen}></dh-button>
                  <dh-button buttonText="+ Neue Software" variant="filled" @click=${this.neuAnlegen}></dh-button>
                  <dh-button icon-button title="Einstellungen" variant="standard" @click=${this.einstellungenOeffnen}>
                    <span slot="img" class="fv-icon-settings"></span>
                  </dh-button>
                `
              : nothing}
          </div>
        </header>
        ${this.beschaeftigt ? html`<p class="hinweis">Bezugsquellen werden geprüft â€¦</p>` : nothing}
        ${this.rendereProblemleiste()}
        ${this.auswahl.size > 0 && !this.formularOffen
          ? html`<div class="auswahl-leiste">
              <dh-button icon-button variant="standard" title="Auswahl aufheben" @click=${this.auswahlAufheben}>
                <span slot="img" class="fv-icon-close"></span>
              </dh-button>
              <span class="auswahl-anzahl">${this.auswahl.size} ausgewählt</span>
              <dh-button buttonText="Herunterladen" variant="filled" @click=${this.auswahlHerunterladen}></dh-button>
            </div>`
          : nothing}
        ${this.ladefehler ? html`<p class="fehler">${this.ladefehler}</p>` : nothing}
        ${this.einstellungenOffen
          ? html`<bibliothek-einstellungen
              @geschlossen=${this.einstellungenSchliessen}
              @umzugAbgeschlossen=${this.datenLaden}
            ></bibliothek-einstellungen>`
          : this.formularOffen
          ? html`<bibliothek-formular
              .software=${this.zuBearbeiten}
              .moeglicheEltern=${this.hauptEintraege()}
              @gespeichert=${this.beiGespeichert}
              @abgebrochen=${this.formularSchliessen}
            ></bibliothek-formular>`
          : this.rendereKatalog()}
      </div>
    `;
  }
  /**
   * Weist auf Einträge hin, deren letzter Abgleich fehlgeschlagen ist â€” sonst
   * fällt ein toter Bezugslink nur auf, wenn jemand gezielt in die Liste schaut.
   */
  private rendereProblemleiste() {
    const betroffene = this.katalog.filter((software) => software.letzterAbgleich && !software.abgleichErfolg);
    if (betroffene.length === 0) {
      return nothing;
    }
    const namen = betroffene.slice(0, 3).map((software) => software.name).join(', ');
    const weitere = betroffene.length > 3 ? ` und ${betroffene.length - 3} weitere` : '';
    // Der Hinweis auf den erhaltenen Stand ergibt nur Sinn, wenn es Ã¼berhaupt einen gibt.
    const mitBisherigerDatei = betroffene.some((software) => software.dateiname);
    return html`<p class="warnung">
      <strong>${betroffene.length} Eintrag${betroffene.length === 1 ? '' : 'e'} mit Problemen beim Abgleich:</strong>
      ${namen}${weitere}.${mitBisherigerDatei ? ' Die zuletzt bereitgestellte Datei bleibt dabei erhalten.' : ''}
    </p>`;
  }
  private rendereKatalog() {
    if (this.katalog.length === 0) {
      return html`<p>Noch keine Software im Katalog.</p>`;
    }
    return html`
      <dh-input
        class="suche"
        type="text"
        placeholder="Suchen"
        .value=${this.suchbegriff}
        @input=${this.beiSuche}
      ></dh-input>
      <dh-tab stretch @clickTab=${this.beiReiterWechsel}>
        ${EINSTUFUNGEN.map(
          (eintrag) => html`<div
            slot="tab-head"
            content=${eintrag.id}
            class=${eintrag.id === this.aktiveEinstufung ? 'selected' : ''}
          >
            ${eintrag.text} (${this.eintraegeMitEinstufung(eintrag.id).length})
          </div>`
        )}
        ${EINSTUFUNGEN.map(
          (eintrag) => html`<div slot="tab-content" tab=${eintrag.id} class=${eintrag.id === this.aktiveEinstufung ? '' : 'hidden'}>
            ${eintrag.id === this.aktiveEinstufung ? this.rendereListe() : nothing}
          </div>`
        )}
      </dh-tab>
    `;
  }
  private rendereListe() {
    const gefiltert = this.gefilterterKatalog();
    if (gefiltert.length === 0) {
      return html`<p class="leer">
        ${this.suchbegriff ? html`Keine Treffer für â€ž${this.suchbegriff}".` : 'In dieser Einstufung ist noch nichts hinterlegt.'}
      </p>`;
    }
    return html`
      ${this.aktiveEinstufung === 'lizenzpflichtig'
        ? html`<p class="hinweis">Für diese Programme kommt der Lizenzschlüssel Ã¼ber den Support.</p>`
        : nothing}
      <div class="liste ${this.lokal ? 'verwaltung' : ''}">
      ${gefiltert.map(
        (software) => html`<bibliothek-zeile
          .software=${software}
          .zusatzinhalte=${this.zusatzinhalteVon(software.id)}
          .auswahl=${this.auswahl}
          ?gewaehlt=${this.auswahl.has(software.id)}
          ?lokal=${this.lokal}
          @auswahlGeaendert=${this.beiAuswahlGeaendert}
          @herunterladen=${this.beiHerunterladen}
          @pruefen=${this.beiPruefen}
          @bearbeiten=${this.beiBearbeiten}
          @loeschen=${this.beiLoeschen}
        ></bibliothek-zeile>`
      )}
    </div>`;
  }
  private beiAuswahlGeaendert(ereignis: CustomEvent<{ software: Software; gewaehlt: boolean }>): void {
    const { software, gewaehlt } = ereignis.detail;
    const neueAuswahl = new Set(this.auswahl);
    if (gewaehlt) {
      neueAuswahl.add(software.id);
    } else {
      neueAuswahl.delete(software.id);
    }
    this.auswahl = neueAuswahl;
  }
  private auswahlAufheben(): void {
    this.auswahl = new Set();
  }
  private auswahlHerunterladen(): void {
    const angegebenerName = this.namenErfragen();
    if (angegebenerName === null) {
      return;
    }
    window.location.href = downloadMehrereUrl([...this.auswahl], angegebenerName);
  }
  /** Fragt im Netzwerk-Betrieb nach dem Namen fürs Protokoll; lokal nicht nötig. */
  private namenErfragen(): string | undefined | null {
    if (this.lokal) {
      return undefined;
    }
    return prompt('Bitte deinen Namen angeben (wird protokolliert):');
  }
  private beiReiterWechsel(ereignis: CustomEvent<string>): void {
    this.aktiveEinstufung = ereignis.detail as Einstufung;
  }
  /** Eigenständige Tools einer Einstufung (Grundlage für die Zähler an den Reitern). */
  private eintraegeMitEinstufung(einstufung: Einstufung): Software[] {
    return this.hauptEintraege().filter((software) => software.einstufung === einstufung);
  }
  /** Eigenständige Tools (ohne die Zusatzinhalte, die unter ihrem Tool erscheinen). */
  private hauptEintraege(): Software[] {
    return this.katalog.filter((software) => !software.elternId);
  }
  private zusatzinhalteVon(elternId: number): Software[] {
    return this.katalog.filter((software) => software.elternId === elternId);
  }
  /** Einträge des aktiven Reiters, zusätzlich nach dem Suchbegriff gefiltert. */
  private gefilterterKatalog(): Software[] {
    const eintraege = this.eintraegeMitEinstufung(this.aktiveEinstufung);
    const begriff = this.suchbegriff.trim().toLowerCase();
    if (!begriff) {
      return eintraege;
    }
    return eintraege.filter(
      (software) => software.name.toLowerCase().includes(begriff) || software.beschreibung.toLowerCase().includes(begriff)
    );
  }
  private beiSuche(ereignis: Event): void {
    this.suchbegriff = (ereignis.target as DhInput).value;
  }
  private beiThemeWechsel(ereignis: CustomEvent<{ selectedOption: { dataId: string } }>): void {
    this.themeModus = ereignis.detail.selectedOption.dataId as ThemeModus;
    setzeTheme(this.themeModus);
  }
  public async datenLaden(): Promise<void> {
    try {
      const [umgebung, katalog] = await Promise.all([ladeUmgebung(), ladeKatalog()]);
      this.lokal = umgebung.lokal;
      this.katalog = katalog;
      this.ladefehler = '';
    } catch (fehler) {
      this.ladefehler = fehler instanceof Error ? fehler.message : 'Daten konnten nicht geladen werden.';
    }
  }
  private neuAnlegen(): void {
    this.zuBearbeiten = undefined;
    this.formularOffen = true;
  }
  private einstellungenOeffnen(): void {
    this.einstellungenOffen = true;
  }
  private einstellungenSchliessen(): void {
    this.einstellungenOffen = false;
  }
  private beiBearbeiten(ereignis: CustomEvent<Software>): void {
    this.zuBearbeiten = ereignis.detail;
    this.formularOffen = true;
  }
  private formularSchliessen(): void {
    this.formularOffen = false;
    this.zuBearbeiten = undefined;
  }
  private beiGespeichert(): void {
    this.formularSchliessen();
    this.datenLaden();
  }
  private beiHerunterladen(ereignis: CustomEvent<Software>): void {
    const angegebenerName = this.namenErfragen();
    if (angegebenerName === null) {
      return;
    }
    window.location.href = downloadUrl(ereignis.detail.id, angegebenerName);
  }
  private async beiPruefen(ereignis: CustomEvent<Software>): Promise<void> {
    await this.mitBeschaeftigt(() => abgleichen(ereignis.detail.id));
    this.datenLaden();
  }
  private async allePruefen(): Promise<void> {
    await this.mitBeschaeftigt(() => alleAbgleichen());
    this.datenLaden();
  }
  private async beiLoeschen(ereignis: CustomEvent<Software>): Promise<void> {
    const software = ereignis.detail;
    const anzahlZusatzinhalte = this.zusatzinhalteVon(software.id).length;
    const zusatz = anzahlZusatzinhalte > 0 ? ` Die ${anzahlZusatzinhalte} zugehörigen Zusatzinhalte werden mitgelöscht.` : '';
    if (!confirm(`„${software.name}" wirklich löschen?${zusatz}`)) {
      return;
    }
    try {
      await softwareLoeschen(software.id);
      this.datenLaden();
    } catch (fehler) {
      this.ladefehler = fehler instanceof Error ? fehler.message : 'Löschen fehlgeschlagen.';
    }
  }
  private async mitBeschaeftigt(aktion: () => Promise<unknown>): Promise<void> {
    this.beschaeftigt = true;
    this.ladefehler = '';
    try {
      await aktion();
    } catch (fehler) {
      this.ladefehler = fehler instanceof Error ? fehler.message : 'Abgleich fehlgeschlagen.';
    } finally {
      this.beschaeftigt = false;
    }
  }
}
declare global {
  interface HTMLElementTagNameMap {
    'bibliothek-app': BibliothekApp;
  }
}
packages/bibliothek-client/src/components/bibliothek-einstellungen.ts
Neue Datei
@@ -0,0 +1,224 @@
import { LitElement, html, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { event, type EventRef } from '@dh-software/lit-extensions';
import type { DhInput } from '@dh-software/dh-components';
import type { EinstellungenAnsicht } from 'bibliothek-types';
import { ladeEinstellungen, speichereEinstellungen, exportUrl, importiereUmzugsPaket } from '../api-client';
/** Einstellungen der Anwendung â€” nur direkt am Server erreichbar. */
@customElement('bibliothek-einstellungen')
export class BibliothekEinstellungen extends LitElement {
  @state() private ansicht?: EinstellungenAnsicht;
  @state() private datenOrdner = '';
  @state() private port = 3000;
  @state() private gitBenutzer = '';
  @state() private gitSchluessel = '';
  @state() private meldung = '';
  @state() private fehler = '';
  @state() private speichert = false;
  @event({ bubbles: true, composed: true }, 'geschlossen')
  private geschlossen!: EventRef<void>;
  @event({ bubbles: true, composed: true }, 'umzugAbgeschlossen')
  private umzugAbgeschlossen!: EventRef<void>;
  // Light DOM, damit das globale dh-components-Theme greift.
  createRenderRoot() {
    return this;
  }
  connectedCallback(): void {
    super.connectedCallback();
    this.einstellungenLaden();
  }
  render() {
    if (!this.ansicht) {
      return html`<dh-card variant="outlined"><span slot="content">Einstellungen werden geladen â€¦</span></dh-card>`;
    }
    const neustartNoetig =
      this.datenOrdner.trim() !== this.ansicht.einstellungen.datenOrdner || this.port !== this.ansicht.laufenderPort;
    return html`
      <dh-card variant="outlined" no-hover-effect no-click-effect>
        <span slot="header">Einstellungen</span>
        <div slot="content">
          <dh-form-grid>
            <dh-form-field label="Datenordner" span="2">
              <dh-input
                type="text"
                placeholder=${'leer = Standardordner unter ProgramData'}
                .value=${this.datenOrdner}
                @input=${this.beiDatenOrdner}
              ></dh-input>
              <span class="feldhinweis">Hier liegen die Datenbank und alle bereitgestellten Programm-Dateien.</span>
            </dh-form-field>
            <dh-form-field label="Port">
              <dh-input type="number" min="1" max="65535" .value=${String(this.port)} @input=${this.beiPort}></dh-input>
            </dh-form-field>
            <dh-form-field label="Git-Benutzer (für interne Tools)">
              <dh-input
                type="text"
                placeholder="leer = SSH-Konfiguration des Dienstbenutzers"
                .value=${this.gitBenutzer}
                @input=${this.beiGitBenutzer}
              ></dh-input>
              <span class="feldhinweis">
                Gilt für alle Git-Quellen. Dadurch bleiben die Katalog-URLs frei von Konto-Angaben â€” ein
                Kontowechsel ist eine Ã„nderung hier statt an jedem Eintrag.
              </span>
            </dh-form-field>
            <dh-form-field label="SSH-Schlüssel (optional)">
              <dh-input
                type="text"
                placeholder="C:\\ProgramData\\Software-Bibliothek\\id_bibliothek"
                .value=${this.gitSchluessel}
                @input=${this.beiGitSchluessel}
              ></dh-input>
              <span class="feldhinweis">
                Nur nötig, wenn das Konto, unter dem die Bibliothek läuft, keinen eigenen SSH-Schlüssel hat â€”
                sonst leer lassen. Wird relevant beim Betrieb als Dienst oder nach einem Serverumzug.
              </span>
            </dh-form-field>
          </dh-form-grid>
          <dl class="einstellungen-infos">
            <dt>Datenordner (aktuell genutzt)</dt>
            <dd>${this.ansicht.effektiverDatenOrdner}</dd>
            <dt>Konfigurationsdatei</dt>
            <dd>${this.ansicht.konfigurationsDatei}</dd>
            <dt>Laufender Port</dt>
            <dd>${this.ansicht.laufenderPort}</dd>
          </dl>
          <div class="umzug">
            <h3>Umzug auf einen anderen Rechner</h3>
            <p class="feldhinweis">
              Gesichert werden Katalog, Bezugsquellen und Einstellungen â€” wenige Kilobyte. Die geholten
              Programm-Dateien kommen nicht mit; die holt der Abgleich auf dem neuen Rechner selbst wieder.
            </p>
            <div class="umzug-aktionen">
              <dh-button buttonText="Exportieren" variant="standard" @click=${this.beiExport}></dh-button>
              <dh-button buttonText="Mit Schlüsseln exportieren" variant="standard" @click=${this.beiExportMitGeheimnissen}></dh-button>
              <dh-button buttonText="Paket einlesen" variant="standard" @click=${this.beiImportWaehlen}></dh-button>
              <input id="import-datei" type="file" accept="application/json,.json" hidden @change=${this.beiImportDatei} />
            </div>
            <p class="feldhinweis warnhinweis">
              â€žMit Schlüsseln" schreibt die hinterlegten Werte im Klartext in die Datei â€” nur bewusst nutzen
              und die Datei danach sicher aufbewahren bzw. löschen.
            </p>
          </div>
          ${neustartNoetig ? html`<p class="hinweis">Änderungen an Datenordner oder Port wirken erst nach einem Neustart der Anwendung.</p>` : nothing}
          ${this.meldung ? html`<p class="hinweis">${this.meldung}</p>` : nothing}
        </div>
        <div slot="actions">
          ${this.fehler ? html`<span class="fehler">${this.fehler}</span>` : nothing}
          <dh-button buttonText="Schließen" variant="standard" @click=${this.beiSchliessen}></dh-button>
          <dh-button
            buttonText=${this.speichert ? 'Speichert…' : 'Speichern'}
            variant="filled"
            ?disabled=${this.speichert}
            @click=${this.beiSpeichern}
          ></dh-button>
        </div>
      </dh-card>
    `;
  }
  private async einstellungenLaden(): Promise<void> {
    try {
      this.ansicht = await ladeEinstellungen();
      this.datenOrdner = this.ansicht.einstellungen.datenOrdner;
      this.port = this.ansicht.einstellungen.port;
      this.gitBenutzer = this.ansicht.einstellungen.gitBenutzer;
      this.gitSchluessel = this.ansicht.einstellungen.gitSchluessel;
    } catch (fehler) {
      this.fehler = fehler instanceof Error ? fehler.message : 'Einstellungen konnten nicht geladen werden.';
    }
  }
  private beiDatenOrdner(ereignis: Event): void {
    this.datenOrdner = (ereignis.target as DhInput).value;
  }
  private beiPort(ereignis: Event): void {
    this.port = Number((ereignis.target as DhInput).value) || 0;
  }
  private beiGitBenutzer(ereignis: Event): void {
    this.gitBenutzer = (ereignis.target as DhInput).value;
  }
  private beiGitSchluessel(ereignis: Event): void {
    this.gitSchluessel = (ereignis.target as DhInput).value;
  }
  private beiSchliessen(): void {
    this.geschlossen.dispatch();
  }
  private beiExport(): void {
    window.location.href = exportUrl(false);
  }
  private beiExportMitGeheimnissen(): void {
    if (confirm('Die Datei enthält dann die hinterlegten Schlüssel im Klartext. Wirklich so exportieren?')) {
      window.location.href = exportUrl(true);
    }
  }
  private beiImportWaehlen(): void {
    this.querySelector<HTMLInputElement>('#import-datei')?.click();
  }
  private async beiImportDatei(ereignis: Event): Promise<void> {
    const feld = ereignis.target as HTMLInputElement;
    const datei = feld.files?.[0];
    feld.value = ''; // erlaubt, dieselbe Datei erneut zu wählen
    if (!datei) {
      return;
    }
    if (!confirm('Der bisherige Katalog wird dabei vollständig ersetzt. Fortfahren?')) {
      return;
    }
    this.fehler = '';
    this.meldung = '';
    try {
      const ergebnis = await importiereUmzugsPaket(JSON.parse(await datei.text()));
      this.meldung = `${ergebnis.uebernommen} Einträge Ã¼bernommen${ergebnis.mitGeheimnissen > 0 ? `, davon ${ergebnis.mitGeheimnissen} mit Schlüsseln` : ''}. ${ergebnis.hinweis}`;
      this.umzugAbgeschlossen.dispatch();
    } catch (fehler) {
      this.fehler = fehler instanceof Error ? fehler.message : 'Paket konnte nicht eingelesen werden.';
    }
  }
  private async beiSpeichern(): Promise<void> {
    this.speichert = true;
    this.fehler = '';
    this.meldung = '';
    try {
      this.ansicht = await speichereEinstellungen({
        datenOrdner: this.datenOrdner.trim(),
        port: this.port,
        gitBenutzer: this.gitBenutzer.trim(),
        gitSchluessel: this.gitSchluessel.trim(),
      });
      this.datenOrdner = this.ansicht.einstellungen.datenOrdner;
      this.port = this.ansicht.einstellungen.port;
      this.gitBenutzer = this.ansicht.einstellungen.gitBenutzer;
      this.gitSchluessel = this.ansicht.einstellungen.gitSchluessel;
      this.meldung = 'Gespeichert. Datenordner und Port gelten ab dem nächsten Start.';
    } catch (fehler) {
      this.fehler = fehler instanceof Error ? fehler.message : 'Speichern fehlgeschlagen.';
    } finally {
      this.speichert = false;
    }
  }
}
declare global {
  interface HTMLElementTagNameMap {
    'bibliothek-einstellungen': BibliothekEinstellungen;
  }
}
packages/bibliothek-client/src/components/bibliothek-formular.ts
Neue Datei
@@ -0,0 +1,441 @@
import { LitElement, html, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { event, type EventRef } from '@dh-software/lit-extensions';
import type { DhInput } from '@dh-software/dh-components';
import {
  EINSTUFUNGEN,
  PROGRAMMARTEN,
  type Einstufung,
  type GeheimnisUebersicht,
  type Programmart,
  type QuelleTyp,
  type Software,
  type SoftwareEingabe,
} from 'bibliothek-types';
import { softwareAnlegen, softwareAktualisieren, dateiHochladen, ladeGeheimnisse, speichereGeheimnisse } from '../api-client';
interface QuellenOption {
  dataId: string;
  showContent: string;
  selected?: boolean;
}
/**
 * dh-select verwirft Klicks auf Einträge ohne dataId (`if (!dataId) return;`) und lässt
 * die Liste dann offen stehen. â€žKeine Quelle" braucht deshalb eine eigene Kennung, die
 * beim Lesen und Schreiben auf den Leerwert Ã¼bersetzt wird.
 */
const OHNE_QUELLE = 'keine';
const QUELLEN_OPTIONEN: QuellenOption[] = [
  { dataId: OHNE_QUELLE, showContent: 'Keine (nur Katalog-Eintrag)' },
  { dataId: 'manuell', showContent: 'Manuell â€” Datei hochladen (kein Auto-Update)' },
  { dataId: 'direkt', showContent: 'Direkt-Download-Link' },
  { dataId: 'github', showContent: 'GitHub-Release' },
  { dataId: 'winget', showContent: 'winget-Paket' },
  { dataId: 'svn', showContent: 'SVN-Export' },
  { dataId: 'git', showContent: 'Git-Repository â€” wird selbst gebaut' },
];
const WERT_PLATZHALTER: Record<string, string> = {
  '': 'zuerst eine Quelle wählen',
  direkt: 'https://…/setup.exe',
  github: 'besitzer/projekt  (z.B. ip7z/7zip)',
  winget: 'Paket-ID  (z.B. Google.Chrome)',
  svn: 'https://svn…/pfad/datei.exe',
  git: 'ssh://git.server.de:29418/gruppe/tool.git   (optional mit #zweig)',
};
/** Was die gewählte Programmart für den Nutzer bedeutet â€” steht als Hilfstext unter dem Feld. */
const PROGRAMMART_ERLAEUTERUNG: Record<Programmart, string> = {
  unbestimmt: 'Noch nicht eingeordnet â€” am Eintrag erscheint kein Hinweis',
  installer: 'Am Eintrag erscheint: Datei kann nach der Installation gelöscht werden',
  eigenstaendig: 'Am Eintrag erscheint: läuft direkt, an einen festen Ort legen',
};
/** Formular zum Anlegen/Bearbeiten einer Software inkl. Bezugsquelle. */
@customElement('bibliothek-formular')
export class BibliothekFormular extends LitElement {
  @property({ attribute: false }) software?: Software;
  /** Eigenständige Tools, denen dieser Eintrag als Plug-in zugeordnet werden kann. */
  @property({ attribute: false }) moeglicheEltern: Software[] = [];
  @state() private name = '';
  @state() private beschreibung = '';
  @state() private elternId = 0;
  @state() private elternOptionen: QuellenOption[] = [];
  @state() private quelleTyp: QuelleTyp | '' = '';
  @state() private quelleWert = '';
  @state() private bauBefehl = '';
  @state() private bauErgebnis = '';
  @state() private einstufung: Einstufung = 'lizenzfrei';
  @state() private einstufungsOptionen: QuellenOption[] = [];
  @state() private programmart: Programmart = 'unbestimmt';
  @state() private programmartOptionen: QuellenOption[] = [];
  @state() private hinweis = '';
  @state() private anleitungUrl = '';
  @state() private erwarteterHerausgeber = '';
  @state() private geheimnisStand: GeheimnisUebersicht[] = [];
  @state() private geheimwerte: Record<string, string> = {};
  @state() private datei?: File;
  @state() private quellenOptionen: QuellenOption[] = QUELLEN_OPTIONEN.map((option) => ({ ...option }));
  @state() private fehler = '';
  @state() private speichert = false;
  @event({ bubbles: true, composed: true }, 'gespeichert')
  private gespeichert!: EventRef<void>;
  @event({ bubbles: true, composed: true }, 'abgebrochen')
  private abgebrochen!: EventRef<void>;
  // Light DOM, damit das globale dh-components-Theme greift.
  createRenderRoot() {
    return this;
  }
  connectedCallback(): void {
    super.connectedCallback();
    if (this.software) {
      this.name = this.software.name;
      this.beschreibung = this.software.beschreibung;
      this.elternId = this.software.elternId;
      this.quelleTyp = this.software.quelleTyp;
      this.quelleWert = this.software.quelleWert;
      this.bauBefehl = this.software.bauBefehl;
      this.bauErgebnis = this.software.bauErgebnis;
      this.einstufung = this.software.einstufung;
      this.programmart = this.software.programmart;
      this.hinweis = this.software.hinweis;
      this.anleitungUrl = this.software.anleitungUrl;
      this.erwarteterHerausgeber = this.software.erwarteterHerausgeber;
      this.quellenOptionen = QUELLEN_OPTIONEN.map((option) => ({
        ...option,
        selected: option.dataId === (this.quelleTyp || OHNE_QUELLE),
      }));
    }
    this.einstufungsOptionen = EINSTUFUNGEN.map((eintrag) => ({
      dataId: eintrag.id,
      showContent: eintrag.text,
      selected: eintrag.id === this.einstufung,
    }));
    this.programmartOptionen = PROGRAMMARTEN.map((eintrag) => ({
      dataId: eintrag.id,
      showContent: eintrag.text,
      selected: eintrag.id === this.programmart,
    }));
    if (this.software) {
      void this.geheimnisStandLaden(this.software.id);
    }
    // Sich selbst nicht als eigenes Eltern-Tool anbieten.
    this.elternOptionen = [
      { dataId: '0', showContent: 'Eigenständiges Tool', selected: !this.elternId },
      ...this.moeglicheEltern
        .filter((tool) => tool.id !== this.software?.id)
        .map((tool) => ({ dataId: String(tool.id), showContent: tool.name, selected: tool.id === this.elternId })),
    ];
  }
  render() {
    return html`
      <dh-card variant="outlined" no-hover-effect no-click-effect>
        <span slot="header">${this.software ? 'Software bearbeiten' : 'Neue Software'}</span>
        <dh-form-grid slot="content">
          <dh-form-field label="Name">
            <dh-input type="text" .value=${this.name} @input=${this.beiName}></dh-input>
          </dh-form-field>
          <dh-form-field label="Einstufung">
            <dh-select
              withoutEmptyDefault
              notSearchable
              .selectOptions=${this.einstufungsOptionen}
              @changeSelectOption=${this.beiEinstufung}
            ></dh-select>
          </dh-form-field>
          <dh-form-field label="Programmart">
            <dh-select
              withoutEmptyDefault
              notSearchable
              .selectOptions=${this.programmartOptionen}
              @changeSelectOption=${this.beiProgrammart}
            ></dh-select>
            <span class="feldhinweis">${PROGRAMMART_ERLAEUTERUNG[this.programmart]}</span>
          </dh-form-field>
          <dh-form-field label="Gehört zu">
            <dh-select
              withoutEmptyDefault
              notSearchable
              .selectOptions=${this.elternOptionen}
              @changeSelectOption=${this.beiEltern}
            ></dh-select>
            <span class="feldhinweis">Zusatzinhalte (Plug-ins, Sprachpakete, Vorlagen â€¦) erscheinen unter dem gewählten Tool</span>
          </dh-form-field>
          <dh-form-field label="Bezugsquelle">
            <dh-select
              withoutEmptyDefault
              notSearchable
              .selectOptions=${this.quellenOptionen}
              @changeSelectOption=${this.beiQuelleTyp}
            ></dh-select>
          </dh-form-field>
          ${this.rendereQuellenAngabe()} ${this.rendereBauFelder()} ${this.rendereGeheimnisse()}
          <dh-form-field label="Anleitung (Link, optional)">
            <dh-input type="text" placeholder="https://wiki…" .value=${this.anleitungUrl} @input=${this.beiAnleitung}></dh-input>
          </dh-form-field>
          <dh-form-field label="Hinweis (optional)">
            <dh-input
              type="text"
              placeholder="z. B. braucht Adminrechte"
              .value=${this.hinweis}
              @input=${this.beiHinweis}
            ></dh-input>
            <span class="feldhinweis">Erscheint am Eintrag im Katalog</span>
          </dh-form-field>
          <dh-form-field label="Erwarteter Signaturgeber (optional)">
            <dh-input
              type="text"
              placeholder="z. B. NOTEPAD++"
              .value=${this.erwarteterHerausgeber}
              @input=${this.beiErwarteterHerausgeber}
            ></dh-input>
            <span class="feldhinweis">Weicht die Signatur der geholten Datei davon ab, wird sie nicht zum Download angeboten.</span>
          </dh-form-field>
          <dh-form-field label="Beschreibung" span="2">
            <dh-textarea .value=${this.beschreibung} @isChange=${this.beiBeschreibung}></dh-textarea>
          </dh-form-field>
        </dh-form-grid>
        <div slot="actions">
          ${this.fehler ? html`<span class="fehler">${this.fehler}</span>` : ''}
          <dh-button buttonText="Abbrechen" variant="standard" @click=${this.beiAbbrechen}></dh-button>
          <dh-button
            buttonText=${this.speichert ? 'Speichert…' : 'Speichern'}
            variant="filled"
            ?disabled=${this.speichert}
            @click=${this.beiSpeichern}
          ></dh-button>
        </div>
      </dh-card>
    `;
  }
  /** Je nach Quelle: Datei-Auswahl (manuell) oder Textfeld für die Quell-Angabe. */
  private rendereQuellenAngabe() {
    if (this.quelleTyp === 'manuell') {
      return html`
        <dh-form-field label="Datei">
          <input type="file" @change=${this.beiDatei} />
          ${this.software?.dateiname ? html`<span class="feldhinweis">Aktuell hinterlegt: ${this.software.dateiname}</span>` : ''}
        </dh-form-field>
      `;
    }
    return html`
      <dh-form-field label="Quell-Angabe">
        <dh-input
          type="text"
          placeholder=${WERT_PLATZHALTER[this.quelleTyp]}
          ?disabled=${this.quelleTyp === ''}
          .value=${this.quelleWert}
          @input=${this.beiQuelleWert}
        ></dh-input>
        ${this.quelleTyp === 'git' && this.quelleWert.includes('@')
          ? html`<span class="feldhinweis warnhinweis">
              Die URL enthält einen Benutzernamen. Besser ohne â€” dann liefert die SSH-Konfiguration des
              Servers die Kennung, und ein Kontowechsel erfordert keine Ã„nderung an den Einträgen.
            </span>`
          : nothing}
      </dh-form-field>
    `;
  }
  /**
   * Bau-Geheimnisse: Das Repository deklariert in `software-bibliothek.json`, was es
   * braucht; hier wird nur der Wert hinterlegt. Bereits gespeicherte Werte werden nie
   * zurückgeliefert â€” ein leer gelassenes Feld lässt den bisherigen Wert unverändert.
   */
  private rendereGeheimnisse() {
    if (this.quelleTyp !== 'git' || !this.software) {
      return nothing;
    }
    const bedarf = this.software.benoetigteGeheimnisse ?? [];
    if (bedarf.length === 0) {
      return html`<dh-form-field label="Schlüssel für den Bau" span="2">
        <span class="feldhinweis">
          Dieses Repository fordert keine an. Bedarf wird in <code>software-bibliothek.json</code> unter
          <code>secrets</code> deklariert und beim Bau als Umgebungsvariable bereitgestellt.
        </span>
      </dh-form-field>`;
    }
    return bedarf.map(
      (eintrag) => html`<dh-form-field label=${eintrag.name} span="2">
        <dh-input
          type="password"
          toggle-password
          placeholder=${this.istHinterlegt(eintrag.name) ? 'hinterlegt â€” leer lassen, um ihn zu behalten' : 'noch nicht hinterlegt'}
          .value=${this.geheimwerte[eintrag.name] ?? ''}
          @input=${(ereignis: Event) => this.beiGeheimwert(eintrag.name, ereignis)}
        ></dh-input>
        <span class="feldhinweis">${eintrag.beschreibung || 'Wird dem Bau als Umgebungsvariable Ã¼bergeben.'}</span>
      </dh-form-field>`
    );
  }
  private istHinterlegt(name: string): boolean {
    return this.geheimnisStand.some((eintrag) => eintrag.name === name && eintrag.hinterlegt);
  }
  private beiGeheimwert(name: string, ereignis: Event): void {
    this.geheimwerte = { ...this.geheimwerte, [name]: (ereignis.target as DhInput).value };
  }
  /** Nur für die Git-Quelle: Bau-Befehl und Pfad des Ergebnisses. */
  private rendereBauFelder() {
    if (this.quelleTyp !== 'git') {
      return nothing;
    }
    return html`
      <dh-form-field label="Bau-Befehl (optional)" span="2">
        <dh-input
          type="text"
          placeholder="kommt normalerweise aus software-bibliothek.json im Repository"
          .value=${this.bauBefehl}
          @input=${this.beiBauBefehl}
        ></dh-input>
        <span class="feldhinweis">
          Das Repository beschreibt seinen Bau selbst in <code>software-bibliothek.json</code>
          (<code>bauBefehl</code> und <code>ergebnis</code>). Nur ausfüllen, um das zu Ã¼bersteuern.
        </span>
      </dh-form-field>
      <dh-form-field label="Ergebnis-Datei (optional)" span="2">
        <dh-input type="text" placeholder="z. B. dist/MeinTool.exe" .value=${this.bauErgebnis} @input=${this.beiBauErgebnis}></dh-input>
        <span class="feldhinweis">Pfad relativ zur Arbeitskopie; im Dateinamen ist ein * erlaubt.</span>
      </dh-form-field>
    `;
  }
  private beiBauBefehl(ereignis: Event): void {
    this.bauBefehl = (ereignis.target as DhInput).value;
  }
  private beiBauErgebnis(ereignis: Event): void {
    this.bauErgebnis = (ereignis.target as DhInput).value;
  }
  private beiName(ereignis: Event): void {
    this.name = (ereignis.target as DhInput).value;
  }
  private beiQuelleWert(ereignis: Event): void {
    this.quelleWert = (ereignis.target as DhInput).value;
  }
  private beiAnleitung(ereignis: Event): void {
    this.anleitungUrl = (ereignis.target as DhInput).value;
  }
  private beiErwarteterHerausgeber(ereignis: Event): void {
    this.erwarteterHerausgeber = (ereignis.target as DhInput).value;
  }
  private async geheimnisStandLaden(id: number): Promise<void> {
    try {
      this.geheimnisStand = await ladeGeheimnisse(id);
    } catch {
      this.geheimnisStand = []; // ohne lokalen Zugriff nicht abrufbar â€” dann eben leer
    }
  }
  /** Speichert nur die tatsächlich eingegebenen Werte; leere bleiben unangetastet. */
  private async geheimnisseSpeichern(id: number): Promise<void> {
    const bedarf = this.software?.benoetigteGeheimnisse ?? [];
    if (bedarf.length === 0) {
      return;
    }
    await speichereGeheimnisse(
      id,
      bedarf.map((eintrag) => ({ name: eintrag.name, wert: this.geheimwerte[eintrag.name] ?? '' }))
    );
  }
  private beiEinstufung(ereignis: CustomEvent<{ selectedOption: { dataId: string } }>): void {
    this.einstufung = ereignis.detail.selectedOption.dataId as Einstufung;
  }
  private beiProgrammart(ereignis: CustomEvent<{ selectedOption: { dataId: string } }>): void {
    this.programmart = ereignis.detail.selectedOption.dataId as Programmart;
  }
  private beiHinweis(ereignis: CustomEvent<string>): void {
    this.hinweis = ereignis.detail;
  }
  private beiBeschreibung(ereignis: CustomEvent<string>): void {
    this.beschreibung = ereignis.detail;
  }
  private beiDatei(ereignis: Event): void {
    this.datei = (ereignis.target as HTMLInputElement).files?.[0];
  }
  private beiQuelleTyp(ereignis: CustomEvent<{ selectedOption: { dataId: string } }>): void {
    const gewaehlt = ereignis.detail.selectedOption.dataId;
    this.quelleTyp = gewaehlt === OHNE_QUELLE ? '' : (gewaehlt as QuelleTyp);
  }
  private beiEltern(ereignis: CustomEvent<{ selectedOption: { dataId: string } }>): void {
    this.elternId = Number(ereignis.detail.selectedOption.dataId) || 0;
  }
  private beiAbbrechen(): void {
    this.abgebrochen.dispatch();
  }
  private async beiSpeichern(): Promise<void> {
    if (!this.name.trim()) {
      this.fehler = 'Name ist erforderlich.';
      return;
    }
    if (this.quelleTyp && this.quelleTyp !== 'manuell' && !this.quelleWert.trim()) {
      this.fehler = 'Für die gewählte Quelle fehlt die Angabe.';
      return;
    }
    if (this.quelleTyp === 'manuell' && !this.datei && !this.software?.dateiname) {
      this.fehler = 'Bitte eine Datei auswählen.';
      return;
    }
    this.speichert = true;
    this.fehler = '';
    const eingabe: SoftwareEingabe = {
      name: this.name.trim(),
      beschreibung: this.beschreibung.trim(),
      elternId: this.elternId,
      quelleTyp: this.quelleTyp,
      quelleWert: this.quelleTyp === 'manuell' ? '' : this.quelleWert.trim(),
      bauBefehl: this.quelleTyp === 'git' ? this.bauBefehl.trim() : '',
      bauErgebnis: this.quelleTyp === 'git' ? this.bauErgebnis.trim() : '',
      einstufung: this.einstufung,
      programmart: this.programmart,
      hinweis: this.hinweis.trim(),
      anleitungUrl: this.anleitungUrl.trim(),
      erwarteterHerausgeber: this.erwarteterHerausgeber.trim(),
    };
    try {
      const gespeicherteSoftware = this.software
        ? await softwareAktualisieren(this.software.id, eingabe)
        : await softwareAnlegen(eingabe);
      if (this.datei) {
        await dateiHochladen(gespeicherteSoftware.id, this.datei);
      }
      await this.geheimnisseSpeichern(gespeicherteSoftware.id);
      this.gespeichert.dispatch();
    } catch (fehler) {
      this.fehler = fehler instanceof Error ? fehler.message : 'Speichern fehlgeschlagen.';
    } finally {
      this.speichert = false;
    }
  }
}
declare global {
  interface HTMLElementTagNameMap {
    'bibliothek-formular': BibliothekFormular;
  }
}
packages/bibliothek-client/src/components/bibliothek-zeile.ts
Neue Datei
@@ -0,0 +1,235 @@
import { LitElement, html, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { event, type EventRef } from '@dh-software/lit-extensions';
import type { Software } from 'bibliothek-types';
/**
 * Eine kompakte Katalog-Zeile für einen Software-Eintrag. Feuert Aktions-Events an die App.
 * Die Aktionen liegen in festen Spalten, damit sie Ã¼ber alle Zeilen hinweg untereinander stehen;
 * fehlt eine Aktion, bleibt ihre Spalte leer.
 *
 * Zusatzinhalte (Plug-ins, Sprachpakete, Vorlagen â€¦) stehen eingerückt direkt unter ihrem Tool
 * und bilden mit ihm eine Gruppe.
 */
@customElement('bibliothek-zeile')
export class BibliothekZeile extends LitElement {
  @property({ attribute: false }) software!: Software;
  /** Weitere Einträge, die zu dieser Software gehören. */
  @property({ attribute: false }) zusatzinhalte: Software[] = [];
  @property({ type: Boolean }) lokal = false;
  /** Zeile eines Zusatzinhalts (eingerückt unter seinem Tool). */
  @property({ type: Boolean }) eingerueckt = false;
  /** Ist dieser Eintrag für den Sammel-Download angehakt? */
  @property({ type: Boolean }) gewaehlt = false;
  /** Ids aller angehakten Einträge â€” nötig, damit auch eingerückte Zeilen ihren Zustand kennen. */
  @property({ attribute: false }) auswahl: ReadonlySet<number> = new Set();
  @event({ bubbles: true, composed: true }, 'auswahlGeaendert')
  private auswahlGeaendert!: EventRef<{ software: Software; gewaehlt: boolean }>;
  @event({ bubbles: true, composed: true }, 'herunterladen')
  private herunterladen!: EventRef<Software>;
  @event({ bubbles: true, composed: true }, 'pruefen')
  private pruefen!: EventRef<Software>;
  @event({ bubbles: true, composed: true }, 'bearbeiten')
  private bearbeiten!: EventRef<Software>;
  @event({ bubbles: true, composed: true }, 'loeschen')
  private loeschen!: EventRef<Software>;
  // Light DOM, damit das globale dh-components-Theme greift.
  createRenderRoot() {
    return this;
  }
  render() {
    const software = this.software;
    return html`
      <div class="zeile ${this.eingerueckt ? 'zeile-eingerueckt' : ''}">
        <span class="zeile-auswahl">
          <dh-checkbox
            ?checked=${this.gewaehlt}
            ?disabled=${!software.dateiname}
            title=${software.dateiname ? 'Für Sammel-Download auswählen' : 'Keine Datei vorhanden'}
            @isChange=${this.beiAuswahl}
          ></dh-checkbox>
        </span>
        <div class="zeile-info">
          <span class="zeile-name">
            ${software.name}
            ${software.einstufung === 'lizenzpflichtig'
              ? html`<span class="lizenz-marke" title="Lizenzschlüssel Ã¼ber den Support beziehen">lizenzpflichtig</span>`
              : nothing}
            ${software.einstufung === 'intern' ? html`<span class="intern-marke">intern</span>` : nothing}
          </span>
          <span class="zeile-meta">
            ${this.rendereStand()}
            <span class="${this.statusKlasse()}">${this.statusText()}</span>
            ${this.rendereSignatur()}
          </span>
          ${this.rendereHinweise()}
        </div>
        <div class="zeile-aktionen">
          <span class="aktion">
            ${software.anleitungUrl
              ? html`<a class="anleitung" href=${software.anleitungUrl} target="_blank" rel="noopener noreferrer">Anleitung</a>`
              : nothing}
          </span>
          <span class="aktion">
            ${software.dateiname
              ? html`<dh-button
                  icon-button
                  variant="filled"
                  title=${`${software.name} herunterladen`}
                  @click=${this.beiHerunterladen}
                >
                  <span slot="img" class="fv-icon-download"></span>
                </dh-button>`
              : nothing}
          </span>
          ${this.lokal
            ? html`
                <span class="aktion">
                  ${software.quelleTyp && software.quelleTyp !== 'manuell'
                    ? html`<dh-button buttonText="Prüfen" variant="standard" @click=${this.beiPruefen}></dh-button>`
                    : nothing}
                </span>
                <span class="aktion">
                  <dh-button buttonText="Bearbeiten" variant="standard" @click=${this.beiBearbeiten}></dh-button>
                </span>
                <span class="aktion">
                  <dh-button buttonText="Löschen" variant="standard" @click=${this.beiLoeschen}></dh-button>
                </span>
              `
            : nothing}
        </div>
      </div>
      ${this.zusatzinhalte.map(
        (zusatzinhalt) => html`<bibliothek-zeile
          .software=${zusatzinhalt}
          .auswahl=${this.auswahl}
          ?gewaehlt=${this.auswahl.has(zusatzinhalt.id)}
          ?lokal=${this.lokal}
          eingerueckt
        ></bibliothek-zeile>`
      )}
    `;
  }
  private beiAuswahl(ereignis: CustomEvent<boolean>): void {
    this.auswahlGeaendert.dispatch({ software: this.software, gewaehlt: ereignis.detail });
  }
  /**
   * Hinweise zum Umgang mit der Datei: was aus der Programmart folgt, plus der
   * freie Hinweis am Eintrag. Die eigene Zeile kostet nur dort Höhe, wo wirklich
   * etwas zu sagen ist â€” die meisten Einträge bleiben einzeilig.
   */
  private rendereHinweise() {
    const software = this.software;
    const teile: string[] = [];
    if (software.dateiname && software.programmart === 'installer') {
      teile.push('Datei kann nach der Installation gelöscht werden');
    }
    if (software.dateiname && software.programmart === 'eigenstaendig') {
      teile.push('Läuft direkt â€” an einen festen Ort legen');
    }
    if (software.hinweis) {
      teile.push(software.hinweis);
    }
    return teile.length > 0 ? html`<span class="zeile-hinweis">${teile.join(' Â· ')}</span>` : nothing;
  }
  /**
   * Signaturbefund der bereitgestellten Datei â€” nur in der Verwaltung.
   * Für die Netzwerk-Ansicht ist das Betriebswissen ohne Nutzen: Wer etwas
   * herunterlädt, kann daraus keine Entscheidung ableiten.
   */
  private rendereSignatur() {
    const software = this.software;
    if (!this.lokal || !software.dateiname || !software.signaturStatus) {
      return nothing;
    }
    if (software.signaturStatus === 'Valid') {
      return html` &nbsp;·&nbsp;
        <span class="signatur-gut" title="Gültig signiert von ${software.signaturHerausgeber}">
          signiert: ${software.signaturHerausgeber}
        </span>`;
    }
    const text = software.signaturStatus === 'NotSigned' ? 'unsigniert' : `Signatur unklar (${software.signaturStatus})`;
    return html` &nbsp;·&nbsp; <span class="signatur-unklar" title="Windows-Signaturstatus: ${software.signaturStatus}">${text}</span>`;
  }
  /**
   * Zeigt bevorzugt das Veröffentlichungsdatum des Anbieters â€” das ist die Angabe,
   * die den Nutzer interessiert. Nur wenn die Quelle keins liefert, wird ersatzweise
   * unser Abrufzeitpunkt genannt (und als solcher benannt).
   */
  private rendereStand() {
    const software = this.software;
    if (software.veroeffentlichtAm) {
      return html`Stand ${this.alsDatum(software.veroeffentlichtAm)} &nbsp;·&nbsp; `;
    }
    if (software.dateiStandVom) {
      return html`geholt ${this.alsDatum(software.dateiStandVom)} &nbsp;·&nbsp; `;
    }
    return nothing;
  }
  private alsDatum(zeitstempel: string): string {
    const zeitpunkt = new Date(zeitstempel);
    return Number.isNaN(zeitpunkt.getTime()) ? '—' : zeitpunkt.toLocaleDateString('de-DE');
  }
  private statusText(): string {
    const software = this.software;
    if (software.dateiname) {
      return software.dateiname;
    }
    if (!software.quelleTyp) {
      return 'keine Quelle';
    }
    if (software.quelleTyp === 'manuell') {
      return 'keine Datei hinterlegt';
    }
    if (software.letzterAbgleich && !software.abgleichErfolg) {
      return software.abgleichMeldung || 'Abgleich fehlgeschlagen';
    }
    return 'Quelle hinterlegt, noch nicht geladen';
  }
  private statusKlasse(): string {
    const software = this.software;
    if (software.dateiname) {
      return 'status-ok';
    }
    if (!software.quelleTyp || software.quelleTyp === 'manuell' || (software.letzterAbgleich && !software.abgleichErfolg)) {
      return 'status-fehler';
    }
    return 'status-ausstehend';
  }
  private beiHerunterladen(): void {
    this.herunterladen.dispatch(this.software);
  }
  private beiPruefen(): void {
    this.pruefen.dispatch(this.software);
  }
  private beiBearbeiten(): void {
    this.bearbeiten.dispatch(this.software);
  }
  private beiLoeschen(): void {
    this.loeschen.dispatch(this.software);
  }
}
declare global {
  interface HTMLElementTagNameMap {
    'bibliothek-zeile': BibliothekZeile;
  }
}
packages/bibliothek-client/src/global.css
Neue Datei
@@ -0,0 +1,336 @@
html,
body {
  margin: 0;
  min-height: 100%;
  background: var(--md-sys-color-surface, #ffffff);
  color: var(--md-sys-color-on-surface, #1a1a1a);
  font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
}
.bibliothek-app-rahmen {
  padding: 24px;
  /* Dauerhaft Platz für die schwebende Auswahl-Leiste â€” so verschiebt sich
     auch am Listenende nichts, wenn sie erscheint. */
  padding-bottom: 96px;
  max-width: 1100px;
  margin: 0 auto;
}
.kopf {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 16px;
}
.kopf-aktionen {
  display: flex;
  gap: 8px;
  align-items: center;
}
.theme-select {
  min-width: 150px;
}
.hinweis {
  background: var(--md-sys-color-secondary-container, #e6e0ec);
  color: var(--md-sys-color-on-secondary-container, #1d1b20);
  padding: 12px 16px;
  border-radius: 8px;
  margin-bottom: 16px;
}
/* --- Kompakte Katalog-Liste --- */
.suche {
  display: block;
  max-width: 420px;
  margin-bottom: 12px;
}
dh-tab {
  display: block;
}
/* Inhalt eines nicht gewählten Reiters (die Komponente setzt die Klasse ebenfalls). */
[slot='tab-content'].hidden {
  display: none;
}
.leer {
  padding: 16px 4px;
  opacity: 0.7;
}
.liste {
  border: 1px solid var(--md-sys-color-outline-variant, #cac4d0);
  border-radius: 8px;
  overflow: hidden;
}
/*
 * Die Trennlinie sitzt auf der Gruppe (Tool + seine Zusatzinhalte), nicht auf der
 * einzelnen Zeile â€” so wirken zusammengehörige Einträge als ein Block.
 */
.liste > bibliothek-zeile {
  display: block;
  border-bottom: 1px solid var(--md-sys-color-outline-variant, #e7e0ec);
}
.liste > bibliothek-zeile:last-child {
  border-bottom: none;
}
.zeile {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 8px 16px;
}
.zeile-auswahl {
  display: flex;
  align-items: center;
  flex: 0 0 28px;
}
/* Bei knapper Breite lieber abschneiden als umbrechen â€” sonst franst die Liste aus. */
.zeile-info {
  display: flex;
  flex-direction: column;
  min-width: 0;
  flex: 1 1 auto;
  overflow: hidden;
}
.zeile-info > * {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.warnung {
  padding: 12px 16px;
  margin-bottom: 16px;
  border-radius: 8px;
  border: 1px solid var(--md-sys-color-error, #b00020);
  background: var(--md-sys-color-error-container, rgba(176, 0, 32, 0.12));
  color: var(--md-sys-color-on-error-container, #410002);
}
/*
 * Schwebt Ã¼ber dem Inhalt statt ihn zu verschieben: Beim Anhaken soll die Liste
 * an Ort und Stelle bleiben. Bleibt beim Blättern sichtbar â€” bei langen Listen
 * ist die Auswahl sonst aus dem Blick.
 *
 * Bewusst eine gewöhnliche erhobene Fläche statt der umgekehrten Farbe: Auf
 * einer umgekehrten Fläche greift keine der Button-Gestaltungen des Themes,
 * die Schaltflächen wären dann nicht mehr als solche zu erkennen.
 */
.auswahl-leiste {
  position: fixed;
  bottom: 24px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 10;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px;
  border-radius: var(--md-sys-shape-corner-large, 16px);
  border: 1px solid var(--md-sys-color-outline-variant, #cac4d0);
  background: var(--md-sys-color-surface-container-high);
  color: var(--md-sys-color-on-surface);
  box-shadow:
    0 3px 5px -1px rgba(0, 0, 0, 0.2),
    0 6px 10px 0 rgba(0, 0, 0, 0.14),
    0 1px 18px 0 rgba(0, 0, 0, 0.12);
  max-width: calc(100vw - 32px);
}
.auswahl-anzahl {
  font-weight: 500;
}
.zeile-name {
  font-weight: 600;
  display: flex;
  align-items: center;
  gap: 8px;
}
.lizenz-marke,
.intern-marke {
  flex-shrink: 0;
}
.zeile-meta {
  font-size: 0.85em;
  opacity: 0.7;
}
/* Handhabungs-Hinweise stehen unter den Metadaten, etwas zurückgenommen. */
.zeile-hinweis {
  font-size: 0.85em;
  color: var(--md-sys-color-on-surface-variant, #49454f);
  font-style: italic;
}
/*
 * Aktionen in festen Spalten, damit gleichartige Schaltflächen Ã¼ber alle Zeilen
 * hinweg exakt untereinander stehen. Fehlt eine Aktion, bleibt ihre Spalte leer.
 * Reihenfolge: Plug-ins | Anleitung | Herunterladen [| Prüfen | Bearbeiten | Löschen]
 */
.zeile-aktionen {
  display: grid;
  grid-template-columns: 84px 48px;
  gap: 8px;
  align-items: center;
  flex-shrink: 0;
}
.liste.verwaltung .zeile-aktionen {
  grid-template-columns: 84px 48px 84px 108px 88px;
}
.aktion {
  display: flex;
  justify-content: center;
  align-items: center;
  min-width: 0;
}
.aktion dh-button {
  max-width: 100%;
}
/* --- Zusatzinhalte: eingerückt, ohne eigene Trennlinie --- */
.zeile-eingerueckt {
  padding-left: 44px;
  padding-top: 4px;
  padding-bottom: 4px;
}
.intern-marke {
  font-size: 0.72em;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 2px 8px;
  border-radius: 999px;
  background: var(--md-sys-color-primary-container, #ffe4cc);
  color: var(--md-sys-color-on-primary-container, #4a1e00);
}
.lizenz-marke {
  font-size: 0.72em;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 2px 8px;
  border-radius: 999px;
  background: var(--md-sys-color-tertiary-container, #ffd9a0);
  color: var(--md-sys-color-on-tertiary-container, #2b1700);
}
.status-ok {
  color: var(--md-sys-color-primary, #ff8014);
}
.status-fehler {
  color: var(--md-sys-color-error, #b00020);
}
.status-ausstehend {
  font-style: italic;
}
.signatur-gut {
  color: var(--md-sys-color-primary, #ff8014);
}
.signatur-unklar {
  color: var(--md-sys-color-error, #b00020);
}
.warnhinweis {
  color: var(--md-sys-color-error, #b00020);
  opacity: 1;
}
.anleitung {
  color: var(--md-sys-color-primary, #ff8014);
  font-size: 0.9em;
  text-decoration: none;
  white-space: nowrap;
}
.anleitung:hover {
  text-decoration: underline;
}
/* --- Formular --- */
dh-card {
  display: block;
  margin-bottom: 16px;
}
[slot='actions'] {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
}
.umzug {
  margin-top: 24px;
  padding-top: 16px;
  border-top: 1px solid var(--md-sys-color-outline-variant, #cac4d0);
}
.umzug h3 {
  margin: 0 0 4px;
  font-size: 1em;
}
.umzug-aktionen {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 12px 0 8px;
}
.einstellungen-infos {
  margin: 20px 0 0;
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 4px 16px;
  font-size: 0.9em;
}
.einstellungen-infos dt {
  opacity: 0.7;
}
.einstellungen-infos dd {
  margin: 0;
  font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
  word-break: break-all;
}
.feldhinweis {
  display: block;
  font-size: 0.8em;
  opacity: 0.7;
  margin-top: 4px;
}
.fehler {
  color: var(--md-sys-color-error, #b00020);
  margin-right: auto;
}
packages/bibliothek-client/src/globals.d.ts
Neue Datei
@@ -0,0 +1,8 @@
// CSS-Imports haben keine TypeScript-Typen.
// Mit `?raw` liefert webpack den Quelltext als String (für die Theme-Umschaltung).
declare module '*.css?raw' {
  const quelltext: string;
  export default quelltext;
}
declare module '*.css';
packages/bibliothek-client/src/index.html
Neue Datei
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="de">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Software-Bibliothek</title>
  </head>
  <body>
    <bibliothek-app></bibliothek-app>
  </body>
</html>
packages/bibliothek-client/src/index.ts
Neue Datei
@@ -0,0 +1,19 @@
import 'construct-style-sheets-polyfill';
// Theme in fester Reihenfolge: Tokens -> Styles -> Komponenten.
// Die Farb-Tokens hängt initTheme() als umschaltbare <style>-Elemente ein.
import { initTheme } from './theme';
import '@dh-software/dh-components-themes/styles.css';
import '@dh-software/dh-components';
// eigenes globales Grundlayout (nutzt die Theme-Tokens)
import './global.css';
// dh-Icon-Font (furnview-icons) beim Start in den <head> injizieren
import { injectStylesheets } from '@dh-software/furnview-icons';
injectStylesheets(true);
initTheme();
// eigene Wurzel-Komponente
import './components/bibliothek-app';
packages/bibliothek-client/src/theme.ts
Neue Datei
@@ -0,0 +1,58 @@
/**
 * Theme-Umschaltung (System/hell/dunkel) auf Basis der Design-System-Defines.
 * Beide Defines werden als <style> eingehängt; je nach Modus ist genau eines
 * aktiv (das andere per `disabled` abgeschaltet). â€žSystem" folgt der
 * Windows-Einstellung (prefers-color-scheme).
 *
 * Gleiches Vorgehen wie im Projekt meeting-transkriptor, damit die Bedienung
 * Ã¼ber unsere Anwendungen hinweg einheitlich bleibt.
 */
// Relativer Pfad (umgeht das exports-Feld des Pakets, das ?raw sonst blockt).
import hellCss from '../../../node_modules/@dh-software/dh-components-themes/build/defines.orange-light.css?raw';
import dunkelCss from '../../../node_modules/@dh-software/dh-components-themes/build/defines.orange-dark.css?raw';
export type ThemeModus = 'system' | 'hell' | 'dunkel';
const SPEICHER_SCHLUESSEL = 'software-bibliothek-theme';
let hellStil: HTMLStyleElement;
let dunkelStil: HTMLStyleElement;
let aktuellerModus: ThemeModus = 'system';
const dunkelMedia = window.matchMedia('(prefers-color-scheme: dark)');
function anwenden(): void {
  const dunkel = aktuellerModus === 'dunkel' || (aktuellerModus === 'system' && dunkelMedia.matches);
  hellStil.disabled = dunkel;
  dunkelStil.disabled = !dunkel;
  document.documentElement.style.colorScheme = dunkel ? 'dark' : 'light';
  document.documentElement.dataset.theme = dunkel ? 'dunkel' : 'hell';
}
/** Liest den zuletzt gewählten Modus (Standard: System). */
export function gemerkterModus(): ThemeModus {
  const gemerkt = localStorage.getItem(SPEICHER_SCHLUESSEL);
  return gemerkt === 'hell' || gemerkt === 'dunkel' || gemerkt === 'system' ? gemerkt : 'system';
}
/** Hängt beide Theme-Defines ein und aktiviert den gemerkten Modus. */
export function initTheme(): void {
  hellStil = document.createElement('style');
  hellStil.textContent = hellCss;
  dunkelStil = document.createElement('style');
  dunkelStil.textContent = dunkelCss;
  document.head.append(hellStil, dunkelStil);
  dunkelMedia.addEventListener('change', () => {
    if (aktuellerModus === 'system') {
      anwenden();
    }
  });
  aktuellerModus = gemerkterModus();
  anwenden();
}
/** Setzt den Modus und merkt ihn für den nächsten Aufruf. */
export function setzeTheme(modus: ThemeModus): void {
  aktuellerModus = modus;
  localStorage.setItem(SPEICHER_SCHLUESSEL, modus);
  anwenden();
}
packages/bibliothek-client/tsconfig.json
Neue Datei
@@ -0,0 +1,15 @@
{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["DOM", "DOM.Iterable", "ES2021"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "experimentalDecorators": true,
    "useDefineForClassFields": false,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "exclude": ["node_modules", "dist"]
}
packages/bibliothek-client/webpack.config.js
Neue Datei
@@ -0,0 +1,61 @@
import path from 'path';
import { fileURLToPath } from 'url';
import HtmlWebpackPlugin from 'html-webpack-plugin';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const isProduction = process.env.NODE_ENV === 'production';
export default {
  mode: isProduction ? 'production' : 'development',
  entry: './src/index.ts',
  devtool: isProduction ? false : 'inline-source-map',
  experiments: {
    topLevelAwait: true,
  },
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: 'ts-loader',
        exclude: /node_modules/,
      },
      {
        test: /\.css$/i,
        resourceQuery: { not: [/raw/] },
        use: ['style-loader', 'css-loader'],
      },
      {
        // Import mit ?raw liefert den CSS-Quelltext als String (zum Umschalten
        // der Theme-Defines zur Laufzeit).
        resourceQuery: /raw/,
        type: 'asset/source',
      },
      {
        test: /\.(woff|woff2|ttf|eot|svg|png|jpg|gif|ico)$/i,
        type: 'asset',
      },
    ],
  },
  resolve: {
    extensions: ['.ts', '.js'],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: 'src/index.html',
    }),
  ],
  devServer: {
    host: '0.0.0.0',
    port: 5000,
    open: false,
    proxy: [{ context: ['/api'], target: 'http://localhost:3000' }],
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'index.[contenthash].js',
    clean: true,
  },
  stats: {
    errorDetails: true,
  },
};
packages/bibliothek-server/package.json
Neue Datei
@@ -0,0 +1,21 @@
{
  "name": "bibliothek-server",
  "version": "0.1.0",
  "main": "build/index.js",
  "scripts": {
    "build": "tsc",
    "bundle": "esbuild build/index.js --bundle --platform=node --target=node22 --format=cjs --outfile=build-exe/server.cjs --log-level=warning",
    "start": "node --disable-warning=ExperimentalWarning build/index.js",
    "dev": "ts-node src/index.ts"
  },
  "dependencies": {
    "bibliothek-types": "*",
    "express": "^4.21.2"
  },
  "devDependencies": {
    "@types/express": "^4.17.21",
    "@types/node": "^24.0.0",
    "esbuild": "^0.25.0",
    "ts-node": "^10.9.2"
  }
}
packages/bibliothek-server/src/abgleich.ts
Neue Datei
@@ -0,0 +1,108 @@
import * as fs from 'fs';
import * as path from 'path';
import type { AbgleichErgebnis, Software } from 'bibliothek-types';
import { DATEIEN_ORDNER, QUELLEN_ORDNER, abgleichSpeichern } from './datenbank';
import { bezugsquelleFuer, type QuellenAbgleich } from './bezugsquellen';
import { pruefeSignatur, weichtHerausgeberAb } from './signatur';
import { alsUmgebung, ladeGeheimnisse } from './geheimnisse';
import { gitSshBefehl } from './konfiguration';
/** Größe der aktuell bereitgestellten Datei â€” Grundlage der Plausibilitätsprüfung. */
function bisherigeGroesse(software: Software): number {
  if (!software.dateiname) {
    return 0;
  }
  const pfad = path.join(DATEIEN_ORDNER, String(software.id), software.dateiname);
  return fs.existsSync(pfad) ? fs.statSync(pfad).size : 0;
}
/** Unveränderten Stand zurückschreiben (bei Misserfolg oder unbekannter Quelle). */
function unveraendert(software: Software, meldung: string, version = software.version): AbgleichErgebnis {
  return {
    version,
    dateiname: software.dateiname,
    erfolg: false,
    meldung,
    geaendert: false,
    signaturStatus: software.signaturStatus,
    signaturHerausgeber: software.signaturHerausgeber,
    veroeffentlichtAm: software.veroeffentlichtAm,
    benoetigteGeheimnisse: software.benoetigteGeheimnisse,
  };
}
/** Gleicht eine Software mit ihrer Bezugsquelle ab und speichert das Ergebnis zurück. */
export async function gleicheSoftwareAb(software: Software): Promise<void> {
  if (software.quelleTyp === 'manuell') {
    return; // Datei wird von Hand hinterlegt â€“ es gibt nichts abzugleichen
  }
  if (!software.quelleTyp || !software.quelleWert) {
    return; // keine Quelle gesetzt â€“ nichts zu holen
  }
  const quelle = bezugsquelleFuer(software.quelleTyp);
  if (!quelle) {
    abgleichSpeichern(software.id, unveraendert(software, `Unbekannte Bezugsquelle: ${software.quelleTyp}`));
    return;
  }
  const zielordner = path.join(DATEIEN_ORDNER, String(software.id));
  const ergebnis = await quelle.gleicheAb({
    wert: software.quelleWert,
    aktuelleVersion: software.version,
    zielordner,
    arbeitsordner: path.join(QUELLEN_ORDNER, String(software.id)),
    bauBefehl: software.bauBefehl,
    bauErgebnis: software.bauErgebnis,
    vorherigeGroesse: bisherigeGroesse(software),
    geheimnisse: alsUmgebung(ladeGeheimnisse(software.id)),
    gitSshBefehl: gitSshBefehl(),
  });
  if (!ergebnis.erfolg) {
    abgleichSpeichern(software.id, unveraendert(software, ergebnis.meldung, ergebnis.version));
    return;
  }
  abgleichSpeichern(software.id, await mitSignaturbefund(software, ergebnis, zielordner));
}
/**
 * Ergänzt das Abgleich-Ergebnis um die Windows-Signatur der bereitgestellten Datei.
 * Ist ein Signaturgeber erwartet und weicht der gefundene ab, wird die Datei nicht
 * zum Download freigegeben (`dateiname` bleibt leer) â€” sie bleibt aber zur Ansicht liegen.
 */
async function mitSignaturbefund(software: Software, ergebnis: QuellenAbgleich, zielordner: string): Promise<AbgleichErgebnis> {
  const befund = await pruefeSignatur(path.join(zielordner, ergebnis.dateiname));
  const gemeinsam = {
    version: ergebnis.version,
    signaturStatus: befund.status,
    signaturHerausgeber: befund.herausgeber,
    veroeffentlichtAm: ergebnis.veroeffentlichtAm ?? software.veroeffentlichtAm,
    benoetigteGeheimnisse: ergebnis.benoetigteGeheimnisse ?? software.benoetigteGeheimnisse,
  };
  if (weichtHerausgeberAb(software.erwarteterHerausgeber, befund.herausgeber)) {
    return {
      ...gemeinsam,
      dateiname: '', // nicht ausliefern, bis das geklärt ist
      erfolg: false,
      meldung: `Signatur weicht ab: erwartet â€ž${software.erwarteterHerausgeber}", gefunden â€ž${befund.herausgeber || 'unsigniert'}" (${befund.status}). Die Datei wird nicht zum Download angeboten.`,
      geaendert: false,
    };
  }
  return {
    ...gemeinsam,
    dateiname: ergebnis.dateiname,
    erfolg: true,
    meldung: ergebnis.meldung,
    geaendert: ergebnis.geaendert,
  };
}
/** Gleicht nacheinander alle Einträge mit gesetzter Quelle ab (täglicher Auto-Check). */
export async function gleicheAlleAb(alle: Software[]): Promise<void> {
  for (const software of alle) {
    if (software.quelleTyp && software.quelleWert) {
      await gleicheSoftwareAb(software);
    }
  }
}
packages/bibliothek-server/src/bezugsquellen/basis.ts
Neue Datei
@@ -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';
  }
}
packages/bibliothek-server/src/bezugsquellen/direkt-link.ts
Neue Datei
@@ -0,0 +1,40 @@
import {
  abgleichMisserfolg,
  alsIsoDatum,
  dateinameAusUrl,
  dateiVorhanden,
  ersetzeDateiSicher,
  inhaltBeanstanden,
  type Abgleichauftrag,
  type Bezugsquelle,
  type QuellenAbgleich,
} from './basis';
/** Direkter Download-Link: lädt die Datei, erkennt Ã„nderungen Ã¼ber ETag/Last-Modified. */
export class DirektLinkQuelle implements Bezugsquelle {
  async gleicheAb(auftrag: Abgleichauftrag): Promise<QuellenAbgleich> {
    try {
      const antwort = await fetch(auftrag.wert, { redirect: 'follow' });
      if (!antwort.ok) {
        return abgleichMisserfolg(auftrag.aktuelleVersion, `Server nicht erreichbar (HTTP ${antwort.status}).`);
      }
      const kennung = antwort.headers.get('etag') ?? antwort.headers.get('last-modified') ?? '';
      const veroeffentlichtAm = alsIsoDatum(antwort.headers.get('last-modified'));
      const dateiname = dateinameAusUrl(auftrag.wert);
      if (kennung && kennung === auftrag.aktuelleVersion && dateiVorhanden(auftrag.zielordner, dateiname)) {
        return { erfolg: true, geaendert: false, version: kennung, dateiname, meldung: 'Bereits aktuell.', veroeffentlichtAm };
      }
      const inhalt = Buffer.from(await antwort.arrayBuffer());
      const beanstandung = inhaltBeanstanden(inhalt, dateiname, antwort.headers.get('content-type'), auftrag.vorherigeGroesse);
      if (beanstandung) {
        return abgleichMisserfolg(auftrag.aktuelleVersion, beanstandung);
      }
      ersetzeDateiSicher(auftrag.zielordner, dateiname, inhalt);
      return { erfolg: true, geaendert: true, version: kennung || new Date().toISOString(), dateiname, meldung: 'Aktualisiert.', veroeffentlichtAm };
    } catch (fehler) {
      return abgleichMisserfolg(auftrag.aktuelleVersion, `Download fehlgeschlagen: ${fehler instanceof Error ? fehler.message : String(fehler)}`);
    }
  }
}
packages/bibliothek-server/src/bezugsquellen/git-build.ts
Neue Datei
@@ -0,0 +1,244 @@
import { exec, execFile } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs';
import * as path from 'path';
import {
  abgleichMisserfolg,
  alsIsoDatum,
  dateiVorhanden,
  ersetzeDateiSicher,
  inhaltBeanstanden,
  type Abgleichauftrag,
  type Bezugsquelle,
  type GeheimnisBedarf,
  type QuellenAbgleich,
} from './basis';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
/** Ein Bau darf lange dauern (z. B. PyInstaller mit vielen Abhängigkeiten). */
const BAU_ZEITGRENZE_MS = 30 * 60 * 1000;
const GIT_ZEITGRENZE_MS = 10 * 60 * 1000;
/** Name der Bauanleitung, die ein Repository selbst mitbringt. */
export const BAUANLEITUNG_DATEI = 'software-bibliothek.json';
interface Bauanleitung {
  bauBefehl: string;
  ergebnis: string;
  geheimnisse: GeheimnisBedarf[];
}
/**
 * Aufbau der Anleitung, wie sie im Repository steht. Die Feldnamen sind englisch,
 * weil sie ein Vertrag nach außen sind â€” jedes anzubindende Tool schreibt sie.
 * Beschrieben im Skill â€žbuild-secrets" des Repos dh-claude-rules.
 */
interface AnleitungImRepo {
  buildCommand?: string;
  artifact?: string;
  secrets?: Array<{ name?: string; description?: string }>;
}
/**
 * Interne Tools aus einem Git-Repository: Arbeitskopie holen bzw. aktualisieren,
 * bauen und das erzeugte Programm bereitstellen.
 *
 * Angabe der Quelle: nur die Repo-URL, optional mit `#zweig` (z. B. `ssh://…/tool.git#main`).
 * Wie gebaut wird, beschreibt das Repository selbst in `software-bibliothek.json`:
 *
 *   { "buildCommand": "powershell -ExecutionPolicy Bypass -File build_exe.ps1",
 *     "artifact":     "dist/MeinTool.exe" }
 *
 * So bleibt die Bauanleitung dort, wo sie hingehört â€” beim Tool. In der Bibliothek
 * lassen sich beide Angaben bei Bedarf Ã¼berschreiben (für fremde Repos, die man
 * nicht Ã¤ndern kann).
 *
 * Die Arbeitskopie legt die Anwendung selbst an (`<Datenordner>\quellen\<id>`) â€” es
 * wird nichts vorausgesetzt, was auf dem jeweiligen Rechner schon vorhanden sein müsste.
 *
 * Hinweis zur Sicherheit: Der Bau-Befehl wird auf dem Server mit den Rechten des
 * Dienstbenutzers ausgeführt. Pflegen kann ihn nur, wer lokal am Server arbeitet
 * bzw. wer ins Repository schreiben darf.
 */
export class GitBuildQuelle implements Bezugsquelle {
  async gleicheAb(auftrag: Abgleichauftrag): Promise<QuellenAbgleich> {
    const [repoUrl, gewuenschterZweig] = auftrag.wert.split('#');
    let commit = '';
    try {
      commit = await arbeitskopieAktualisieren(repoUrl.trim(), gewuenschterZweig?.trim() ?? '', auftrag.arbeitsordner, auftrag.gitSshBefehl);
    } catch (fehler) {
      return abgleichMisserfolg(auftrag.aktuelleVersion, gitFehlertext(fehler));
    }
    const veroeffentlichtAm = await commitZeitpunkt(auftrag.arbeitsordner);
    const anleitung = leseBauanleitung(auftrag);
    if (!anleitung.bauBefehl || !anleitung.ergebnis) {
      return abgleichMisserfolg(
        auftrag.aktuelleVersion,
        `Im Repository fehlt die Datei ${BAUANLEITUNG_DATEI} mit â€žbuildCommand" und â€žartifact" â€” alternativ beides in der Bibliothek hinterlegen.`
      );
    }
    // Nur bauen, wenn sich seit dem letzten Mal etwas geändert hat.
    const bekannterDateiname = ergebnisDateiname(anleitung.ergebnis);
    if (commit === auftrag.aktuelleVersion && dateiVorhanden(auftrag.zielordner, bekannterDateiname)) {
      return { erfolg: true, geaendert: false, version: commit, dateiname: bekannterDateiname, meldung: 'Bereits aktuell (unveränderter Stand).', veroeffentlichtAm, benoetigteGeheimnisse: anleitung.geheimnisse };
    }
    const fehlende = anleitung.geheimnisse.filter((bedarf) => !auftrag.geheimnisse[bedarf.name]).map((bedarf) => bedarf.name);
    try {
      // Geheimnisse gehen als Umgebungsvariablen in den Bau â€” das Tool entscheidet selbst,
      // was es daraus macht. Sie landen dadurch nirgends auf der Platte.
      await execAsync(anleitung.bauBefehl, {
        cwd: auftrag.arbeitsordner,
        timeout: BAU_ZEITGRENZE_MS,
        windowsHide: true,
        maxBuffer: 32 * 1024 * 1024,
        env: { ...process.env, ...auftrag.geheimnisse },
      });
    } catch (fehler) {
      const hinweis = fehlende.length > 0 ? ` Nicht hinterlegte Schlüssel: ${fehlende.join(', ')}.` : '';
      return abgleichMisserfolg(auftrag.aktuelleVersion, `Bau fehlgeschlagen: ${kurzeFehlermeldung(fehler)}${hinweis}`);
    }
    const ergebnisPfad = findeErgebnis(auftrag.arbeitsordner, anleitung.ergebnis);
    if (!ergebnisPfad) {
      return abgleichMisserfolg(auftrag.aktuelleVersion, `Der Bau lief durch, aber unter â€ž${anleitung.ergebnis}" liegt keine Datei.`);
    }
    const dateiname = path.basename(ergebnisPfad);
    const inhalt = fs.readFileSync(ergebnisPfad);
    const beanstandung = inhaltBeanstanden(inhalt, dateiname, null, auftrag.vorherigeGroesse);
    if (beanstandung) {
      return abgleichMisserfolg(auftrag.aktuelleVersion, beanstandung);
    }
    ersetzeDateiSicher(auftrag.zielordner, dateiname, inhalt);
    return {
      erfolg: true,
      geaendert: true,
      version: commit,
      dateiname,
      meldung: `Aus Git gebaut (Stand ${commit.slice(0, 8)}).`,
      veroeffentlichtAm,
      benoetigteGeheimnisse: anleitung.geheimnisse,
    };
  }
}
/**
 * Bauanleitung ermitteln: bevorzugt aus dem Repository selbst, ersatzweise aus den
 * in der Bibliothek hinterlegten Angaben (die dort gepflegten haben Vorrang, damit
 * sich fremde Repos Ã¼bersteuern lassen).
 */
function leseBauanleitung(auftrag: Abgleichauftrag): Bauanleitung {
  let ausRepo: AnleitungImRepo = {};
  const anleitungsPfad = path.join(auftrag.arbeitsordner, BAUANLEITUNG_DATEI);
  if (fs.existsSync(anleitungsPfad)) {
    try {
      ausRepo = JSON.parse(fs.readFileSync(anleitungsPfad, 'utf8')) as AnleitungImRepo;
    } catch {
      // Unlesbare Anleitung wie â€žnicht vorhanden" behandeln â€” die Angaben aus der
      // Bibliothek können den Bau trotzdem ermöglichen.
    }
  }
  const angeforderte = Array.isArray(ausRepo.secrets) ? ausRepo.secrets : [];
  return {
    bauBefehl: auftrag.bauBefehl.trim() || (ausRepo.buildCommand ?? '').trim(),
    ergebnis: auftrag.bauErgebnis.trim() || (ausRepo.artifact ?? '').trim(),
    geheimnisse: angeforderte
      .filter((bedarf): bedarf is { name: string; description?: string } => typeof bedarf?.name === 'string' && !!bedarf.name.trim())
      .map((bedarf) => ({ name: bedarf.name.trim(), beschreibung: String(bedarf.description ?? '') })),
  };
}
/** Klont die Arbeitskopie oder bringt sie auf den neuesten Stand. Liefert den Commit. */
async function arbeitskopieAktualisieren(repoUrl: string, gewuenschterZweig: string, arbeitsordner: string, sshBefehl: string): Promise<string> {
  // Die Kennung kommt aus den Einstellungen, nicht aus der URL â€” so bleibt der
  // Katalogeintrag frei von Konto-Angaben und ein Kontowechsel ist eine Ã„nderung.
  const umgebung = sshBefehl ? { ...process.env, GIT_SSH_COMMAND: sshBefehl } : process.env;
  const git = (argumente: string[], cwd?: string) =>
    execFileAsync('git', argumente, { cwd, timeout: GIT_ZEITGRENZE_MS, windowsHide: true, maxBuffer: 16 * 1024 * 1024, env: umgebung });
  if (!fs.existsSync(path.join(arbeitsordner, '.git'))) {
    fs.rmSync(arbeitsordner, { recursive: true, force: true });
    fs.mkdirSync(path.dirname(arbeitsordner), { recursive: true });
    await git(['clone', repoUrl, arbeitsordner]);
  } else {
    await git(['remote', 'set-url', 'origin', repoUrl], arbeitsordner);
    await git(['fetch', '--prune', 'origin'], arbeitsordner);
  }
  const zweig = gewuenschterZweig || (await ermittleStandardZweig(arbeitsordner));
  // Harter Abgleich auf den Fernstand â€” lokale Reste aus einem früheren Bau stören sonst.
  await git(['checkout', '-B', zweig, `origin/${zweig}`], arbeitsordner);
  await git(['reset', '--hard', `origin/${zweig}`], arbeitsordner);
  await git(['clean', '-ffdx', '-e', 'node_modules', '-e', '.venv'], arbeitsordner);
  const { stdout } = await git(['rev-parse', 'HEAD'], arbeitsordner);
  return stdout.trim();
}
/** Zeitpunkt des ausgecheckten Commits â€” das ist der â€žStand" des internen Tools. */
async function commitZeitpunkt(arbeitsordner: string): Promise<string> {
  try {
    const { stdout } = await execFileAsync('git', ['log', '-1', '--format=%cI'], {
      cwd: arbeitsordner,
      timeout: 30000,
      windowsHide: true,
    });
    return alsIsoDatum(stdout.trim());
  } catch {
    return '';
  }
}
async function ermittleStandardZweig(arbeitsordner: string): Promise<string> {
  try {
    const { stdout } = await execFileAsync('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], {
      cwd: arbeitsordner,
      timeout: 30000,
      windowsHide: true,
    });
    return stdout.trim().replace(/^origin\//, '') || 'main';
  } catch {
    return 'main'; // Repos ohne gesetzten HEAD-Zeiger
  }
}
/** Löst den Ergebnis-Pfad auf; im Dateinamen ist ein `*` erlaubt. */
function findeErgebnis(arbeitsordner: string, muster: string): string | undefined {
  const vollstaendig = path.resolve(arbeitsordner, muster);
  if (!muster.includes('*')) {
    return fs.existsSync(vollstaendig) ? vollstaendig : undefined;
  }
  const ordner = path.dirname(vollstaendig);
  if (!fs.existsSync(ordner)) {
    return undefined;
  }
  const regel = new RegExp(`^${path.basename(vollstaendig).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`, 'i');
  const treffer = fs.readdirSync(ordner).filter((name) => regel.test(name));
  return treffer.length > 0 ? path.join(ordner, treffer[0]) : undefined;
}
function ergebnisDateiname(muster: string): string {
  const name = path.basename(muster);
  return name.includes('*') ? '' : name;
}
function gitFehlertext(fehler: unknown): string {
  const text = kurzeFehlermeldung(fehler);
  if (/authenticat|permission denied|could not read|host key|publickey/i.test(text)) {
    return `Git-Anmeldung fehlgeschlagen. Der Dienstbenutzer braucht Zugriff auf das Repository (SSH-Schlüssel bzw. einmalige Anmeldung im Windows-Anmeldespeicher). Ursprüngliche Meldung: ${text}`;
  }
  return `Git fehlgeschlagen: ${text}`;
}
/** Kommandozeilen-Fehler enthalten oft seitenlange Ausgaben â€” auf das Wesentliche kürzen. */
function kurzeFehlermeldung(fehler: unknown): string {
  const roh = fehler instanceof Error ? ((fehler as Error & { stderr?: string }).stderr || fehler.message) : String(fehler);
  const zeilen = roh.split('\n').map((zeile) => zeile.trim()).filter(Boolean);
  return zeilen.slice(-4).join(' | ').slice(0, 500);
}
packages/bibliothek-server/src/bezugsquellen/github.ts
Neue Datei
@@ -0,0 +1,95 @@
import {
  abgleichMisserfolg,
  alsIsoDatum,
  dateiVorhanden,
  ersetzeDateiSicher,
  inhaltBeanstanden,
  type Abgleichauftrag,
  type Bezugsquelle,
  type QuellenAbgleich,
} from './basis';
interface GithubAsset {
  name: string;
  browser_download_url: string;
}
interface GithubRelease {
  tag_name: string;
  /** Veröffentlichungszeitpunkt des Releases beim Anbieter. */
  published_at: string;
  assets: GithubAsset[];
}
/** GitHub-Releases: holt das neueste Release und lädt ein passendes Installer-Asset. */
export class GithubQuelle implements Bezugsquelle {
  async gleicheAb(auftrag: Abgleichauftrag): Promise<QuellenAbgleich> {
    try {
      const antwort = await fetch(`https://api.github.com/repos/${auftrag.wert}/releases/latest`, {
        headers: { 'User-Agent': 'software-bibliothek', Accept: 'application/vnd.github+json' },
      });
      if (!antwort.ok) {
        return abgleichMisserfolg(auftrag.aktuelleVersion, `GitHub nicht erreichbar (HTTP ${antwort.status}).`);
      }
      const release = (await antwort.json()) as GithubRelease;
      const asset = waehlePassendesAsset(release.assets);
      if (!asset) {
        return abgleichMisserfolg(auftrag.aktuelleVersion, `Im Release ${release.tag_name} liegt keine herunterladbare Datei.`);
      }
      const veroeffentlichtAm = alsIsoDatum(release.published_at);
      if (release.tag_name === auftrag.aktuelleVersion && dateiVorhanden(auftrag.zielordner, asset.name)) {
        return { erfolg: true, geaendert: false, version: release.tag_name, dateiname: asset.name, meldung: 'Bereits aktuell.', veroeffentlichtAm };
      }
      const dateiAntwort = await fetch(asset.browser_download_url, { headers: { 'User-Agent': 'software-bibliothek' }, redirect: 'follow' });
      if (!dateiAntwort.ok) {
        return abgleichMisserfolg(auftrag.aktuelleVersion, `Asset-Download fehlgeschlagen (HTTP ${dateiAntwort.status}).`);
      }
      const inhalt = Buffer.from(await dateiAntwort.arrayBuffer());
      const beanstandung = inhaltBeanstanden(inhalt, asset.name, dateiAntwort.headers.get('content-type'), auftrag.vorherigeGroesse);
      if (beanstandung) {
        return abgleichMisserfolg(auftrag.aktuelleVersion, beanstandung);
      }
      ersetzeDateiSicher(auftrag.zielordner, asset.name, inhalt);
      return { erfolg: true, geaendert: true, version: release.tag_name, dateiname: asset.name, meldung: `Auf ${release.tag_name} aktualisiert.`, veroeffentlichtAm };
    } catch (fehler) {
      return abgleichMisserfolg(auftrag.aktuelleVersion, `GitHub-Abgleich fehlgeschlagen: ${fehler instanceof Error ? fehler.message : String(fehler)}`);
    }
  }
}
/**
 * Wählt aus den Release-Dateien den passenden Windows-Installer aus.
 *
 * Bewertet statt â€žerster Treffer gewinnt", damit die Reihenfolge im Release
 * das Ergebnis nicht beeinflusst: 64-Bit vor allem anderen, ARM und 32-Bit
 * ausgeschlossen, `.exe` vor `.msi`, portable Varianten nur als Notnagel.
 */
export function waehlePassendesAsset(assets: GithubAsset[]): GithubAsset | undefined {
  const kandidaten = assets
    .filter((eintrag) => /\.(exe|msi|msix|zip)$/i.test(eintrag.name))
    .filter((eintrag) => !/arm|aarch|x86|ia32|32.?bit/i.test(eintrag.name));
  if (kandidaten.length === 0) {
    return undefined;
  }
  const bewertet = kandidaten.map((eintrag) => ({ eintrag, punkte: bewerteAsset(eintrag.name) }));
  bewertet.sort((links, rechts) => rechts.punkte - links.punkte);
  return bewertet[0].eintrag;
}
function bewerteAsset(name: string): number {
  let punkte = 0;
  if (/x64|amd64|win64|64.?bit/i.test(name)) {
    punkte += 100; // ausdrücklich 64-Bit
  }
  if (/\.exe$/i.test(name)) {
    punkte += 20; // Installer bevorzugt gegenüber .msi/.zip
  } else if (/\.msi$/i.test(name)) {
    punkte += 10;
  }
  if (/portable/i.test(name)) {
    punkte -= 50; // nur nehmen, wenn es nichts anderes gibt
  }
  return punkte;
}
packages/bibliothek-server/src/bezugsquellen/index.ts
Neue Datei
@@ -0,0 +1,25 @@
import type { QuelleTyp } from 'bibliothek-types';
import type { Bezugsquelle } from './basis';
import { DirektLinkQuelle } from './direkt-link';
import { GithubQuelle } from './github';
import { WingetQuelle } from './winget';
import { SvnQuelle } from './svn';
import { GitBuildQuelle } from './git-build';
export type { Abgleichauftrag, Bezugsquelle, QuellenAbgleich } from './basis';
// Registry der automatisch abgleichbaren Bezugsquellen. Eine neue Quellenart
// hinzufügen = hier einen Eintrag ergänzen. `manuell` fehlt bewusst: dort wird
// die Datei von Hand hinterlegt, es gibt nichts abzugleichen.
const registry: Partial<Record<QuelleTyp, Bezugsquelle>> = {
  direkt: new DirektLinkQuelle(),
  github: new GithubQuelle(),
  winget: new WingetQuelle(),
  svn: new SvnQuelle(),
  git: new GitBuildQuelle(),
};
/** Liefert den Handler für einen Quell-Typ (oder undefined, falls unbekannt). */
export function bezugsquelleFuer(typ: QuelleTyp): Bezugsquelle | undefined {
  return registry[typ];
}
packages/bibliothek-server/src/bezugsquellen/svn.ts
Neue Datei
@@ -0,0 +1,53 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
import * as path from 'path';
import { abgleichMisserfolg, alsIsoDatum, dateiVorhanden, mitTemporaeremOrdner, type Abgleichauftrag, type Bezugsquelle, type QuellenAbgleich } from './basis';
const execFileAsync = promisify(execFile);
/**
 * SVN: exportiert eine Datei aus einem Repository, erkennt Ã„nderungen Ã¼ber die Revision.
 * Anmeldedaten kommen aus dem Windows-Anmeldespeicher des Dienstbenutzers â€”
 * die Erstanmeldung muss einmal interaktiv als dieser Benutzer erfolgt sein.
 */
export class SvnQuelle implements Bezugsquelle {
  async gleicheAb(auftrag: Abgleichauftrag): Promise<QuellenAbgleich> {
    const dateiname = decodeURIComponent(auftrag.wert.split('/').pop() ?? 'datei');
    let revision = '';
    let veroeffentlichtAm = '';
    try {
      const svnInfo = (element: string) =>
        execFileAsync('svn', ['info', '--show-item', element, '--non-interactive', auftrag.wert], { timeout: 60000, windowsHide: true });
      revision = (await svnInfo('last-changed-revision')).stdout.trim();
      veroeffentlichtAm = alsIsoDatum((await svnInfo('last-changed-date')).stdout.trim());
    } catch (fehler) {
      return abgleichMisserfolg(auftrag.aktuelleVersion, svnFehlertext(fehler));
    }
    if (revision && revision === auftrag.aktuelleVersion && dateiVorhanden(auftrag.zielordner, dateiname)) {
      return { erfolg: true, geaendert: false, version: revision, dateiname, meldung: 'Bereits aktuell.', veroeffentlichtAm };
    }
    const ablage = mitTemporaeremOrdner(auftrag.zielordner);
    try {
      await execFileAsync('svn', ['export', '--force', '--non-interactive', auftrag.wert, path.join(ablage.ordner, dateiname)], {
        timeout: 300000,
        windowsHide: true,
      });
      ablage.uebernehmen();
      return { erfolg: true, geaendert: true, version: revision || new Date().toISOString(), dateiname, meldung: `SVN-Revision ${revision} exportiert.`, veroeffentlichtAm };
    } catch (fehler) {
      ablage.verwerfen();
      return abgleichMisserfolg(auftrag.aktuelleVersion, svnFehlertext(fehler));
    }
  }
}
/** Macht fehlende Anmeldedaten als häufigste Ursache kenntlich. */
function svnFehlertext(fehler: unknown): string {
  const text = fehler instanceof Error ? fehler.message : String(fehler);
  if (/authoriz|authentic|E170001|credentials/i.test(text)) {
    return `SVN-Anmeldung fehlt oder ist ungültig. Einmalig als Dienstbenutzer anmelden (die Anmeldung wird dann im Windows-Speicher hinterlegt). Ursprüngliche Meldung: ${text}`;
  }
  return `SVN fehlgeschlagen: ${text}`;
}
packages/bibliothek-server/src/bezugsquellen/winget.ts
Neue Datei
@@ -0,0 +1,81 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs';
import * as path from 'path';
import { abgleichMisserfolg, alsIsoDatum, mitTemporaeremOrdner, type Abgleichauftrag, type Bezugsquelle, type QuellenAbgleich } from './basis';
const execFileAsync = promisify(execFile);
/** winget: lädt das Paket per `winget download` (nur unter Windows verfügbar). */
export class WingetQuelle implements Bezugsquelle {
  async gleicheAb(auftrag: Abgleichauftrag): Promise<QuellenAbgleich> {
    // In einen temporären Ordner laden â€” der bisherige Stand bleibt erhalten, falls etwas schiefgeht.
    const ablage = mitTemporaeremOrdner(auftrag.zielordner);
    try {
      const { stdout } = await execFileAsync(
        'winget',
        [
          'download',
          '--id',
          auftrag.wert,
          '--exact',
          '--download-directory',
          ablage.ordner,
          '--accept-package-agreements',
          '--accept-source-agreements',
          '--disable-interactivity',
        ],
        { timeout: 300000, windowsHide: true }
      );
      const geladeneDateien = fs.readdirSync(ablage.ordner);
      const installer = geladeneDateien.find((name) => /\.(exe|msi|msix|appx|appxbundle|zip)$/i.test(name));
      if (!installer) {
        ablage.verwerfen();
        return abgleichMisserfolg(auftrag.aktuelleVersion, 'winget hat keine Installer-Datei geliefert.');
      }
      // winget legt zusätzlich sein Manifest (.yaml) ab â€” im Ablageordner soll nur der Installer liegen.
      for (const name of geladeneDateien) {
        if (name !== installer) {
          fs.rmSync(path.join(ablage.ordner, name), { recursive: true, force: true });
        }
      }
      const version = versionAusAusgabe(stdout) || new Date().toISOString();
      ablage.uebernehmen();
      return {
        erfolg: true,
        geaendert: true,
        version,
        dateiname: installer,
        meldung: `Über winget geladen (${version}).`,
        // Das Veröffentlichungsdatum steht nur in der Paket-Auskunft, nicht in der Download-Ausgabe.
        veroeffentlichtAm: alsIsoDatum(await veroeffentlichungsdatum(auftrag.wert)),
      };
    } catch (fehler) {
      ablage.verwerfen();
      return abgleichMisserfolg(auftrag.aktuelleVersion, `winget fehlgeschlagen: ${fehler instanceof Error ? fehler.message : String(fehler)}`);
    }
  }
}
function versionAusAusgabe(ausgabe: string): string {
  const treffer = ausgabe.match(/[Vv]ersion[:\s]+([0-9][0-9.]*)/);
  return treffer ? treffer[1] : '';
}
/**
 * Fragt das Veröffentlichungsdatum beim Paket-Verzeichnis ab. `winget download`
 * gibt es nicht aus, `winget show` schon â€” dafür ein zweiter, kurzer Aufruf.
 */
async function veroeffentlichungsdatum(paketId: string): Promise<string> {
  try {
    const { stdout } = await execFileAsync(
      'winget',
      ['show', '--id', paketId, '--exact', '--accept-source-agreements', '--disable-interactivity'],
      { timeout: 120000, windowsHide: true }
    );
    const treffer = stdout.match(/(?:Release\s*Date|Ver[öo]ffentlich\w*)[:\s]+([0-9]{4}-[0-9]{2}-[0-9]{2})/i);
    return treffer ? treffer[1] : '';
  } catch {
    return ''; // ohne Datum ist die Zeile nur weniger aussagekräftig, kein Grund zu scheitern
  }
}
packages/bibliothek-server/src/datenbank.ts
Neue Datei
@@ -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[];
}
packages/bibliothek-server/src/geheimnisse.ts
Neue Datei
@@ -0,0 +1,127 @@
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { GEHEIMNIS_ORDNER } from './datenbank';
/**
 * Bau-Geheimnisse (API-Schlüssel, Tokens â€¦) nach einheitlichem Muster.
 *
 * Standard â€” bewusst wie bei gängigen Bau-Systemen, damit die Bibliothek nichts
 * Ã¼ber einzelne Tools wissen muss:
 *
 *  1. Das Repository deklariert in `software-bibliothek.json`, welche Geheimnisse
 *     es braucht:  "secrets": [{ "name": "ANTHROPIC_API_KEY", "description": "…" }]
 *  2. Der Wert wird in der Bibliothek hinterlegt (nur lokal am Server pflegbar).
 *  3. Beim Bau wird er als **Umgebungsvariable** gesetzt. Was das Tool daraus macht
 *     â€” Datei erzeugen, direkt einkompilieren â€” entscheidet sein eigenes Bau-Skript.
 *
 * Ablage getrennt vom Katalog und NIE im Software-Datensatz: `/api/software` ist aus
 * dem Netzwerk lesbar. Auch der Ordner `secrets/` wird nirgends ausgeliefert â€”
 * die Download-Route bedient ausschließlich `files/<id>/<datei>`.
 */
export interface Geheimniswert {
  /** Name der Umgebungsvariablen, z. B. `ANTHROPIC_API_KEY`. */
  name: string;
  wert: string;
}
/** Was die Oberfläche zu sehen bekommt â€” nie der Wert selbst. */
export interface GeheimnisUebersicht {
  name: string;
  hinterlegt: boolean;
  zeichen: number;
}
function ablagePfad(softwareId: number): string {
  return path.join(GEHEIMNIS_ORDNER, `${softwareId}.json`);
}
/**
 * Schränkt die Zugriffsrechte auf den Geheimnis-Ordner ein.
 *
 * Nötig, weil Unterordner von %ProgramData% die Leseberechtigung für die
 * Gruppe â€žBenutzer" erben â€” sonst könnte jeder lokal angemeldete Benutzer die
 * hinterlegten Schlüssel lesen. Node's `mode` greift unter Windows nicht auf
 * ACLs, deshalb Ã¼ber `icacls`. Bezeichnung Ã¼ber die bekannten SIDs, damit es
 * unabhängig von der Systemsprache funktioniert.
 */
export function sichereGeheimnisOrdner(): void {
  if (process.platform !== 'win32') {
    return;
  }
  const dienstbenutzer = `${process.env.USERDOMAIN ?? ''}\\${process.env.USERNAME ?? ''}`;
  const rechte = [
    '*S-1-5-18:(OI)(CI)F', // SYSTEM
    '*S-1-5-32-544:(OI)(CI)F', // Administratoren
  ];
  if (process.env.USERNAME) {
    rechte.push(`${dienstbenutzer}:(OI)(CI)F`); // der Benutzer, unter dem der Dienst läuft
  }
  try {
    execFileSync('icacls', [GEHEIMNIS_ORDNER, '/inheritance:r', '/grant:r', ...rechte], { stdio: 'pipe', windowsHide: true });
  } catch (fehler) {
    console.warn(`Zugriffsrechte des Schlüssel-Ordners konnten nicht eingeschränkt werden: ${String(fehler).slice(0, 200)}`);
  }
}
export function ladeGeheimnisse(softwareId: number): Geheimniswert[] {
  const pfad = ablagePfad(softwareId);
  if (!fs.existsSync(pfad)) {
    return [];
  }
  try {
    const gelesen = JSON.parse(fs.readFileSync(pfad, 'utf8')) as Geheimniswert[];
    return Array.isArray(gelesen) ? gelesen.filter((eintrag) => istGueltigerName(eintrag?.name)) : [];
  } catch {
    return [];
  }
}
/** Nur Namen und Länge â€” der Wert verlässt den Server nicht. */
export function uebersichtGeheimnisse(softwareId: number): GeheimnisUebersicht[] {
  return ladeGeheimnisse(softwareId).map((eintrag) => ({
    name: eintrag.name,
    hinterlegt: eintrag.wert.length > 0,
    zeichen: eintrag.wert.length,
  }));
}
/**
 * Speichert die Werte. Ein leerer Wert bedeutet â€žunverändert lassen" â€” so kann die
 * Oberfläche die Liste zurückschicken, ohne die Geheimnisse je gesehen zu haben.
 */
export function speichereGeheimnisse(softwareId: number, eingehende: Geheimniswert[]): GeheimnisUebersicht[] {
  const bisherige = new Map(ladeGeheimnisse(softwareId).map((eintrag) => [eintrag.name, eintrag.wert]));
  const zusammengefuehrt: Geheimniswert[] = [];
  for (const eintrag of eingehende) {
    const name = (eintrag?.name ?? '').trim();
    if (!istGueltigerName(name)) {
      continue;
    }
    const neuerWert = String(eintrag.wert ?? '');
    zusammengefuehrt.push({ name, wert: neuerWert || bisherige.get(name) || '' });
  }
  fs.mkdirSync(GEHEIMNIS_ORDNER, { recursive: true });
  fs.writeFileSync(ablagePfad(softwareId), JSON.stringify(zusammengefuehrt, null, 2), { mode: 0o600 });
  return zusammengefuehrt.map((eintrag) => ({ name: eintrag.name, hinterlegt: eintrag.wert.length > 0, zeichen: eintrag.wert.length }));
}
export function loescheGeheimnisse(softwareId: number): void {
  fs.rmSync(ablagePfad(softwareId), { force: true });
}
/** Umgebungsvariablen für den Bau â€” nur Einträge mit hinterlegtem Wert. */
export function alsUmgebung(geheimnisse: Geheimniswert[]): Record<string, string> {
  const umgebung: Record<string, string> = {};
  for (const eintrag of geheimnisse) {
    if (eintrag.wert) {
      umgebung[eintrag.name] = eintrag.wert;
    }
  }
  return umgebung;
}
/** Nur was als Umgebungsvariable taugt â€” schützt zugleich vor Einschleusen. */
function istGueltigerName(name: string | undefined): boolean {
  return !!name && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
}
packages/bibliothek-server/src/index.ts
Neue Datei
@@ -0,0 +1,393 @@
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()}`);
});
packages/bibliothek-server/src/konfiguration.ts
Neue Datei
@@ -0,0 +1,107 @@
import * as fs from 'fs';
import * as path from 'path';
import type { Einstellungen } from 'bibliothek-types';
import { anwendungsDatenOrdner } from './pfade';
/**
 * Einstellungen liegen in `konfiguration.json` neben der Anwendung. Die Datei wird
 * beim ersten Start mit Standardwerten angelegt und kann Ã¼ber die Oberfläche
 * (nur lokal am Server) oder von Hand gepflegt werden.
 *
 * Umgebungsvariablen haben Vorrang â€” praktisch für abweichende Testläufe.
 */
const STANDARD_EINSTELLUNGEN: Einstellungen = {
  datenOrdner: '', // leer = Unterordner â€ždata" im Anwendungsdaten-Ordner
  port: 3000,
  gitBenutzer: '', // leer = SSH-Konfiguration des Dienstbenutzers entscheidet
  gitSchluessel: '',
};
const konfigurationsPfad = path.join(anwendungsDatenOrdner(), 'konfiguration.json');
/** Liest die Konfigurationsdatei (und legt sie beim ersten Start an). */
function leseOderErzeuge(): Einstellungen {
  try {
    if (fs.existsSync(konfigurationsPfad)) {
      return normalisiere(JSON.parse(fs.readFileSync(konfigurationsPfad, 'utf8')) as Partial<Einstellungen>);
    }
    const standard = { ...STANDARD_EINSTELLUNGEN };
    schreibeDatei(standard);
    return standard;
  } catch (fehler) {
    console.warn(`konfiguration.json konnte nicht gelesen werden, Standardwerte greifen: ${String(fehler)}`);
    return { ...STANDARD_EINSTELLUNGEN };
  }
}
// Beim Laden des Moduls einlesen, damit auch die Datenbank-Schicht schon den
// konfigurierten Ordner sieht (sie wird beim Start direkt initialisiert).
let gelesene: Einstellungen = leseOderErzeuge();
/** Speichert geänderte Einstellungen. Der Datenordner wirkt erst nach einem Neustart. */
export function speichereEinstellungen(neue: Partial<Einstellungen>): Einstellungen {
  gelesene = normalisiere({ ...gelesene, ...neue });
  schreibeDatei(gelesene);
  return gelesene;
}
export function aktuelleEinstellungen(): Einstellungen {
  return gelesene;
}
export function konfigurationsDatei(): string {
  return konfigurationsPfad;
}
/** Tatsächlich genutzter Datenordner â€” Umgebungsvariable vor Einstellung vor Standard. */
export function effektiverDatenOrdner(): string {
  const ausUmgebung = process.env.DATEN_ORDNER?.trim();
  if (ausUmgebung) {
    return path.resolve(ausUmgebung);
  }
  if (gelesene.datenOrdner.trim()) {
    return path.resolve(gelesene.datenOrdner.trim());
  }
  return path.join(anwendungsDatenOrdner(), 'data');
}
/** Tatsächlich genutzter Port â€” Umgebungsvariable vor Einstellung vor Standard. */
export function effektiverPort(): number {
  const ausUmgebung = Number(process.env.PORT);
  if (Number.isInteger(ausUmgebung) && ausUmgebung > 0) {
    return ausUmgebung;
  }
  return gelesene.port;
}
function normalisiere(eingabe: Partial<Einstellungen>): Einstellungen {
  const port = Number(eingabe.port);
  return {
    datenOrdner: typeof eingabe.datenOrdner === 'string' ? eingabe.datenOrdner.trim() : '',
    port: Number.isInteger(port) && port > 0 && port < 65536 ? port : STANDARD_EINSTELLUNGEN.port,
    // Nur was als SSH-Benutzername taugt â€” schützt zugleich vor Einschleusen in die Befehlszeile.
    gitBenutzer: /^[A-Za-z0-9._-]*$/.test(String(eingabe.gitBenutzer ?? '').trim()) ? String(eingabe.gitBenutzer ?? '').trim() : '',
    gitSchluessel: String(eingabe.gitSchluessel ?? '').trim(),
  };
}
/**
 * Baut den SSH-Aufruf für Git aus den Einstellungen. Leer, wenn nichts gepflegt ist â€”
 * dann greift die SSH-Konfiguration des Dienstbenutzers wie bisher.
 */
export function gitSshBefehl(): string {
  const teile = ['ssh'];
  if (gelesene.gitBenutzer) {
    teile.push('-o', `User=${gelesene.gitBenutzer}`);
  }
  if (gelesene.gitSchluessel) {
    teile.push('-i', `"${gelesene.gitSchluessel.replace(/\\/g, '/')}"`);
  }
  return teile.length > 1 ? teile.join(' ') : '';
}
function schreibeDatei(einstellungen: Einstellungen): void {
  fs.mkdirSync(path.dirname(konfigurationsPfad), { recursive: true });
  fs.writeFileSync(konfigurationsPfad, `${JSON.stringify(einstellungen, null, 2)}\n`);
}
packages/bibliothek-server/src/pfade.ts
Neue Datei
@@ -0,0 +1,45 @@
import * as path from 'path';
/** Läuft die Anwendung als gebündelte Einzeldatei (Node SEA / exe)? */
export function istEinzeldatei(): boolean {
  try {
    return (require('node:sea') as { isSea(): boolean }).isSea();
  } catch {
    return false; // node:sea nicht verfügbar -> normaler Node-Start
  }
}
const ANWENDUNGSNAME = 'Software-Bibliothek';
/**
 * Ordner für Konfiguration und Daten der Anwendung.
 *
 * Bewusst neutral unter `%ProgramData%` (üblicher Ort für maschinenweite
 * Anwendungsdaten unter Windows) statt neben der exe oder gar im Quellcode-
 * Verzeichnis: So liegen die Daten immer am selben Ort, egal wo die exe
 * abgelegt oder wohin sie verschoben wird â€” und ein Entwicklungslauf
 * verschmutzt das Projektverzeichnis nicht.
 *
 * Der Datenordner selbst lässt sich in den Einstellungen frei umlegen.
 */
export function anwendungsDatenOrdner(): string {
  const programmDaten = process.env.ProgramData?.trim();
  if (programmDaten) {
    return path.join(programmDaten, ANWENDUNGSNAME);
  }
  // Kein Windows bzw. Variable nicht gesetzt: neben der Anwendung ablegen.
  return path.join(anwendungsOrdner(), `.${ANWENDUNGSNAME.toLowerCase()}`);
}
/** Ordner der Anwendung selbst: als exe der Ordner neben der exe, sonst die Projekt-Wurzel. */
export function anwendungsOrdner(): string {
  if (istEinzeldatei()) {
    return path.dirname(process.execPath);
  }
  return path.join(__dirname, '..', '..', '..');
}
/** Ordner mit der gebauten Oberfläche (nur im Nicht-exe-Betrieb genutzt). */
export function oberflaechenOrdner(): string {
  return path.join(__dirname, '..', '..', 'bibliothek-client', 'dist');
}
packages/bibliothek-server/src/signatur.ts
Neue Datei
@@ -0,0 +1,66 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
export interface Signaturbefund {
  /** Windows-Status: Valid, NotSigned, UnknownError, HashMismatch â€¦ bzw. â€žnicht geprüft". */
  status: string;
  /** Anzeigename des Signaturgebers (leer, wenn unsigniert). */
  herausgeber: string;
}
const NICHT_GEPRUEFT: Signaturbefund = { status: 'nicht geprüft', herausgeber: '' };
/**
 * Prüft die Windows-Signatur (Authenticode) einer Datei.
 *
 * Bewusst nur erfassend, nicht blockierend: Manche Programme werden legitim
 * unsigniert ausgeliefert (z. B. 7-Zip). Erst ein hinterlegter Erwartungswert
 * macht daraus eine Zusicherung â€” siehe `weichtHerausgeberAb`.
 *
 * Der Dateipfad geht Ã¼ber eine Umgebungsvariable an PowerShell, damit
 * Anführungszeichen im Pfad nichts zerbrechen können.
 */
export async function pruefeSignatur(dateipfad: string): Promise<Signaturbefund> {
  if (process.platform !== 'win32') {
    return NICHT_GEPRUEFT;
  }
  const skript = [
    '$befund = Get-AuthenticodeSignature -LiteralPath $env:ZU_PRUEFENDE_DATEI;',
    '$name = if ($befund.SignerCertificate) { $befund.SignerCertificate.Subject } else { "" };',
    'ConvertTo-Json -Compress @{ status = $befund.Status.ToString(); herausgeber = $name }',
  ].join(' ');
  try {
    const { stdout } = await execFileAsync('powershell', ['-NoProfile', '-NonInteractive', '-Command', skript], {
      timeout: 60000,
      windowsHide: true,
      env: { ...process.env, ZU_PRUEFENDE_DATEI: dateipfad },
    });
    const roh = JSON.parse(stdout) as { status?: string; herausgeber?: string };
    return {
      status: roh.status ?? 'nicht geprüft',
      herausgeber: lesbarerHerausgeber(roh.herausgeber ?? ''),
    };
  } catch {
    return NICHT_GEPRUEFT; // Prüfung selbst fehlgeschlagen â€” kein Grund, den Abgleich zu verwerfen
  }
}
/** Zieht aus dem Zertifikats-Betreff den sprechenden Teil (CN bzw. erstes Feld). */
function lesbarerHerausgeber(betreff: string): string {
  if (!betreff) {
    return '';
  }
  const felder = betreff.split(',').map((feld) => feld.trim());
  const gemeinsamerName = felder.find((feld) => feld.startsWith('CN='));
  return (gemeinsamerName ?? felder[0]).replace(/^CN=/, '').replace(/^"|"$/g, '');
}
/** Weicht der gefundene Herausgeber von der Erwartung ab? Ohne Erwartung nie. */
export function weichtHerausgeberAb(erwartet: string, gefunden: string): boolean {
  if (!erwartet.trim()) {
    return false;
  }
  return !gefunden.toLowerCase().includes(erwartet.trim().toLowerCase());
}
packages/bibliothek-server/src/umzug.ts
Neue Datei
@@ -0,0 +1,131 @@
import type { Einstellungen, Software } from 'bibliothek-types';
import { alleSoftware, softwareAnlegen, softwareLoeschen, umzugsAngabenSetzen } from './datenbank';
import { aktuelleEinstellungen, speichereEinstellungen } from './konfiguration';
import { ladeGeheimnisse, loescheGeheimnisse, speichereGeheimnisse, type Geheimniswert } from './geheimnisse';
/**
 * Umzug auf einen anderen Rechner.
 *
 * Bewusst als lesbares JSON und nicht als Ordner-Kopie:
 *  - Die geholten Dateien (mehrere hundert MB) und die Git-Arbeitskopien (GB)
 *    sind jederzeit reproduzierbar â€” ein Abgleich holt sie neu. Nur Katalog,
 *    Einstellungen und Geheimnisse sind unersetzlich, und das sind wenige KB.
 *  - Die Datenbank im laufenden Betrieb zu kopieren wäre unsicher: die jüngsten
 *    Ã„nderungen stehen im WAL und fehlten in der Kopie.
 *  - JSON Ã¼bersteht Schema-Änderungen: beim Einlesen werden Felder zugeordnet,
 *    statt eine Binärdatei einer bestimmten Version vorauszusetzen.
 */
/** Aktuelle Fassung des Austauschformats â€” erlaubt später sauberes Nachziehen. */
const FORMAT_FASSUNG = 1;
export interface UmzugsPaket {
  fassung: number;
  erstelltAm: string;
  einstellungen: Einstellungen;
  software: Software[];
  /** Nur enthalten, wenn ausdrücklich mit exportiert (enthält Klartext-Schlüssel!). */
  geheimnisse?: Record<string, Geheimniswert[]>;
}
export function erstelleUmzugsPaket(mitGeheimnissen: boolean): UmzugsPaket {
  const eintraege = alleSoftware();
  const paket: UmzugsPaket = {
    fassung: FORMAT_FASSUNG,
    erstelltAm: new Date().toISOString(),
    einstellungen: aktuelleEinstellungen(),
    software: eintraege,
  };
  if (mitGeheimnissen) {
    paket.geheimnisse = {};
    for (const eintrag of eintraege) {
      const werte = ladeGeheimnisse(eintrag.id);
      if (werte.length > 0) {
        paket.geheimnisse[String(eintrag.id)] = werte;
      }
    }
  }
  return paket;
}
export interface UmzugsErgebnis {
  uebernommen: number;
  mitGeheimnissen: number;
  hinweis: string;
}
/**
 * Liest ein Paket ein und ersetzt den bisherigen Katalog vollständig.
 * Die eigentlichen Programm-Dateien kommen nicht mit â€” sie werden beim
 * nächsten Abgleich neu geholt bzw. gebaut.
 */
export function uebernehmeUmzugsPaket(paket: UmzugsPaket): UmzugsErgebnis {
  if (!paket || typeof paket !== 'object' || !Array.isArray(paket.software)) {
    throw new Error('Die Datei ist kein gültiges Umzugs-Paket.');
  }
  if (paket.fassung > FORMAT_FASSUNG) {
    throw new Error(`Das Paket stammt aus einer neueren Fassung (${paket.fassung}) â€” bitte die Anwendung aktualisieren.`);
  }
  // Bisherigen Bestand räumen, damit keine Mischung aus altem und neuem Stand entsteht.
  for (const vorhandene of alleSoftware()) {
    loescheGeheimnisse(vorhandene.id);
    softwareLoeschen(vorhandene.id);
  }
  // Die Ids Ã¤ndern sich beim Neuanlegen â€” für die Eltern-Zuordnung mitführen.
  const idZuordnung = new Map<number, number>();
  for (const eintrag of paket.software.filter((eintrag) => !eintrag.elternId)) {
    idZuordnung.set(eintrag.id, uebernehmeEintrag(eintrag, 0).id);
  }
  for (const eintrag of paket.software.filter((eintrag) => eintrag.elternId)) {
    idZuordnung.set(eintrag.id, uebernehmeEintrag(eintrag, idZuordnung.get(eintrag.elternId) ?? 0).id);
  }
  let mitGeheimnissen = 0;
  for (const [alteId, werte] of Object.entries(paket.geheimnisse ?? {})) {
    const neueId = idZuordnung.get(Number(alteId));
    if (neueId) {
      speichereGeheimnisse(neueId, werte);
      mitGeheimnissen++;
    }
  }
  if (paket.einstellungen) {
    // Datenordner und Port gehören zum Zielrechner und werden bewusst nicht Ã¼berschrieben.
    speichereEinstellungen({
      gitBenutzer: paket.einstellungen.gitBenutzer ?? '',
      gitSchluessel: paket.einstellungen.gitSchluessel ?? '',
    });
  }
  return {
    uebernommen: idZuordnung.size,
    mitGeheimnissen,
    hinweis: 'Die Programm-Dateien wurden nicht mit Ã¼bertragen â€” sie werden beim nächsten Abgleich neu geholt bzw. gebaut.',
  };
}
/** Legt einen Eintrag neu an und stellt den bekannten Abgleich-Stand wieder her. */
function uebernehmeEintrag(eintrag: Software, elternId: number): Software {
  const angelegt = softwareAnlegen({
    name: eintrag.name ?? '',
    beschreibung: eintrag.beschreibung ?? '',
    elternId,
    quelleTyp: eintrag.quelleTyp ?? '',
    quelleWert: eintrag.quelleWert ?? '',
    bauBefehl: eintrag.bauBefehl ?? '',
    bauErgebnis: eintrag.bauErgebnis ?? '',
    einstufung: eintrag.einstufung ?? 'lizenzfrei',
    programmart: eintrag.programmart ?? 'unbestimmt',
    hinweis: eintrag.hinweis ?? '',
    anleitungUrl: eintrag.anleitungUrl ?? '',
    erwarteterHerausgeber: eintrag.erwarteterHerausgeber ?? '',
  });
  // Version und Datei bleiben leer (Standard beim Anlegen): Die Datei fehlt auf dem
  // neuen Rechner noch, der nächste Abgleich soll sie wirklich holen. Bewusst KEIN
  // fehlgeschlagener Abgleich â€” â€žnoch nicht geholt" ist kein Problemfall und soll
  // nicht in der Warnleiste auftauchen.
  umzugsAngabenSetzen(angelegt.id, eintrag.veroeffentlichtAm ?? '', eintrag.benoetigteGeheimnisse ?? []);
  return angelegt;
}
packages/bibliothek-server/src/warnungen.ts
Neue Datei
@@ -0,0 +1,14 @@
/**
 * Node meldet `node:sqlite` beim Start als experimentelles Feature. Für den Betrieb
 * ist das ohne Belang, im Konsolenfenster der Anwendung verunsichert die Meldung aber.
 * Alle anderen Warnungen werden weiterhin ausgegeben.
 *
 * Muss als erstes Modul importiert werden, damit der Filter vor dem Laden von
 * `node:sqlite` steht.
 */
process.removeAllListeners('warning');
process.on('warning', (warnung: Error) => {
  if (warnung.name !== 'ExperimentalWarning') {
    console.warn(warnung);
  }
});
packages/bibliothek-server/src/zip.ts
Neue Datei
@@ -0,0 +1,107 @@
/**
 * Minimaler ZIP-Schreiber (Speicher-Methode, ohne Komprimierung).
 *
 * Bewusst ohne Fremdbibliothek: Installer und Archive sind bereits komprimiert,
 * erneutes Packen brächte nichts und kostet nur Zeit. Damit bleibt der Server
 * ohne Zusatz-Abhängigkeit.
 *
 * Grenze: Das klassische ZIP-Format adressiert mit 32 Bit, ein Archiv (bzw. eine
 * Einzeldatei darin) darf also 4 GB nicht Ã¼berschreiten â€” ZIP64 ist nicht umgesetzt.
 */
const CRC_TABELLE = erzeugeCrcTabelle();
function erzeugeCrcTabelle(): Uint32Array {
  const tabelle = new Uint32Array(256);
  for (let index = 0; index < 256; index++) {
    let wert = index;
    for (let bit = 0; bit < 8; bit++) {
      wert = wert & 1 ? 0xedb88320 ^ (wert >>> 1) : wert >>> 1;
    }
    tabelle[index] = wert >>> 0;
  }
  return tabelle;
}
function crc32(daten: Buffer): number {
  let pruefsumme = 0xffffffff;
  for (const byte of daten) {
    pruefsumme = CRC_TABELLE[(pruefsumme ^ byte) & 0xff] ^ (pruefsumme >>> 8);
  }
  return (pruefsumme ^ 0xffffffff) >>> 0;
}
/** Wandelt einen Zeitpunkt in das von ZIP verwendete DOS-Format (Datum + Uhrzeit). */
function dosZeitstempel(zeitpunkt: Date): { zeit: number; datum: number } {
  const zeit = (zeitpunkt.getHours() << 11) | (zeitpunkt.getMinutes() << 5) | (Math.floor(zeitpunkt.getSeconds() / 2) & 0x1f);
  const datum = ((zeitpunkt.getFullYear() - 1980) << 9) | ((zeitpunkt.getMonth() + 1) << 5) | zeitpunkt.getDate();
  return { zeit, datum };
}
export interface ZipEintrag {
  dateiname: string;
  inhalt: Buffer;
}
/** Packt die Einträge zu einem ZIP-Archiv (ohne Komprimierung). */
export function erzeugeZip(eintraege: ZipEintrag[], zeitpunkt: Date = new Date()): Buffer {
  const { zeit, datum } = dosZeitstempel(zeitpunkt);
  const lokaleBloecke: Buffer[] = [];
  const zentralBloecke: Buffer[] = [];
  let versatz = 0;
  for (const eintrag of eintraege) {
    const nameBytes = Buffer.from(eintrag.dateiname, 'utf8');
    const pruefsumme = crc32(eintrag.inhalt);
    const groesse = eintrag.inhalt.length;
    const lokalerKopf = Buffer.alloc(30);
    lokalerKopf.writeUInt32LE(0x04034b50, 0); // Signatur
    lokalerKopf.writeUInt16LE(20, 4); // benötigte Version
    lokalerKopf.writeUInt16LE(0x0800, 6); // Flags: Dateiname ist UTF-8
    lokalerKopf.writeUInt16LE(0, 8); // Methode 0 = gespeichert
    lokalerKopf.writeUInt16LE(zeit, 10);
    lokalerKopf.writeUInt16LE(datum, 12);
    lokalerKopf.writeUInt32LE(pruefsumme, 14);
    lokalerKopf.writeUInt32LE(groesse, 18); // komprimierte Größe
    lokalerKopf.writeUInt32LE(groesse, 22); // Originalgröße
    lokalerKopf.writeUInt16LE(nameBytes.length, 26);
    lokalerKopf.writeUInt16LE(0, 28); // keine Extra-Felder
    lokaleBloecke.push(lokalerKopf, nameBytes, eintrag.inhalt);
    const zentralKopf = Buffer.alloc(46);
    zentralKopf.writeUInt32LE(0x02014b50, 0); // Signatur
    zentralKopf.writeUInt16LE(20, 4); // erstellt mit Version
    zentralKopf.writeUInt16LE(20, 6); // benötigte Version
    zentralKopf.writeUInt16LE(0x0800, 8);
    zentralKopf.writeUInt16LE(0, 10);
    zentralKopf.writeUInt16LE(zeit, 12);
    zentralKopf.writeUInt16LE(datum, 14);
    zentralKopf.writeUInt32LE(pruefsumme, 16);
    zentralKopf.writeUInt32LE(groesse, 20);
    zentralKopf.writeUInt32LE(groesse, 24);
    zentralKopf.writeUInt16LE(nameBytes.length, 28);
    zentralKopf.writeUInt16LE(0, 30); // Extra
    zentralKopf.writeUInt16LE(0, 32); // Kommentar
    zentralKopf.writeUInt16LE(0, 34); // Datenträger
    zentralKopf.writeUInt16LE(0, 36); // interne Attribute
    zentralKopf.writeUInt32LE(0, 38); // externe Attribute
    zentralKopf.writeUInt32LE(versatz, 42); // Position des lokalen Kopfes
    zentralBloecke.push(zentralKopf, nameBytes);
    versatz += lokalerKopf.length + nameBytes.length + groesse;
  }
  const zentralVerzeichnis = Buffer.concat(zentralBloecke);
  const abschluss = Buffer.alloc(22);
  abschluss.writeUInt32LE(0x06054b50, 0); // Signatur
  abschluss.writeUInt16LE(0, 4); // Datenträger
  abschluss.writeUInt16LE(0, 6); // Datenträger mit Verzeichnis
  abschluss.writeUInt16LE(eintraege.length, 8);
  abschluss.writeUInt16LE(eintraege.length, 10);
  abschluss.writeUInt32LE(zentralVerzeichnis.length, 12);
  abschluss.writeUInt32LE(versatz, 16); // Beginn des Zentralverzeichnisses
  abschluss.writeUInt16LE(0, 20); // kein Kommentar
  return Buffer.concat([...lokaleBloecke, zentralVerzeichnis, abschluss]);
}
packages/bibliothek-server/tsconfig.json
Neue Datei
@@ -0,0 +1,14 @@
{
  "compilerOptions": {
    "target": "es2021",
    "module": "commonjs",
    "moduleResolution": "node",
    "rootDir": "./src",
    "outDir": "./build",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "exclude": ["node_modules"]
}
packages/bibliothek-types/package.json
Neue Datei
@@ -0,0 +1,10 @@
{
  "name": "bibliothek-types",
  "version": "0.1.0",
  "main": "build/index.js",
  "types": "build/index.d.ts",
  "files": ["build"],
  "scripts": {
    "build": "tsc"
  }
}
packages/bibliothek-types/src/index.ts
Neue Datei
@@ -0,0 +1,160 @@
/** Geteilte Typen zwischen Server und Client. */
/**
 * Bezugsquelle, aus der die Datei einer Software geholt und aktuell gehalten wird.
 * `manuell` bedeutet: die Datei wird von Hand hochgeladen und nicht automatisch aktualisiert.
 */
export type QuelleTyp = 'direkt' | 'github' | 'winget' | 'svn' | 'git' | 'manuell';
/** Einstufung einer Software â€” gliedert den Katalog und steuert die Hinweise. */
export type Einstufung = 'lizenzfrei' | 'lizenzpflichtig' | 'intern';
/** Alle Einstufungen mit Anzeigetext, in der Reihenfolge der Katalog-Reiter. */
export const EINSTUFUNGEN: Array<{ id: Einstufung; text: string }> = [
  { id: 'lizenzfrei', text: 'Lizenzfrei' },
  { id: 'lizenzpflichtig', text: 'Lizenzpflichtig' },
  { id: 'intern', text: 'Intern' },
];
/**
 * Wie das Programm auf den Rechner kommt. `unbestimmt` ist der Ausgangswert â€”
 * ein Eintrag muss nicht sofort eingeordnet werden.
 */
export type Programmart = 'unbestimmt' | 'installer' | 'eigenstaendig';
/** Alle Programmarten mit Anzeigetext, in der Reihenfolge des Auswahlfelds. */
export const PROGRAMMARTEN: Array<{ id: Programmart; text: string }> = [
  { id: 'unbestimmt', text: 'Unbestimmt' },
  { id: 'installer', text: 'Installationsprogramm' },
  { id: 'eigenstaendig', text: 'Eigenständiges Programm' },
];
/** Ein Bau-Geheimnis, das ein Repository für seinen Bau anfordert. */
export interface GeheimnisBedarf {
  /** Name der Umgebungsvariablen, z. B. `ANTHROPIC_API_KEY`. */
  name: string;
  beschreibung: string;
}
/** Stand eines Bau-Geheimnisses in der Oberfläche â€” nie der Wert selbst. */
export interface GeheimnisUebersicht {
  name: string;
  hinterlegt: boolean;
  zeichen: number;
}
/** Ein Software-Eintrag im Katalog. */
export interface Software {
  id: number;
  name: string;
  beschreibung: string;
  /** Id der Ã¼bergeordneten Software, falls dieser Eintrag ein Zusatzinhalt dazu ist (0 = eigenständiges Tool). */
  elternId: number;
  /** Typ der Bezugsquelle (leer, falls noch keine gesetzt). */
  quelleTyp: QuelleTyp | '';
  /** Quell-spezifische Angabe: URL (direkt), `besitzer/repo` (github), Paket-ID (winget), Repo-URL (svn/git). Bei `manuell` leer. */
  quelleWert: string;
  /** Nur bei Quelle `git`: Befehl, der das Tool baut (im Wurzelverzeichnis der Arbeitskopie ausgeführt). */
  bauBefehl: string;
  /** Nur bei Quelle `git`: Pfad des Bau-Ergebnisses relativ zur Arbeitskopie, `*` im Dateinamen erlaubt. */
  bauErgebnis: string;
  /** Einstufung: lizenzfrei, lizenzpflichtig (Schlüssel Ã¼ber den Support) oder intern (Eigenentwicklung). */
  einstufung: Einstufung;
  /** Installationsprogramm oder eigenständige Datei â€” bestimmt den Hinweis am Eintrag. */
  programmart: Programmart;
  /** Freier Hinweis am Eintrag, z. B. â€žbraucht Adminrechte". Leer = keiner. */
  hinweis: string;
  /** Link zur Anleitung (WikiJS intern oder extern), leer falls keine hinterlegt. */
  anleitungUrl: string;
  /** Aktuelle Versions-/Stand-Kennung, vom Abgleich gesetzt (Release-Tag, ETag, winget-Version, SVN-Revision). */
  version: string;
  /** Aktuell bereitgestellte Datei im Ablageordner (leer, solange noch nichts vorliegt). */
  dateiname: string;
  /** Zeitpunkt, zu dem die bereitgestellte Datei zuletzt tatsächlich erneuert wurde (unser Abruf). */
  dateiStandVom: string;
  /** Veröffentlichungsdatum beim Anbieter, sofern die Quelle es mitliefert. */
  veroeffentlichtAm: string;
  /** Windows-Signaturstatus der bereitgestellten Datei (Valid, NotSigned, â€¦). */
  signaturStatus: string;
  /** Signaturgeber der bereitgestellten Datei (leer, wenn unsigniert). */
  signaturHerausgeber: string;
  /** Optional: erwarteter Signaturgeber. Weicht er ab, wird die Datei nicht bereitgestellt. */
  erwarteterHerausgeber: string;
  /** Vom Repository angeforderte Bau-Geheimnisse (aus `software-bibliothek.json`). */
  benoetigteGeheimnisse: GeheimnisBedarf[];
  /** Zeitpunkt des letzten Abgleichs (leer, falls nie geprüft). */
  letzterAbgleich: string;
  /** War der letzte Abgleich erfolgreich? */
  abgleichErfolg: boolean;
  /** Meldung des letzten Abgleichs (z.B. â€žAktualisiert." oder ein Fehlertext). */
  abgleichMeldung: string;
  erstelltAm: string;
  aktualisiertAm: string;
}
/** Nutzlast beim Anlegen/Bearbeiten (server-verwaltete Felder wie Version/Datei/Abgleich ausgenommen). */
export interface SoftwareEingabe {
  name: string;
  beschreibung: string;
  elternId: number;
  quelleTyp: QuelleTyp | '';
  quelleWert: string;
  bauBefehl: string;
  bauErgebnis: string;
  einstufung: Einstufung;
  programmart: Programmart;
  hinweis: string;
  anleitungUrl: string;
  erwarteterHerausgeber: string;
}
/** Ergebnis eines Bezugsquellen-Abgleichs, das in die DB zurückgeschrieben wird. */
export interface AbgleichErgebnis {
  version: string;
  dateiname: string;
  erfolg: boolean;
  meldung: string;
  /** Wurde tatsächlich eine neue Datei bereitgestellt? Nur dann zählt es als Update. */
  geaendert: boolean;
  signaturStatus: string;
  signaturHerausgeber: string;
  veroeffentlichtAm: string;
  benoetigteGeheimnisse: GeheimnisBedarf[];
}
/** Einstellungen der Anwendung (Datei `konfiguration.json` neben der Anwendung). */
export interface Einstellungen {
  /** Ablageort für Datenbank und Programm-Dateien. Leer = Ordner â€ždata" neben der Anwendung. */
  datenOrdner: string;
  /** Port, auf dem die Oberfläche erreichbar ist. */
  port: number;
  /**
   * Kennung für Git-Zugriffe Ã¼ber SSH. Leer = die SSH-Konfiguration des
   * Dienstbenutzers entscheidet. Hier zentral gepflegt, damit ein Kontowechsel
   * keine Anpassung an den Katalogeinträgen erfordert.
   */
  gitBenutzer: string;
  /** Optionaler Pfad zum privaten SSH-Schlüssel für Git-Zugriffe. */
  gitSchluessel: string;
}
/** Einstellungen samt der daraus abgeleiteten, tatsächlich genutzten Pfade. */
export interface EinstellungenAnsicht {
  einstellungen: Einstellungen;
  /** Vollständiger Pfad der Konfigurationsdatei. */
  konfigurationsDatei: string;
  /** Tatsächlich verwendeter Datenordner (aufgelöst aus der Einstellung). */
  effektiverDatenOrdner: string;
  /** Läuft gerade auf diesem Port (Änderungen wirken erst nach Neustart). */
  laufenderPort: number;
}
/** Ein protokollierter Zugriff/Download aus dem Netzwerk. */
export interface ZugriffslogEintrag {
  id: number;
  zeitpunkt: string;
  ipAdresse: string;
  rechnername: string;
  angegebenerName: string;
  aktion: string;
}
packages/bibliothek-types/tsconfig.json
Neue Datei
@@ -0,0 +1,16 @@
{
  "compilerOptions": {
    "target": "es2021",
    "module": "ESNext",
    "moduleResolution": "node",
    "rootDir": "./src",
    "outDir": "./build",
    "declaration": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "build"]
}
pruefe_updates.bat
Datei wurde gelöscht
requirements.txt
Datei wurde gelöscht
software-bibliothek.json
Neue Datei
@@ -0,0 +1,5 @@
{
  "_hinweis": "Bau-Anleitung für die Software-Bibliothek selbst. Verteilt wird von hier nicht der Server (den legt man beim Aufsetzen hin), sondern der Client für die Arbeitsplätze. Verfahren: Skill build-secrets im Repo dh-claude-rules.",
  "buildCommand": "powershell -ExecutionPolicy Bypass -File desktop/build_exe.ps1",
  "artifact": "desktop/dist/Software-Bibliothek.exe"
}
start.bat
Datei wurde gelöscht
static/style.css
Datei wurde gelöscht
templates/assignment_form.html
Datei wurde gelöscht
templates/base.html
Datei wurde gelöscht
templates/dashboard.html
Datei wurde gelöscht
templates/einrichten.html
Datei wurde gelöscht
templates/protokoll_detail.html
Datei wurde gelöscht
templates/protokolle.html
Datei wurde gelöscht
templates/software_detail.html
Datei wurde gelöscht
templates/software_form.html
Datei wurde gelöscht
templates/zugriff_eingeschraenkt.html
Datei wurde gelöscht
templates/zugriffslog.html
Datei wurde gelöscht
updater.py
Datei wurde gelöscht