636fe0df8f
- Aufgabe 2: vier sichere Biome-Regeln (useImportType pfadgebunden auf apps/web+packages, noUselessEscapeInRegex, useConst, useExponentiationOperator) sowie fuenf ungesicherte Regeln (useNodejsImportProtocol, useLiteralKeys, useOptionalChain, useTemplate, useParseIntRadix) angewendet und den gesamten Diff von Hand gelesen (ldap.service.ts zeichenweise gegen Gross-/Kleinschreibung der AD-Merkmale, auth.service.ts/jwt.strategy.ts gegen Durchwinken bei fehlender Sitzung geprueft) - noUselessSwitchCase bleibt bewusst stehen (tender-normalizer.service.ts:60, die Fallmarke dokumentiert Absicht) - Toter Code (D-03): fuenf folgenlose Auffangvariablen entfernt, eine nicht benutzte Funktion (forSystemQuery, Pruefskript) entfernt, ein positionsgebundener Dekoratorparameter umbenannt (current-user.decorator.ts), fuenf Symptomfunde entfernt und als Folgeaufgaben zu melden (siehe unten) - Sechs weitere, im Plan nicht namentlich gelistete aber gleich-kategorische Dead-Code-Fundstellen in Testdateien zusaetzlich bereinigt (groups.service.spec.ts, cert-manager.test.tsx, ldap.service.spec.ts, prisma-tenant.extension.spec.ts x3) — noetig, um die vom Plan selbst verlangten Nullstaende bei noUnusedVariables/ noUnusedImports/noUnusedFunctionParameters zu erreichen Dekoratordaten aus apps/api unveraendert (593 Zeilen, sha256 6e1583f1...). Endstand 620 Befunde (541 echt, 79 Test) statt der im Plan geschaetzten 621/542 — eine Differenz von 1, weil das Streichen des Namens aus `catch (e: any)` in calendar.service.ts (Symptom-Fix) den dort ebenfalls gemeldeten noExplicitAny-Befund miteliminiert; das ist eine erwuenschte Nebenwirkung, keine Regression. Fehlerstufe 0, beide Testlaeufe punktgleich gruen (69/1124, 66/459), pnpm type-check 4/4, pnpm lint --force 5/5. Folgeaufgaben aus D-03 (nicht in diesem Vorgang behoben): - force-password-change.interceptor.ts: Freigabeliste prueft nur den Pfad, nicht die HTTP-Methode - change-password/page.tsx: nach erzwungenem Wechsel bleibt die Person auf der Seite stehen (keine Weiterleitung, keine Aktualisierung der Benutzerablage) - VehicleTable.tsx: Loeschschaltflaeche hat keinen Besetztzustand, laesst sich doppelt ausloesen - SplitTab.tsx: downloadAllAsZip erhielt eine ungenutzte Uebersetzungsfunktion, Hinweis auf fest verdrahtete Texte im Zip-Pfad Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
291 lines
10 KiB
TypeScript
291 lines
10 KiB
TypeScript
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<string, unknown>;
|
|
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<sha7>` (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 };
|
|
}
|
|
}
|