feat(18-01): Task 1 — Linux-Paket bis zum Download aus der API (Durchstich)

Die duenne Strecke Skript -> Abbild -> API beweisen (D-08, D-10):

- desktop-collect.sh sammelt AppImage/exe ein, schreibt manifest.json
  (Version, Kanal, Commit, Groesse, SHA-256) ohne Secrets
- apps/api/src/desktop/: neues Modul mit GET /desktop/latest und
  GET /desktop/download/:platform, beide @Public(); Plattform-Whitelist
  vor jedem Dateisystemzugriff, Dateiname ausschliesslich aus dem
  Manifest (T-18-01, T-18-02)
- packages/shared: DesktopPlatform/-Manifest(File)/-Latest(Response)
  Typen
- Dockerfile kopiert desktop-dist/ in die runner-Stufe; desktop-dist/
  per .gitkeep + .gitignore versioniert (leeres Verzeichnis, Pakete
  bleiben ungetrackt)
- HTTP-Durchstich-Spec (8 Tests) via NestFactory, kein fs-Mock; echtes
  Temp-Verzeichnis + unabhaengig berechneter SHA-256

Abweichung: DesktopController braucht @Inject(DesktopService) explizit
— Vitest transpiliert ueber esbuild, das emitDecoratorMetadata nicht
abbildet, sonst bleibt desktopService bei einem echten NestFactory-Bau
undefined (Rule 3, Blocker).

Lokal bewiesen: neu gebautes API-Abbild liefert /desktop/latest (200)
und /desktop/download/linux (200, attachment) aus, auch ueber
/api-proxy/ des Web-Containers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-16 16:05:55 +02:00
parent 0e4eb9bf9e
commit ae8fecb538
10 changed files with 539 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
#!/bin/sh
# desktop-collect.sh -- Desktop-Pakete aus dem Tauri-Bau einsammeln, unter
# kanonischem Namen ablegen und manifest.json schreiben (Phase 18, D-08).
#
# Kanalmodell (identisch zu publish-images.sh, an GITHUB_REF entschieden,
# damit lokale Proben ohne Runner pruefbar sind):
# refs/tags/v* -> Kanal live, kein Namenssuffix
# refs/heads/main -> Kanal beta, Suffix -beta.{7-stelliger SHA} am Dateinamen
# alles andere -> Kanal dev, kein Suffix (lokale Proben tragen den
# Freigabe-Namen, damit desktop-version.sh/desktop-collect.sh
# ohne Pipeline durchgespielt werden koennen)
#
# Aufruf: sh .gitea/scripts/desktop-collect.sh --require linux[,windows]
#
# Umgebung:
# GITHUB_REF Kanalentscheidung (siehe oben)
# DESKTOP_DIST Zielordner fuer die Pakete (Vorgabe: desktop-dist)
# TAURI_DIR Tauri-Projektordner (Vorgabe: apps/desktop/src-tauri)
#
# Die Version kommt aus tauri.conf.json (von desktop-version.sh geschrieben
# oder als eingecheckte Basislinie vorhanden) -- dieses Skript liest sie nur,
# es schreibt sie nicht. Dieses Skript kennt kein Secret.
set -eu
REQUIRE=""
while [ $# -gt 0 ]; do
case "$1" in
--require)
shift
REQUIRE="${1:-}"
;;
*)
echo "Unbekanntes Argument: $1" >&2
exit 1
;;
esac
shift
done
if [ -z "$REQUIRE" ]; then
echo "Aufruf: $0 --require linux[,windows]" >&2
exit 1
fi
DESKTOP_DIST="${DESKTOP_DIST:-desktop-dist}"
TAURI_DIR="${TAURI_DIR:-apps/desktop/src-tauri}"
REF="${GITHUB_REF:-}"
case "$REF" in
refs/tags/v*)
CHANNEL=live
SUFFIX=""
;;
refs/heads/main)
CHANNEL=beta
SHA_SHORT="$(git rev-parse --short=7 HEAD)"
SUFFIX="-beta.$SHA_SHORT"
;;
*)
CHANNEL=dev
SUFFIX=""
;;
esac
VERSION="$(jq -r .version "$TAURI_DIR/tauri.conf.json")"
case "$VERSION" in
[0-9]*.[0-9]*.[0-9]*)
if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Version '$VERSION' aus $TAURI_DIR/tauri.conf.json ist nicht rein numerisch (X.Y.Z)." >&2
exit 1
fi
;;
*)
echo "Version '$VERSION' aus $TAURI_DIR/tauri.conf.json ist nicht rein numerisch (X.Y.Z)." >&2
exit 1
;;
esac
COMMIT="$(git rev-parse --short=7 HEAD)"
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
mkdir -p "$DESKTOP_DIST"
# Alte Pakete/Manifest entfernen, Platzhalter (.gitkeep) bleibt erhalten.
rm -f "$DESKTOP_DIST"/*.AppImage "$DESKTOP_DIST"/*.exe "$DESKTOP_DIST/manifest.json"
MANIFEST_ARGS=""
TMP_MANIFEST="$(mktemp)"
trap 'rm -f "$TMP_MANIFEST"' EXIT INT TERM
LINUX_NAME=""
LINUX_SIZE=""
LINUX_SHA=""
WINDOWS_NAME=""
WINDOWS_SIZE=""
WINDOWS_SHA=""
case ",$REQUIRE," in
*,linux,*)
APPIMAGE_DIR="$TAURI_DIR/target/release/bundle/appimage"
APPIMAGE_COUNT="$(find "$APPIMAGE_DIR" -maxdepth 1 -name '*.AppImage' 2>/dev/null | wc -l | tr -d ' ')"
if [ "$APPIMAGE_COUNT" -ne 1 ]; then
echo "Erwartet genau eine .AppImage-Datei in $APPIMAGE_DIR, gefunden: $APPIMAGE_COUNT" >&2
exit 1
fi
APPIMAGE_SRC="$(find "$APPIMAGE_DIR" -maxdepth 1 -name '*.AppImage')"
LINUX_NAME="Tessera-${VERSION}${SUFFIX}.AppImage"
cp "$APPIMAGE_SRC" "$DESKTOP_DIST/$LINUX_NAME"
LINUX_SIZE="$(stat -c %s "$DESKTOP_DIST/$LINUX_NAME")"
LINUX_SHA="$(sha256sum "$DESKTOP_DIST/$LINUX_NAME" | cut -d' ' -f1)"
echo "linux: $LINUX_NAME (${LINUX_SIZE} Bytes, sha256 $LINUX_SHA)"
;;
esac
case ",$REQUIRE," in
*,windows,*)
NSIS_DIR="$TAURI_DIR/target/x86_64-pc-windows-msvc/release/bundle/nsis"
NSIS_COUNT="$(find "$NSIS_DIR" -maxdepth 1 -name '*.exe' 2>/dev/null | wc -l | tr -d ' ')"
if [ "$NSIS_COUNT" -ne 1 ]; then
echo "Erwartet genau eine .exe-Datei in $NSIS_DIR, gefunden: $NSIS_COUNT" >&2
exit 1
fi
NSIS_SRC="$(find "$NSIS_DIR" -maxdepth 1 -name '*.exe')"
WINDOWS_NAME="Tessera-Setup-${VERSION}${SUFFIX}.exe"
cp "$NSIS_SRC" "$DESKTOP_DIST/$WINDOWS_NAME"
WINDOWS_SIZE="$(stat -c %s "$DESKTOP_DIST/$WINDOWS_NAME")"
WINDOWS_SHA="$(sha256sum "$DESKTOP_DIST/$WINDOWS_NAME" | cut -d' ' -f1)"
echo "windows: $WINDOWS_NAME (${WINDOWS_SIZE} Bytes, sha256 $WINDOWS_SHA)"
;;
esac
# manifest.json ausschliesslich ueber jq -n mit --arg/--argjson bauen (kein
# manuelles String-Zusammenbauen von JSON).
jq -n \
--arg version "$VERSION" \
--arg channel "$CHANNEL" \
--arg commit "$COMMIT" \
--arg buildTime "$BUILD_TIME" \
--arg linuxName "$LINUX_NAME" \
--argjson linuxSize "${LINUX_SIZE:-null}" \
--arg linuxSha "$LINUX_SHA" \
--arg windowsName "$WINDOWS_NAME" \
--argjson windowsSize "${WINDOWS_SIZE:-null}" \
--arg windowsSha "$WINDOWS_SHA" \
'{
version: $version,
channel: $channel,
commit: $commit,
buildTime: $buildTime,
files: (
{}
+ (if $linuxName != "" then { linux: { name: $linuxName, size: $linuxSize, sha256: $linuxSha } } else {} end)
+ (if $windowsName != "" then { windows: { name: $windowsName, size: $windowsSize, sha256: $windowsSha } } else {} end)
)
}' > "$DESKTOP_DIST/manifest.json"
echo "Manifest geschrieben: $DESKTOP_DIST/manifest.json (Version $VERSION, Kanal $CHANNEL)"
+4
View File
@@ -39,3 +39,7 @@ user-files/
# GSD runtime scratch (Dispatch-Sentinel, pro Sitzung neu geschrieben) # GSD runtime scratch (Dispatch-Sentinel, pro Sitzung neu geschrieben)
.gsd/ .gsd/
# Desktop-Pakete aus dem Bau (Phase 18)
desktop-dist/*
!desktop-dist/.gitkeep
+4
View File
@@ -47,6 +47,10 @@ COPY --from=builder /app/node_modules/.pnpm/@prisma+client@6.19.3_prisma@6.19.3_
COPY --from=builder /app/apps/api/prisma ./apps/api/prisma COPY --from=builder /app/apps/api/prisma ./apps/api/prisma
COPY --from=builder /app/packages/shared/src ./packages/shared/src COPY --from=builder /app/packages/shared/src ./packages/shared/src
COPY apps/api/scripts ./apps/api/scripts COPY apps/api/scripts ./apps/api/scripts
# Desktop-Pakete (Phase 18, D-08): im CI legt desktop-collect.sh Pakete +
# manifest.json in diesen Ordner, lokal liegt nur der Platzhalter. Nur
# lesend zur Laufzeit -- kein chown noetig.
COPY desktop-dist ./desktop-dist
USER nestjs USER nestjs
EXPOSE 3001 EXPOSE 3001
CMD ["sh", "apps/api/scripts/migrate-and-start.sh"] CMD ["sh", "apps/api/scripts/migrate-and-start.sh"]
+2
View File
@@ -7,6 +7,7 @@ import { BugReportsModule } from './bug-reports/bug-reports.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard'; import { RolesGuard } from './auth/guards/roles.guard';
import { ForcePasswordChangeInterceptor } from './auth/interceptors/force-password-change.interceptor'; import { ForcePasswordChangeInterceptor } from './auth/interceptors/force-password-change.interceptor';
import { DesktopModule } from './desktop/desktop.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { LdapModule } from './ldap/ldap.module'; import { LdapModule } from './ldap/ldap.module';
import { MailModule } from './mail/mail.module'; import { MailModule } from './mail/mail.module';
@@ -36,6 +37,7 @@ import { UserModule } from './user/user.module';
UserModule, UserModule,
TenantModule, TenantModule,
HealthModule, HealthModule,
DesktopModule,
MailModule, MailModule,
LdapModule, LdapModule,
ModuleRegistryModule, ModuleRegistryModule,
@@ -0,0 +1,37 @@
import { Controller, Get, Inject, Param, StreamableFile } from '@nestjs/common';
import type { DesktopLatestResponse } from '@tessera/shared';
import { Public } from '../auth/decorators/public.decorator';
import { DesktopService } 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): 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,
});
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DesktopController } from './desktop.controller';
import { DesktopService } from './desktop.service';
@Module({
controllers: [DesktopController],
providers: [DesktopService],
})
export class DesktopModule {}
@@ -0,0 +1,168 @@
import 'reflect-metadata';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { NestFactory } from '@nestjs/core';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { IS_PUBLIC_KEY } from '../auth/decorators/public.decorator';
import { DesktopController } from './desktop.controller';
import { DesktopModule } from './desktop.module';
import { DesktopService } from './desktop.service';
/**
* DesktopService/DesktopController.spec — HTTP-Durchstich ueber
* NestFactory (Phase 18, Task 1). Kein `fs`-Mock: ein echtes
* Temp-Verzeichnis mit einer kleinen Zufallsdatei und einem von Hand
* geschriebenen manifest.json, dessen sha256 unabhaengig ueber
* crypto.createHash berechnet wird -- der Pruefling erzeugt den
* Erwartungswert nicht selbst.
*
* `DesktopService.getManifest()` liest manifest.json bei JEDEM Aufruf neu
* (kein Cache) -- writeManifest() darf die Datei deshalb zwischen Tests
* ueberschreiben, ohne den laufenden HTTP-Server neu zu starten.
*/
const ORIGINAL_ENV = process.env.DESKTOP_DIST_DIR;
let tempDir: string;
let app: Awaited<ReturnType<typeof NestFactory.create>>;
let baseUrl: string;
const PACKAGE_NAME = 'test-package.bin';
let packageSize: number;
let packageSha256: string;
function writeManifest(files: Record<string, { name: string; size: number; sha256: string }>) {
fs.writeFileSync(
path.join(tempDir, 'manifest.json'),
JSON.stringify({
version: '1.1.0',
channel: 'dev',
commit: 'abc1234',
buildTime: '2026-09-16T00:00:00Z',
files,
}),
);
}
beforeAll(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-desktop-'));
const packageBytes = crypto.randomBytes(64 * 1024);
fs.writeFileSync(path.join(tempDir, PACKAGE_NAME), packageBytes);
packageSize = packageBytes.length;
packageSha256 = crypto.createHash('sha256').update(packageBytes).digest('hex');
writeManifest({ linux: { name: PACKAGE_NAME, size: packageSize, sha256: packageSha256 } });
process.env.DESKTOP_DIST_DIR = tempDir;
app = await NestFactory.create(DesktopModule, { logger: false });
await app.listen(0);
const address = app.getHttpServer().address();
const port = typeof address === 'object' && address ? address.port : 0;
baseUrl = `http://127.0.0.1:${port}`;
});
afterAll(async () => {
await app.close();
fs.rmSync(tempDir, { recursive: true, force: true });
if (ORIGINAL_ENV === undefined) {
delete process.env.DESKTOP_DIST_DIR;
} else {
process.env.DESKTOP_DIST_DIR = ORIGINAL_ENV;
}
});
afterEach(() => {
// Default-Manifest fuer den naechsten Test wiederherstellen (Tests 6/7
// ueberschreiben es bewusst mit einer anderen Form).
writeManifest({ linux: { name: PACKAGE_NAME, size: packageSize, sha256: packageSha256 } });
});
describe('DesktopService/DesktopController — HTTP-Durchstich (Phase 18)', () => {
it('Test 1 (latest, Manifest vorhanden): 200 mit Kopf-Feldern und relativer Download-URL', async () => {
const res = await fetch(`${baseUrl}/desktop/latest`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({
version: '1.1.0',
channel: 'dev',
commit: 'abc1234',
buildTime: '2026-09-16T00:00:00Z',
files: {
linux: {
name: PACKAGE_NAME,
size: packageSize,
sha256: packageSha256,
url: '/desktop/download/linux',
},
},
});
});
it('Test 2 (getLatest ohne Manifest): eigene Instanz mit leerem Temp-Verzeichnis wirft NotFoundException', () => {
const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-desktop-empty-'));
const previous = process.env.DESKTOP_DIST_DIR;
process.env.DESKTOP_DIST_DIR = emptyDir;
try {
const service = new DesktopService();
expect(() => service.getLatest()).toThrow(NotFoundException);
} finally {
process.env.DESKTOP_DIST_DIR = previous;
fs.rmSync(emptyDir, { recursive: true, force: true });
}
});
it('Test 3 (download/linux): 200, attachment-Header und Body-Hash stimmen mit dem Manifest ueberein', async () => {
const res = await fetch(`${baseUrl}/desktop/download/linux`);
expect(res.status).toBe(200);
expect(res.headers.get('content-disposition')).toBe(`attachment; filename="${PACKAGE_NAME}"`);
expect(res.headers.get('content-type')).toBe('application/octet-stream');
expect(res.headers.get('content-length')).toBe(String(packageSize));
const buffer = Buffer.from(await res.arrayBuffer());
const hash = crypto.createHash('sha256').update(buffer).digest('hex');
expect(hash).toBe(packageSha256);
});
it('Test 4 (Plattform-Whitelist + Traversal ueber HTTP): mac und ..%2F..%2Fetc%2Fpasswd enden mit 400', async () => {
const resMac = await fetch(`${baseUrl}/desktop/download/mac`);
expect(resMac.status).toBe(400);
const resTraversal = await fetch(`${baseUrl}/desktop/download/..%2F..%2Fetc%2Fpasswd`);
expect(resTraversal.status).toBe(400);
});
it('Test 5 (Whitelist vor Dateisystem): nicht existierendes Verzeichnis + mac wirft BadRequestException, nicht NotFoundException', () => {
const missingDir = path.join(os.tmpdir(), 'tessera-desktop-does-not-exist-' + Date.now());
const previous = process.env.DESKTOP_DIST_DIR;
process.env.DESKTOP_DIST_DIR = missingDir;
try {
const service = new DesktopService();
expect(() => service.getPackage('mac')).toThrow(BadRequestException);
} finally {
process.env.DESKTOP_DIST_DIR = previous;
}
});
it('Test 6 (Manifest nur mit windows): download/linux endet mit 404', async () => {
writeManifest({
windows: { name: 'Tessera-Setup-1.1.0.exe', size: 123, sha256: 'a'.repeat(64) },
});
const res = await fetch(`${baseUrl}/desktop/download/linux`);
expect(res.status).toBe(404);
});
it('Test 7 (manipulierter Name im Manifest): "../x.AppImage" endet mit 404', async () => {
writeManifest({
linux: { name: '../x.AppImage', size: 123, sha256: 'a'.repeat(64) },
});
const res = await fetch(`${baseUrl}/desktop/download/linux`);
expect(res.status).toBe(404);
});
it('Test 8 (bewusst oeffentlich): getLatest und download tragen @Public()', () => {
expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.getLatest)).toBe(true);
expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.download)).toBe(true);
});
});
+125
View File
@@ -0,0 +1,125 @@
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 };
}
}
View File
+34
View File
@@ -17,3 +17,37 @@ export interface VersionResponse {
commit: string; commit: string;
buildTime: string; buildTime: string;
} }
/**
* Desktop-Pakete (Phase 18, D-08): Quelle ist ausschliesslich `manifest.json`,
* geschrieben nur von `.gitea/scripts/desktop-collect.sh` im CI. `platform` ist
* ein geschlossener Wertevorrat -- eine dritte Plattform waere eine bewusste
* Erweiterung hier und an der Konstante `PLATFORMS` im API-Dienst.
*/
export type DesktopPlatform = 'windows' | 'linux';
export interface DesktopManifestFile {
name: string;
size: number;
sha256: string;
}
export interface DesktopManifest {
version: string;
channel: string;
commit: string;
buildTime: string;
files: Partial<Record<DesktopPlatform, DesktopManifestFile>>;
}
export interface DesktopLatestFile extends DesktopManifestFile {
url: string;
}
export interface DesktopLatestResponse {
version: string;
channel: string;
commit: string;
buildTime: string;
files: Partial<Record<DesktopPlatform, DesktopLatestFile>>;
}