import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import type { DesktopLatestResponse, DesktopManifest, DesktopManifestFile, DesktopPlatform, DesktopUpdateResponse, } from '@tessera/shared'; import * as fs from 'node:fs'; import * as path from 'node:path'; /** * Wertevorrat der Plattformen (Phase 18, D-10). Geschlossen -- eine dritte * Plattform waere eine bewusste Erweiterung hier UND am Typ `DesktopPlatform` * in packages/shared/src/index.ts. */ const PLATFORMS = ['windows', 'linux'] as const; /** sha256 als Hex-String -- genau 64 Zeichen, 0-9/a-f (Gross-/Kleinschreibung egal). */ const SHA256_HEX_RE = /^[a-f0-9]{64}$/i; /** * Grundform eines einzelnen Datei-Eintrags im Manifest (WR-03, Code-Review * Phase 18): `getManifest()` prueft bislang nur die Kopf-Form, nicht die * einzelnen Plattform-Eintraege -- ein kaputter/unvollstaendiger Eintrag * wuerde sonst unbemerkt bis in `getPackage()` durchgereicht (z. B. * `entry.name === undefined`, das sich zu `"undefined"` coerct und dort * fehlleitend als Dateiname gesucht wird). */ function isValidManifestFileEntry(entry: unknown): entry is DesktopManifestFile { if (typeof entry !== 'object' || entry === null) { return false; } const candidate = entry as Record; return ( typeof candidate.name === 'string' && typeof candidate.size === 'number' && typeof candidate.sha256 === 'string' && SHA256_HEX_RE.test(candidate.sha256) && (candidate.signature === undefined || typeof candidate.signature === 'string') ); } /** * Form von `updateVersion` im Manifest: `X.Y.Z` (live/dev) oder * `X.Y.Z-beta.g` (beta) -- exakt die Form, die desktop-collect.sh * schreibt und die der Client-Comparator (`beta_commit`) erwartet. */ const UPDATE_VERSION_RE = /^\d+\.\d+\.\d+(-beta\.g[0-9a-f]{7})?$/; /** * RFC-3339-Zeitstempel. `pub_date` geht nur in die Antwort, wenn `buildTime` * diese Form hat -- ein unparsebares Datum liesse `check()` im Client * scheitern (das Plugin deserialisiert `pub_date` strikt). */ const RFC3339_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/; /** * Validiert den Query-Parameter `base` von `GET /desktop/update` (T-KGC-02): * nur ein reiner http/https-Origin -- kein Pfad ausser `/`, keine Query, kein * Fragment, keine Zugangsdaten. Rueckgabe ist `url.origin` (Host * kleingeschrieben, ohne Schlussstrich), sonst `null`. * * `base` wird NUR zum Bauen der Rueckgabe-URL fuer den Anfragenden verwendet, * nie serverseitig abgerufen (kein SSRF). Ein Angreifer koennte damit * hoechstens seinen eigenen Client auf einen fremden Download lenken -- den * die Signaturpruefung im Client ablehnt. */ export function safeOrigin(base: unknown): string | null { if (typeof base !== 'string' || base.length === 0 || base.length > 2048) { return null; } let url: URL; try { url = new URL(base); } catch { return null; } if (url.protocol !== 'http:' && url.protocol !== 'https:') { return null; } if (url.username !== '' || url.password !== '') { return null; } if (url.pathname !== '/' || url.search !== '' || url.hash !== '') { return null; } return url.origin; } @Injectable() export class DesktopService { private readonly logger = new Logger(DesktopService.name); /** Resolved path to desktop-dist/ (monorepo root, or /app/desktop-dist im Abbild). */ private readonly desktopDistDir: string; constructor() { const envDir = process.env.DESKTOP_DIST_DIR?.trim(); this.desktopDistDir = envDir && envDir.length > 0 ? envDir : // __dirname at runtime = apps/api/dist/desktop/ -- go up 4 levels to monorepo root path.resolve(__dirname, '..', '..', '..', '..', 'desktop-dist'); } /** * Liest manifest.json. Gibt `null` zurueck (nie werfen) wenn die Datei * fehlt, nicht parsebar ist, oder die Grundform nicht stimmt (version kein * String, files kein Objekt) -- D-10: "fehlt das Verzeichnis/Manifest: 404 * mit klarer Meldung". */ getManifest(): DesktopManifest | null { const manifestPath = path.join(this.desktopDistDir, 'manifest.json'); if (!fs.existsSync(manifestPath)) { return null; } try { const raw = fs.readFileSync(manifestPath, 'utf-8'); const parsed = JSON.parse(raw) as DesktopManifest; if ( typeof parsed.version !== 'string' || typeof parsed.files !== 'object' || parsed.files === null || Array.isArray(parsed.files) ) { this.logger.warn(`manifest.json unter ${manifestPath} hat unerwartete Form`); return null; } if (parsed.updateVersion !== undefined && typeof parsed.updateVersion !== 'string') { this.logger.warn(`manifest.json unter ${manifestPath} hat ein ungueltiges updateVersion`); return null; } for (const platform of PLATFORMS) { const entry = parsed.files[platform]; if (entry !== undefined && !isValidManifestFileEntry(entry)) { this.logger.warn( `manifest.json unter ${manifestPath} hat einen ungueltigen Eintrag fuer Plattform ${platform}`, ); return null; } } return parsed; } catch (error) { this.logger.warn(`manifest.json unter ${manifestPath} konnte nicht gelesen werden: ${error}`); return null; } } /** * `GET /desktop/latest` (D-10): Kopf-Felder aus dem Manifest, je * vorhandener Plattform eine relative Download-URL ergaenzt (Client stellt * die API-Basis davor, siehe Objective-Abschnitt "Vom Client aus ..."). */ getLatest(): DesktopLatestResponse { const manifest = this.getManifest(); if (!manifest) { throw new NotFoundException('Desktop packages are not available on this server'); } const files: DesktopLatestResponse['files'] = {}; for (const platform of PLATFORMS) { const entry = manifest.files[platform]; if (entry) { files[platform] = { ...entry, url: `/desktop/download/${platform}` }; } } return { version: manifest.version, channel: manifest.channel, commit: manifest.commit, buildTime: manifest.buildTime, files, }; } /** * `GET /desktop/update` (quick-260917-kgc): Antwort im dynamischen Format * von `tauri-plugin-updater` -- Pflichtfelder `version` (SemVer), `url` * (absolut) und `signature`; optional `pub_date` (RFC 3339) und `notes`. * `null` bedeutet "kein Update" und wird im Controller zu 204 ohne Body. * * `current` wird bewusst nicht ausgewertet: die Entscheidung "neuer?" trifft * der Comparator im Client, die API kennt den Client-Commit nicht. Die * absolute `url` entsteht aus dem validierten `origin` des Anfragenden * (`safeOrigin`), nie aus einem serverseitigen Abruf. `/desktop/latest` * bleibt fuer die Web-Oberflaeche mit relativen URLs. */ getUpdate(input: { target: unknown; arch: unknown; origin: string; }): DesktopUpdateResponse | null { // (1) Plattform per Whitelist -- vor jedem Dateisystemzugriff. if (!PLATFORMS.includes(input.target as DesktopPlatform)) { return null; } const platform = input.target as DesktopPlatform; // (2) Es gibt nur x86_64-Pakete. if (input.arch !== 'x86_64') { return null; } // (3) Manifest holen. const manifest = this.getManifest(); if (!manifest) { return null; } // (4) updateVersion muss vorhanden sein und die erwartete Form haben. if ( typeof manifest.updateVersion !== 'string' || !UPDATE_VERSION_RE.test(manifest.updateVersion) ) { return null; } // (5) Eintrag der Plattform mit Signatur -- ohne Signatur kein Update. const entry = manifest.files[platform]; if (!entry || typeof entry.signature !== 'string' || entry.signature.length === 0) { return null; } // (6) Antwort im Plugin-Format. const response: DesktopUpdateResponse = { version: manifest.updateVersion, url: `${input.origin}/api-proxy/desktop/download/${platform}`, signature: entry.signature, notes: `Tessera ${manifest.updateVersion}`, }; if (typeof manifest.buildTime === 'string' && RFC3339_RE.test(manifest.buildTime)) { response.pub_date = manifest.buildTime; } return response; } /** * `GET /desktop/download/:platform` (D-10, T-18-01): Reihenfolge ist die * Sicherheitseigenschaft -- Whitelist VOR jedem Dateisystemzugriff, der * Dateiname kommt ausschliesslich aus manifest.json, nie aus der Anfrage. */ getPackage(platform: string): { stream: fs.ReadStream; entry: DesktopManifestFile } { // (1) Whitelist -- vor jedem Dateisystemzugriff. if (!PLATFORMS.includes(platform as DesktopPlatform)) { throw new BadRequestException('Unknown platform'); } const knownPlatform = platform as DesktopPlatform; // (2) Manifest holen. const manifest = this.getManifest(); if (!manifest) { throw new NotFoundException('Desktop packages are not available on this server'); } // (3) Eintrag fuer diese Plattform muss existieren. const entry = manifest.files[knownPlatform]; if (!entry) { throw new NotFoundException(`No package for platform: ${knownPlatform}`); } // (4) Verteidigung in der Tiefe (T-18-02): auch ein manipuliertes // Manifest darf nicht aus dem Ordner hinausfuehren. Die Zeichen-Whitelist // allein reicht nicht -- "." und ".." bestehen ausschliesslich aus // erlaubten Zeichen, meinen im Dateisystem aber "aktueller"/"uebergeordneter // Ordner". Deshalb zusaetzlich explizit ausschliessen UND den aufgeloesten // Pfad gegen den Zielordner pruefen (haelt auch kuenftige Varianten dieses // Musters ab, falls die Zeichen-Whitelist anderswo wiederverwendet wird). if ( !/^[A-Za-z0-9._-]+$/.test(entry.name) || entry.name === '.' || entry.name === '..' ) { throw new NotFoundException(`No package for platform: ${knownPlatform}`); } const filePath = path.join(this.desktopDistDir, entry.name); const resolvedRoot = path.resolve(this.desktopDistDir) + path.sep; if (!path.resolve(filePath).startsWith(resolvedRoot)) { throw new NotFoundException(`No package for platform: ${knownPlatform}`); } // (5) Datei muss existieren. if (!fs.existsSync(filePath)) { throw new NotFoundException(`Package file missing: ${entry.name}`); } // (6) Stream zurueckgeben -- kein Puffern der ganzen Datei (Installer // sind deutlich groesser als DKV-Exporte). return { stream: fs.createReadStream(filePath), entry }; } }