Server-Bibliothek
vor 18 Std. 1f44739ba685cd77dc804adcc58fbf584b21e52b
Replace the Flask prototype with a Node/TypeScript monorepo

The handover prototype was a Flask app that catalogued installers someone
had copied into place by hand. It is replaced by three npm workspaces: a
shared type contract, an Express/SQLite server that fetches and builds the
files itself, and a dh-components frontend.

Files now come from GitHub releases, winget, SVN, a direct link or a Git
repository the server builds. Nothing is uploaded by hand. Administration
is reachable only at localhost; the network sees the catalogue and the
downloads.
1 Dateien geändert
40 Dateien hinzugefügt
23 Dateien gelöscht
14194 ■■■■ Geänderte Dateien
.gitignore 27 ●●●●● Patch | Ansicht | Raw | Blame | Historie
.npmrc 1 ●●●● Patch | Ansicht | Raw | Blame | Historie
.prettierrc.json 4 ●●●● Patch | Ansicht | Raw | Blame | Historie
CHANGELOG.md 148 ●●●●● 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
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 6527 ●●●●● Patch | Ansicht | Raw | Blame | Historie
package.json 20 ●●●●● 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
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,22 @@
# Echte Datenbank und Nutzdaten - nicht versionieren, ist Bestand des jeweiligen Servers
data/bibliothek.db
data/doku_bilder/
# Abhaengigkeiten
node_modules
# Von Nutzern abgelegte Installer-Dateien (Pfad ist frei waehlbar, dieser Ordner ist nur ein Vorschlag)
installers/*
!installers/LIESMICH.txt
# Build-Ausgaben
dist
build
build-exe
exe/
# Python
__pycache__/
*.pyc
# Logs
*.log
# Betriebssystem
# 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
}
CHANGELOG.md
Datei wurde gelöscht
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.');
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,20 @@
{
  "name": "software-bibliothek",
  "version": "0.1.0",
  "description": "Interne Software-Bibliothek â€” Katalog, Datei-Download, Zugriffsprotokoll (D+H)",
  "private": true,
  "workspaces": [
    "./packages/*"
  ],
  "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",
    "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
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