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>
This commit is contained in:
@@ -1,7 +1,17 @@
|
|||||||
import { Controller, Get, Inject, Param, StreamableFile } from '@nestjs/common';
|
import {
|
||||||
import type { DesktopLatestResponse } from '@tessera/shared';
|
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 { Public } from '../auth/decorators/public.decorator';
|
||||||
import { DesktopService } from './desktop.service';
|
import { DesktopService, safeOrigin } from './desktop.service';
|
||||||
|
|
||||||
@Controller('desktop')
|
@Controller('desktop')
|
||||||
export class DesktopController {
|
export class DesktopController {
|
||||||
@@ -22,6 +32,36 @@ export class DesktopController {
|
|||||||
return this.desktopService.getLatest();
|
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,
|
// Bewusst oeffentlich (D-10): der Download selbst braucht keine Anmeldung,
|
||||||
// gleicher Grund wie getLatest oben.
|
// gleicher Grund wie getLatest oben.
|
||||||
@Public()
|
@Public()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
import { IS_PUBLIC_KEY } from '../auth/decorators/public.decorator';
|
import { IS_PUBLIC_KEY } from '../auth/decorators/public.decorator';
|
||||||
import { DesktopController } from './desktop.controller';
|
import { DesktopController } from './desktop.controller';
|
||||||
import { DesktopModule } from './desktop.module';
|
import { DesktopModule } from './desktop.module';
|
||||||
import { DesktopService } from './desktop.service';
|
import { DesktopService, safeOrigin } from './desktop.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DesktopService/DesktopController.spec — HTTP-Durchstich ueber
|
* DesktopService/DesktopController.spec — HTTP-Durchstich ueber
|
||||||
@@ -34,7 +34,23 @@ const PACKAGE_NAME = 'test-package.bin';
|
|||||||
let packageSize: number;
|
let packageSize: number;
|
||||||
let packageSha256: string;
|
let packageSha256: string;
|
||||||
|
|
||||||
function writeManifest(files: Record<string, { name: string; size: number; sha256: string }>) {
|
/** Origin des "eigenen" Servers, wie ihn der Desktop-Client als `base` mitschickt. */
|
||||||
|
const ORIGIN = 'https://tessera.example.com';
|
||||||
|
/** Beliebige Base64-Zeile -- die API reicht die Signatur nur durch, prueft sie nicht. */
|
||||||
|
const SIG = 'dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZQo=';
|
||||||
|
|
||||||
|
type ManifestHead = {
|
||||||
|
version?: string;
|
||||||
|
channel?: string;
|
||||||
|
commit?: string;
|
||||||
|
buildTime?: string;
|
||||||
|
updateVersion?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function writeManifest(
|
||||||
|
files: Record<string, { name: string; size: number; sha256: string; signature?: string }>,
|
||||||
|
head: ManifestHead = {},
|
||||||
|
) {
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(tempDir, 'manifest.json'),
|
path.join(tempDir, 'manifest.json'),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -42,11 +58,29 @@ function writeManifest(files: Record<string, { name: string; size: number; sha25
|
|||||||
channel: 'dev',
|
channel: 'dev',
|
||||||
commit: 'abc1234',
|
commit: 'abc1234',
|
||||||
buildTime: '2026-09-16T00:00:00Z',
|
buildTime: '2026-09-16T00:00:00Z',
|
||||||
|
...head,
|
||||||
files,
|
files,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Standard-Eintrag fuer linux, optional signiert. */
|
||||||
|
function linuxEntry(signature?: string) {
|
||||||
|
return {
|
||||||
|
linux: {
|
||||||
|
name: PACKAGE_NAME,
|
||||||
|
size: packageSize,
|
||||||
|
sha256: packageSha256,
|
||||||
|
...(signature === undefined ? {} : { signature }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUrl(query: Record<string, string>) {
|
||||||
|
const params = new URLSearchParams(query);
|
||||||
|
return `${baseUrl}/desktop/update?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-desktop-'));
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-desktop-'));
|
||||||
const packageBytes = crypto.randomBytes(64 * 1024);
|
const packageBytes = crypto.randomBytes(64 * 1024);
|
||||||
@@ -211,8 +245,139 @@ describe('DesktopService/DesktopController — HTTP-Durchstich (Phase 18)', () =
|
|||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Test 11 (bewusst oeffentlich): getLatest und download tragen @Public()', () => {
|
it('Test 11 (bewusst oeffentlich): getLatest, download und update tragen @Public()', () => {
|
||||||
expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.getLatest)).toBe(true);
|
expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.getLatest)).toBe(true);
|
||||||
expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.download)).toBe(true);
|
expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.download)).toBe(true);
|
||||||
|
expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.update)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 12 (update, beta, signiert): 200 im dynamischen Updater-Format mit absoluter URL aus base', async () => {
|
||||||
|
writeManifest(linuxEntry(SIG), { channel: 'beta', updateVersion: '1.1.0-beta.gabc1234' });
|
||||||
|
const res = await fetch(
|
||||||
|
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.1.0', base: ORIGIN }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toContain('application/json');
|
||||||
|
expect(await res.json()).toEqual({
|
||||||
|
version: '1.1.0-beta.gabc1234',
|
||||||
|
pub_date: '2026-09-16T00:00:00Z',
|
||||||
|
url: `${ORIGIN}/api-proxy/desktop/download/linux`,
|
||||||
|
signature: SIG,
|
||||||
|
notes: 'Tessera 1.1.0-beta.gabc1234',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 13 (update, live): version und notes tragen die reine X.Y.Z', async () => {
|
||||||
|
writeManifest(linuxEntry(SIG), { channel: 'live', updateVersion: '1.1.0' });
|
||||||
|
const res = await fetch(
|
||||||
|
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.version).toBe('1.1.0');
|
||||||
|
expect(body.notes).toBe('Tessera 1.1.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 14 (base mit Schlussstrich): url wird aus dem Origin ohne Schlussstrich gebildet', async () => {
|
||||||
|
writeManifest(linuxEntry(SIG), { channel: 'live', updateVersion: '1.1.0' });
|
||||||
|
const res = await fetch(
|
||||||
|
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: `${ORIGIN}/` }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.url).toBe(`${ORIGIN}/api-proxy/desktop/download/linux`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 15 (ohne Signatur): Standard-Manifest -> 204 ohne Body', async () => {
|
||||||
|
const res = await fetch(
|
||||||
|
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
expect(await res.text()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 16 (updateVersion fehlt oder ungueltig trotz Signatur): 204', async () => {
|
||||||
|
writeManifest(linuxEntry(SIG));
|
||||||
|
const resMissing = await fetch(
|
||||||
|
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }),
|
||||||
|
);
|
||||||
|
expect(resMissing.status).toBe(204);
|
||||||
|
|
||||||
|
writeManifest(linuxEntry(SIG), { channel: 'beta', updateVersion: '1.1.0-beta.abc1234' });
|
||||||
|
const resInvalid = await fetch(
|
||||||
|
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }),
|
||||||
|
);
|
||||||
|
expect(resInvalid.status).toBe(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 17 (Plattform/Architektur): darwin, windows ohne Eintrag, aarch64 und fehlendes target -> 204', async () => {
|
||||||
|
writeManifest(linuxEntry(SIG), { channel: 'live', updateVersion: '1.1.0' });
|
||||||
|
const cases: Record<string, string>[] = [
|
||||||
|
{ target: 'darwin', arch: 'x86_64', current: '1.0.0', base: ORIGIN },
|
||||||
|
{ target: 'windows', arch: 'x86_64', current: '1.0.0', base: ORIGIN },
|
||||||
|
{ target: 'linux', arch: 'aarch64', current: '1.0.0', base: ORIGIN },
|
||||||
|
{ arch: 'x86_64', current: '1.0.0', base: ORIGIN },
|
||||||
|
];
|
||||||
|
for (const query of cases) {
|
||||||
|
const res = await fetch(updateUrl(query));
|
||||||
|
expect(res.status, JSON.stringify(query)).toBe(204);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 18 (base-Validierung, HTTP): fehlendes, fremdes oder unreines base -> 400', async () => {
|
||||||
|
writeManifest(linuxEntry(SIG), { channel: 'live', updateVersion: '1.1.0' });
|
||||||
|
const missing = await fetch(updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0' }));
|
||||||
|
expect(missing.status).toBe(400);
|
||||||
|
const bad = [
|
||||||
|
'ftp://host',
|
||||||
|
'https://user:pw@host',
|
||||||
|
'https://host/pfad',
|
||||||
|
'https://host/?x=1',
|
||||||
|
'https://host/#f',
|
||||||
|
'kein url',
|
||||||
|
];
|
||||||
|
for (const base of bad) {
|
||||||
|
const res = await fetch(
|
||||||
|
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base }),
|
||||||
|
);
|
||||||
|
expect(res.status, base).toBe(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 19 (safeOrigin direkt): nur reine http(s)-Origins, kleingeschrieben, ohne Schlussstrich', () => {
|
||||||
|
expect(safeOrigin('https://tessera.example.com')).toBe('https://tessera.example.com');
|
||||||
|
expect(safeOrigin('http://localhost:3000/')).toBe('http://localhost:3000');
|
||||||
|
expect(safeOrigin('HTTPS://Tessera.Example.com')).toBe('https://tessera.example.com');
|
||||||
|
expect(safeOrigin(['https://a', 'https://b'])).toBeNull();
|
||||||
|
expect(safeOrigin(undefined)).toBeNull();
|
||||||
|
expect(safeOrigin('')).toBeNull();
|
||||||
|
expect(safeOrigin('https://host/pfad')).toBeNull();
|
||||||
|
expect(safeOrigin('javascript:alert(1)')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 20 (Manifest fehlt): update -> 204', async () => {
|
||||||
|
fs.rmSync(path.join(tempDir, 'manifest.json'));
|
||||||
|
const res = await fetch(
|
||||||
|
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 21 (Manifest-Validierung): signature als Zahl macht den Eintrag ungueltig -- download/linux 404', async () => {
|
||||||
|
// Am `writeManifest()`-Helper vorbei (dessen Typ verlangt einen 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: {
|
||||||
|
linux: { name: PACKAGE_NAME, size: packageSize, sha256: packageSha256, signature: 123 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const res = await fetch(`${baseUrl}/desktop/download/linux`);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
DesktopManifest,
|
DesktopManifest,
|
||||||
DesktopManifestFile,
|
DesktopManifestFile,
|
||||||
DesktopPlatform,
|
DesktopPlatform,
|
||||||
|
DesktopUpdateResponse,
|
||||||
} from '@tessera/shared';
|
} from '@tessera/shared';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
@@ -35,10 +36,58 @@ function isValidManifestFileEntry(entry: unknown): entry is DesktopManifestFile
|
|||||||
typeof candidate.name === 'string' &&
|
typeof candidate.name === 'string' &&
|
||||||
typeof candidate.size === 'number' &&
|
typeof candidate.size === 'number' &&
|
||||||
typeof candidate.sha256 === 'string' &&
|
typeof candidate.sha256 === 'string' &&
|
||||||
SHA256_HEX_RE.test(candidate.sha256)
|
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()
|
@Injectable()
|
||||||
export class DesktopService {
|
export class DesktopService {
|
||||||
private readonly logger = new Logger(DesktopService.name);
|
private readonly logger = new Logger(DesktopService.name);
|
||||||
@@ -78,6 +127,10 @@ export class DesktopService {
|
|||||||
this.logger.warn(`manifest.json unter ${manifestPath} hat unerwartete Form`);
|
this.logger.warn(`manifest.json unter ${manifestPath} hat unerwartete Form`);
|
||||||
return null;
|
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) {
|
for (const platform of PLATFORMS) {
|
||||||
const entry = parsed.files[platform];
|
const entry = parsed.files[platform];
|
||||||
if (entry !== undefined && !isValidManifestFileEntry(entry)) {
|
if (entry !== undefined && !isValidManifestFileEntry(entry)) {
|
||||||
@@ -120,6 +173,67 @@ export class DesktopService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `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
|
* `GET /desktop/download/:platform` (D-10, T-18-01): Reihenfolge ist die
|
||||||
* Sicherheitseigenschaft -- Whitelist VOR jedem Dateisystemzugriff, der
|
* Sicherheitseigenschaft -- Whitelist VOR jedem Dateisystemzugriff, der
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export interface DesktopManifestFile {
|
|||||||
name: string;
|
name: string;
|
||||||
size: number;
|
size: number;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
|
/**
|
||||||
|
* Base64-Inhalt der `.sig`-Datei des Tauri-Bundlers (minisign), geschrieben
|
||||||
|
* von desktop-collect.sh, gelesen nur von `GET /desktop/update`. Fehlt bei
|
||||||
|
* Bauten mit `--no-sign` -- dann gibt es kein Update in der App.
|
||||||
|
*/
|
||||||
|
signature?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DesktopManifest {
|
export interface DesktopManifest {
|
||||||
@@ -37,9 +43,28 @@ export interface DesktopManifest {
|
|||||||
channel: string;
|
channel: string;
|
||||||
commit: string;
|
commit: string;
|
||||||
buildTime: string;
|
buildTime: string;
|
||||||
|
/**
|
||||||
|
* SemVer-Form, die der Updater im Client vergleicht: `X.Y.Z` bei live,
|
||||||
|
* `X.Y.Z-beta.g<sha7>` bei beta (Praefix `g` Pflicht -- ein rein numerischer
|
||||||
|
* SHA mit fuehrender Null waere kein gueltiger SemVer-Identifier).
|
||||||
|
*/
|
||||||
|
updateVersion?: string;
|
||||||
files: Partial<Record<DesktopPlatform, DesktopManifestFile>>;
|
files: Partial<Record<DesktopPlatform, DesktopManifestFile>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Antwort von `GET /desktop/update` -- das dynamische Antwortformat von
|
||||||
|
* `tauri-plugin-updater`. Die Feldnamen sind vom Plugin vorgegeben, darum
|
||||||
|
* snake_case `pub_date`. `url` muss absolut sein, `signature` ist Pflicht.
|
||||||
|
*/
|
||||||
|
export interface DesktopUpdateResponse {
|
||||||
|
version: string;
|
||||||
|
pub_date?: string;
|
||||||
|
url: string;
|
||||||
|
signature: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DesktopLatestFile extends DesktopManifestFile {
|
export interface DesktopLatestFile extends DesktopManifestFile {
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user