import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import type { DesktopLatestResponse, DesktopManifest, DesktopManifestFile, DesktopPlatform, } from '@tessera/shared'; import * as fs from 'fs'; import * as path from '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) ); } @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; } 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/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 }; } }