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
| | |
| | | # 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 |
| Neue Datei |
| | |
| | | @dh-software:registry=https://npm.furnco.de |
| Neue Datei |
| | |
| | | { |
| | | "printWidth": 200, |
| | | "singleQuote": true |
| | | } |
| Neue Datei |
| | |
| | | /** |
| | | * 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.'); |
| Neue Datei |
| Zu viele Änderungen; Diff wird nicht angezeigt |
| Neue Datei |
| | |
| | | { |
| | | "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" |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | { |
| | | "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" |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | 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; |
| | | } |
| Neue Datei |
| | |
| | | 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; |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | 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; |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | 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; |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | 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` · |
| | | <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` · <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)} · `; |
| | | } |
| | | if (software.dateiStandVom) { |
| | | return html`geholt ${this.alsDatum(software.dateiStandVom)} · `; |
| | | } |
| | | 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; |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | 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; |
| | | } |
| Neue Datei |
| | |
| | | // 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'; |
| Neue Datei |
| | |
| | | <!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> |
| Neue Datei |
| | |
| | | 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'; |
| Neue Datei |
| | |
| | | /** |
| | | * 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(); |
| | | } |
| Neue Datei |
| | |
| | | { |
| | | "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"] |
| | | } |
| Neue Datei |
| | |
| | | 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, |
| | | }, |
| | | }; |
| Neue Datei |
| | |
| | | { |
| | | "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" |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | 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); |
| | | } |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | 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'; |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | 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)}`); |
| | | } |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | 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); |
| | | } |
| Neue Datei |
| | |
| | | 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; |
| | | } |
| Neue Datei |
| | |
| | | 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]; |
| | | } |
| Neue Datei |
| | |
| | | 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}`; |
| | | } |
| Neue Datei |
| | |
| | | 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 |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | 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[]; |
| | | } |
| Neue Datei |
| | |
| | | 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); |
| | | } |
| Neue Datei |
| | |
| | | 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()}`); |
| | | }); |
| Neue Datei |
| | |
| | | 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`); |
| | | } |
| Neue Datei |
| | |
| | | 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'); |
| | | } |
| Neue Datei |
| | |
| | | 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()); |
| | | } |
| Neue Datei |
| | |
| | | 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; |
| | | } |
| Neue Datei |
| | |
| | | /** |
| | | * 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); |
| | | } |
| | | }); |
| Neue Datei |
| | |
| | | /** |
| | | * 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]); |
| | | } |
| Neue Datei |
| | |
| | | { |
| | | "compilerOptions": { |
| | | "target": "es2021", |
| | | "module": "commonjs", |
| | | "moduleResolution": "node", |
| | | "rootDir": "./src", |
| | | "outDir": "./build", |
| | | "strict": true, |
| | | "esModuleInterop": true, |
| | | "skipLibCheck": true, |
| | | "forceConsistentCasingInFileNames": true |
| | | }, |
| | | "exclude": ["node_modules"] |
| | | } |
| Neue Datei |
| | |
| | | { |
| | | "name": "bibliothek-types", |
| | | "version": "0.1.0", |
| | | "main": "build/index.js", |
| | | "types": "build/index.d.ts", |
| | | "files": ["build"], |
| | | "scripts": { |
| | | "build": "tsc" |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | /** 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; |
| | | } |
| Neue Datei |
| | |
| | | { |
| | | "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"] |
| | | } |