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; @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) { this.logger.warn(`manifest.json unter ${manifestPath} hat unerwartete Form`); 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. if (!/^[A-Za-z0-9._-]+$/.test(entry.name)) { throw new NotFoundException(`No package for platform: ${knownPlatform}`); } // (5) Datei muss existieren. const filePath = path.join(this.desktopDistDir, entry.name); 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 }; } }