Files
tessera-ctl/apps/api/src/desktop/desktop.controller.ts
T
schalli de81c74e53 feat(api): GET /desktop/update — signierte Desktop-Pakete im Format des Tauri-Updaters ausliefern
- Neuer oeffentlicher Endpunkt (vor download/:platform): base-Origin wird
  per safeOrigin validiert (nur http/https, kein Pfad/Query/Fragment/
  Userinfo, sonst 400) und nur zum Bau der absoluten Download-URL genutzt,
  nie serverseitig abgerufen
- 204 ohne Body bei fremder Plattform/Architektur, fehlendem Manifest,
  fehlender Signatur oder fehlendem/ungueltigem updateVersion; sonst
  { version, pub_date (nur RFC 3339), url, signature, notes }
- Manifest-Felder signature (je Plattform) und updateVersion optional in
  @tessera/shared, Eintragspruefung akzeptiert nur String-Signaturen
- 10 neue Spec-Tests, Test 11 erweitert (23 gesamt); latest/download
  unveraendert

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:32:52 +02:00

78 lines
2.8 KiB
TypeScript

import {
BadRequestException,
Controller,
Get,
Inject,
Param,
Query,
Res,
StreamableFile,
} from '@nestjs/common';
import type { DesktopLatestResponse, DesktopUpdateResponse } from '@tessera/shared';
import type { Response } from 'express';
import { Public } from '../auth/decorators/public.decorator';
import { DesktopService, safeOrigin } from './desktop.service';
@Controller('desktop')
export class DesktopController {
// `@Inject()` explizit (nicht nur der Konstruktor-Typ): Vitest transpiliert
// ueber esbuild, das `emitDecoratorMetadata` nicht respektiert -- ohne den
// expliziten Token findet Nests DI in diesem einen HTTP-Durchstich-Test
// (desktop.service.spec.ts) keinen Provider und `desktopService` bleibt
// `undefined`. Im echten Build (tsc via `nest build`) waere das auch ohne
// `@Inject()` korrekt aufgeloest worden.
constructor(@Inject(DesktopService) private readonly desktopService: DesktopService) {}
// Bewusst oeffentlich (D-10, gleicher Grund wie HealthController.getVersion,
// T-KU1-03): die Anmeldeseite zeigt den Download-Link, bevor eine Anmeldung
// existiert.
@Public()
@Get('latest')
getLatest(): DesktopLatestResponse {
return this.desktopService.getLatest();
}
// Bewusst oeffentlich (D-10, wie latest/download): der Desktop-Client prueft
// beim Start vor jeder Anmeldung. Statische Route VOR `download/:platform`
// (Konvention Route-Order, auch wenn sich die beiden hier nicht
// ueberschatten). 204 ist der vom Updater-Plugin definierte Status fuer
// "kein Update"; `passthrough` + `res.status(204)` funktioniert, weil Nest
// den Standardstatus VOR dem Handler setzt und die Antwort danach ohne
// eigenen Statuscode schreibt -- der Handler-Status gewinnt. `current`
// wird nicht deklariert, weil der Service es nicht auswertet.
@Public()
@Get('update')
update(
@Query('target') target: unknown,
@Query('arch') arch: unknown,
@Query('base') base: unknown,
@Res({ passthrough: true }) res: Response,
): DesktopUpdateResponse | undefined {
const origin = safeOrigin(base);
if (!origin) {
throw new BadRequestException(
'base must be an http(s) origin without path, query or credentials',
);
}
const result = this.desktopService.getUpdate({ target, arch, origin });
if (!result) {
res.status(204);
return undefined;
}
return result;
}
// Bewusst oeffentlich (D-10): der Download selbst braucht keine Anmeldung,
// gleicher Grund wie getLatest oben.
@Public()
@Get('download/:platform')
download(@Param('platform') platform: string): StreamableFile {
const { stream, entry } = this.desktopService.getPackage(platform);
return new StreamableFile(stream, {
type: 'application/octet-stream',
disposition: `attachment; filename="${entry.name}"`,
length: entry.size,
});
}
}