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:
Executable
+156
@@ -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)"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>>;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user