From 716947228ea3b13c20b269a5e1daebf72529aa95 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 18 Sep 2026 12:32:29 +0200 Subject: [PATCH] feat(bug-reports): Herkunft der Fehlermeldung im Betreff-Kuerzel und als Zeile Herkunft ausweisen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebView2 (Windows) sieht im User-Agent aus wie Edge, WebKitGTK (Linux) wie Safari — im Postfach war eine Client-Meldung von einer Browser-Meldung nicht zu unterscheiden. Neuer reiner Helfer origin.ts leitet aus vier optionalen DTO-Feldern (Desktop-App) bzw. dem User-Agent (Browser) ein Betreff-Kuerzel und eine Zeile "Herkunft: ..." ab; rein informativ, laengenbegrenzt, nichts wird gespeichert (T-GZA-01). Browser-Pfad ist damit Ende-zu-Ende fertig. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016g2npLxzH5gZpg8s2S6vKh --- .../bug-reports.controller.spec.ts | 29 +++- .../bug-reports/bug-reports.service.spec.ts | 56 ++++++- .../src/bug-reports/bug-reports.service.ts | 19 ++- .../api/src/bug-reports/dto/bug-report.dto.ts | 34 ++++ apps/api/src/bug-reports/origin.spec.ts | 130 +++++++++++++++ apps/api/src/bug-reports/origin.ts | 149 ++++++++++++++++++ 6 files changed, 411 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/bug-reports/origin.spec.ts create mode 100644 apps/api/src/bug-reports/origin.ts diff --git a/apps/api/src/bug-reports/bug-reports.controller.spec.ts b/apps/api/src/bug-reports/bug-reports.controller.spec.ts index 164f0fb..817d7d4 100644 --- a/apps/api/src/bug-reports/bug-reports.controller.spec.ts +++ b/apps/api/src/bug-reports/bug-reports.controller.spec.ts @@ -8,7 +8,7 @@ import { BugReportDto } from './dto/bug-report.dto'; /** * BugReportsController.spec — NEU (quick-260914-m97, Fehler-melden-Knopf). * - * Drei Tests an der Grenze Browser -> API: + * Vier Tests an der Grenze Browser -> API: * 1. die globale Pipe (`whitelist: true, transform: true`, wie in * `main.ts`) entfernt Fremdfelder wie `tenantId` (T-M97-06) und * normalisiert `errors` (multer/append-field liefert EIN Feld als @@ -16,6 +16,8 @@ import { BugReportDto } from './dto/bug-report.dto'; * 2. die DTO-Grenzen greifen (31 Eintraege, 4001 Zeichen -> 400); * 3. die Route steht JEDEM angemeldeten Benutzer offen — kein * `@Roles`-Metadatum, Pfad `bug-reports`. + * 4. (quick-260918-gza) die vier neuen Client-Felder bleiben optional + * und laengenbegrenzt erhalten. */ const pipe = new ValidationPipe({ whitelist: true, transform: true }); const meta = { type: 'body' as const, metatype: BugReportDto }; @@ -63,4 +65,29 @@ describe('BugReportsController (quick-260914-m97)', () => { expect(Reflect.getMetadata(ROLES_KEY, BugReportsController.prototype.submit)).toBeUndefined(); expect(Reflect.getMetadata('path', BugReportsController)).toBe('bug-reports'); }); + + it('Test 4 (quick-260918-gza): die vier Client-Felder bleiben optional und laengenbegrenzt erhalten', async () => { + const withDesktop = (await pipe.transform( + { ...baseBody, clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: '' }, + meta, + )) as any; + expect(withDesktop.clientKind).toBe('desktop'); + expect(withDesktop.clientOs).toBe('windows'); + expect(withDesktop.clientVersion).toBe('1.2.0'); + expect(withDesktop.clientCommit).toBe(''); + + const withoutClientFields = (await pipe.transform({ ...baseBody }, meta)) as any; + expect(withoutClientFields.clientKind).toBeUndefined(); + + await expect(pipe.transform({ ...baseBody, clientKind: 'tablet' }, meta)).rejects.toThrow(BadRequestException); + await expect(pipe.transform({ ...baseBody, clientOs: 'x'.repeat(21) }, meta)).rejects.toThrow( + BadRequestException, + ); + await expect(pipe.transform({ ...baseBody, clientVersion: 'x'.repeat(41) }, meta)).rejects.toThrow( + BadRequestException, + ); + await expect(pipe.transform({ ...baseBody, clientCommit: 'x'.repeat(41) }, meta)).rejects.toThrow( + BadRequestException, + ); + }); }); diff --git a/apps/api/src/bug-reports/bug-reports.service.spec.ts b/apps/api/src/bug-reports/bug-reports.service.spec.ts index 781c3a0..f5cb814 100644 --- a/apps/api/src/bug-reports/bug-reports.service.spec.ts +++ b/apps/api/src/bug-reports/bug-reports.service.spec.ts @@ -11,7 +11,7 @@ import { BugReportsService } from './bug-reports.service'; /** * BugReportsService.spec — NEU (quick-260914-m97, Fehler-melden-Knopf). * - * Acht Tests, darunter die vier Falsifizierungen des Plans: + * Zehn Tests, darunter die vier Falsifizierungen des Plans: * (a) Drossel: der sechste Bericht in zehn Minuten -> 429, nach dem * Fenster (Fake-Timer) wieder durch; * (b) manipulierte Bilddatei ohne PNG-Kopf -> 400, nie versendet; @@ -20,6 +20,8 @@ import { BugReportsService } from './bug-reports.service'; * (d) Fremdfelder im Rumpf (tenantId/userId) aendern NICHTS an der * Mandantenkennung — Empfaenger, Benutzerzeile und Versand laufen * ausschliesslich mit der Kennung aus dem Sitzungsnachweis. + * Test 9 und 10 (quick-260918-gza): Betreff-Kuerzel und Zeile `Herkunft:` + * fuer den Desktop- und den Browser-Pfad, Protokollzeile mit Kuerzel. * * `forTenant` wird wie in `user.controller.spec.ts` durch einen gebundenen * Fake-Klienten ersetzt, der nur Zeilen des eigenen Mandanten liefert; @@ -135,7 +137,7 @@ describe('BugReportsService (quick-260914-m97)', () => { const [tenantId, to, report] = mailService.sendBugReport.mock.calls[0] as any[]; expect(tenantId).toBe('t1'); expect(to).toBe('fehler@a.example.invalid'); - expect(report.subject).toBe('[Tessera Fehlermeldung] v1.2.3 beta - /admin/users?tab=x'); + expect(report.subject).toBe('[Tessera Fehlermeldung] [Browser] v1.2.3 beta - /admin/users?tab=x'); for (const needle of [ 'Knopf tut nichts', '/admin/users?tab=x', @@ -145,6 +147,7 @@ describe('BugReportsService (quick-260914-m97)', () => { 't1', 'v1.2.3 (beta) abc1234', 'Tessera API v9.9.9 (live)', + 'Herkunft: Browser — unbekannt auf unbekannt', 'UA', '1920x1080', '[2026-09-14T09:59:00.000Z] fetch: GET /modules -> 500 {"statusCode":500}', @@ -291,4 +294,53 @@ describe('BugReportsService (quick-260914-m97)', () => { expect(report.text).not.toContain('Eindringling'); expect(report.text).not.toContain('fremd@x.invalid'); }); + + it('Test 9 (quick-260918-gza): Desktop/Windows — Betreff-Kuerzel, Zeile Herkunft VOR Browser:, Protokollzeile mit Kuerzel', async () => { + const { service, mailService } = makeService({}); + const edgeUa = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.2792.52'; + + await service.submit( + sessionUser, + { + ...baseDto, + clientKind: 'desktop', + clientOs: 'windows', + clientVersion: '1.2.0', + clientCommit: 'a6d1a64', + userAgent: edgeUa, + } as any, + undefined, + ); + + const report = (mailService.sendBugReport.mock.calls[0] as any[])[2]; + expect(report.subject.startsWith('[Tessera Fehlermeldung] [Desktop/Windows] v1.2.3 beta - ')).toBe(true); + expect(report.text).toContain('Herkunft: Desktop-App (Windows), Tessera-App 1.2.0 · Stand a6d1a64'); + expect(report.text).toContain('Browser: Mozilla/5.0 (Windows NT 10.0'); + expect(report.text).toContain('Fenster: 1920x1080'); + expect(report.text.indexOf('Herkunft:')).toBeLessThan(report.text.indexOf('Browser:')); + + const { service: freshService, mailService: freshMail } = makeService({}); + freshMail.sendBugReport.mockClear(); + (freshService as any).logger.log.mockClear(); + await freshService.submit( + sessionUser, + { ...baseDto, clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: 'a6d1a64', userAgent: edgeUa } as any, + undefined, + ); + expect((freshService as any).logger.log).toHaveBeenCalledTimes(1); + expect((freshService as any).logger.log.mock.calls[0][0]).toContain('[Desktop/Windows]'); + }); + + it('Test 10 (quick-260918-gza): Browser mit echtem User-Agent — Betreff und Zeile Herkunft nennen Browser und Version', async () => { + const { service, mailService } = makeService({}); + const chromeUa = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36'; + + await service.submit(sessionUser, { ...baseDto, clientKind: 'browser', userAgent: chromeUa } as any, undefined); + + const report = (mailService.sendBugReport.mock.calls[0] as any[])[2]; + expect(report.subject).toContain('[Browser]'); + expect(report.text).toContain('Herkunft: Browser — Chrome 129 auf Windows'); + }); }); diff --git a/apps/api/src/bug-reports/bug-reports.service.ts b/apps/api/src/bug-reports/bug-reports.service.ts index 999d13e..a11bfdb 100644 --- a/apps/api/src/bug-reports/bug-reports.service.ts +++ b/apps/api/src/bug-reports/bug-reports.service.ts @@ -14,6 +14,7 @@ import { forTenant } from '../prisma/prisma-tenant.extension'; import { PrismaService } from '../prisma/prisma.service'; import { SettingsService } from '../settings/settings.service'; import { BugReportDto } from './dto/bug-report.dto'; +import { describeOrigin } from './origin'; /** * BugReportsService — Fehler-melden-Knopf (quick-260914-m97). @@ -43,6 +44,14 @@ import { BugReportDto } from './dto/bug-report.dto'; * Benutzer ausschliesslich aus dem Sitzungsnachweis, Benutzerzeile ueber * einen gebundenen Klienten — Zeile in * docs/mandantentrennung-zugriffsklassifikation.md). + * + * Herkunft (quick-260918-gza): der Betreff traegt seit dieser Aenderung + * ein Kuerzel (`[Browser]`, `[Desktop/Windows]`, `[Desktop/Linux]`, oder + * `[Desktop]` bei einem alten Client ohne Details), damit sich das + * Postfach danach sortieren laesst — WebView2 sieht sonst aus wie Edge, + * WebKitGTK wie Safari. Der rohe User-Agent bleibt in der Zeile + * `Browser:` unveraendert erhalten, die Ableitung selbst steht in + * `origin.ts` (dort auch T-GZA-01: rein informativ, laengenbegrenzt). */ const WINDOW_MS = 10 * 60 * 1000; @@ -119,9 +128,10 @@ export class BugReportsService { const email: string = row?.email ?? '-'; const role: string = row?.role ?? user.role; - // (5) Betreff + // (5) Betreff — Herkunfts-Kuerzel direkt nach dem festen Praefix (quick-260918-gza). + const origin = describeOrigin(dto); const pageShort = dto.page.slice(0, 120); - const subject = `[Tessera Fehlermeldung] ${dto.webVersion} ${dto.webChannel} - ${pageShort}`; + const subject = `[Tessera Fehlermeldung] ${origin.tag} ${dto.webVersion} ${dto.webChannel} - ${pageShort}`; // (6) Text const bytes = file ? file.buffer.length : 0; @@ -139,6 +149,7 @@ export class BugReportsService { `Mandant: ${user.tenantId}`, `Web: ${dto.webVersion} (${dto.webChannel}) ${dto.webCommit}`.trimEnd(), `API: ${formatAppVersionLine()}`, + `Herkunft: ${origin.line}`, `Browser: ${dto.userAgent}`, `Fenster: ${dto.viewport}`, '', @@ -164,8 +175,10 @@ export class BugReportsService { } // (9) Genau eine Protokollzeile — nie Beschreibung, nie Bild (T-M97-07). + // Das Kuerzel ist ein aufgezaehlter Wert aus origin.ts, nie ein roher + // Client-String — deshalb protokollierbar (T-GZA-01). this.logger.log( - `Bug report from ${user.username} (tenant ${user.tenantId}) sent to ${to} — page ${pageShort}, screenshot ${bytes} bytes`, + `Bug report ${origin.tag} from ${user.username} (tenant ${user.tenantId}) sent to ${to} — page ${pageShort}, screenshot ${bytes} bytes`, ); return { sent: true }; } diff --git a/apps/api/src/bug-reports/dto/bug-report.dto.ts b/apps/api/src/bug-reports/dto/bug-report.dto.ts index 83e8875..0a90f91 100644 --- a/apps/api/src/bug-reports/dto/bug-report.dto.ts +++ b/apps/api/src/bug-reports/dto/bug-report.dto.ts @@ -2,6 +2,7 @@ import { Expose, Transform } from 'class-transformer'; import { ArrayMaxSize, IsArray, + IsIn, IsOptional, IsString, MaxLength, @@ -21,6 +22,16 @@ import { * Dienst nimmt beides ausschliesslich aus dem Sitzungsnachweis * (`@CurrentUser()`), und `whitelist: true` der globalen ValidationPipe * entfernt jedes Fremdfeld, das ein Client hier trotzdem mitschickt. + * + * Vier weitere Felder (`clientKind`, `clientOs`, `clientVersion`, + * `clientCommit`) kommen seit quick-260918-gza vom Web-Client dazu — im + * Browser als `clientKind=browser` mit leeren uebrigen Feldern, in der + * Desktop-App aus dem Cookie `tessera_desktop_client`. Sie sind optional, + * damit aeltere Web-Baue weiterhin gueltig senden (der Dienst faellt ohne + * sie auf `browser` zurueck); `whitelist: true` verlangt ihre Deklaration + * hier, sonst wuerde die Pipe sie stillschweigend entfernen. Sie dienen + * ausschliesslich der Anzeige in der Mail und sind laengenbegrenzt + * (T-GZA-01) — siehe `origin.ts`. */ export class BugReportDto { /** Freitext „Was ist passiert?“ — optional, hoechstens 4000 Zeichen. */ @@ -77,4 +88,27 @@ export class BugReportDto { @IsString({ each: true }) @MaxLength(1000, { each: true }) errors!: string[]; + + /** `desktop` oder `browser` — vom Web-Client gesetzt, siehe Kopfkommentar. */ + @IsOptional() + @IsIn(['desktop', 'browser']) + clientKind?: 'desktop' | 'browser'; + + /** Betriebssystem der Desktop-App (`windows`, `linux`, `macos`, …), unbeglaubigt. */ + @IsOptional() + @IsString() + @MaxLength(20) + clientOs?: string; + + /** Version der Desktop-App (`CARGO_PKG_VERSION`), unbeglaubigt. */ + @IsOptional() + @IsString() + @MaxLength(40) + clientVersion?: string; + + /** Commit-Stempel der Desktop-App (`APP_COMMIT`), darf leer sein. */ + @IsOptional() + @IsString() + @MaxLength(40) + clientCommit?: string; } diff --git a/apps/api/src/bug-reports/origin.spec.ts b/apps/api/src/bug-reports/origin.spec.ts new file mode 100644 index 0000000..3a9fa4c --- /dev/null +++ b/apps/api/src/bug-reports/origin.spec.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; +import { describeOrigin, parseUserAgent } from './origin'; + +/** + * origin.spec.ts — NEU (quick-260918-gza, Herkunft einer Fehlermeldung). + * + * Zehn Tests fuer die beiden reinen Funktionen aus origin.ts: + * 1-5 `parseUserAgent` fuer Desktop- und Mobil-Browser (Reihenfolge der + * Muster: Edge/Opera vor Chrome, Safari nur ohne Chrome-Token, + * Android/iOS vor Linux/macOS); + * 6-8 `describeOrigin` im Desktop-Fall (mit/ohne Commit, ganz ohne + * Details — alter Client); + * 9 Rueckfall auf den Browser-Pfad, wenn alle vier Client-Felder + * fehlen (alter Web-Bau); + * 10 Bereinigung: kein Zeilenumbruch, kein Markup, hoechstens 40 + * Zeichen je Wert (T-GZA-01). + */ + +const EDGE_WINDOWS_UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.2792.52'; +const CHROME_WINDOWS_UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36'; +const FIREFOX_LINUX_UA = 'Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0'; +const SAFARI_MACOS_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15'; +const ANDROID_CHROME_UA = + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36'; +const IPAD_SAFARI_UA = + 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'; +const OPERA_UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 OPR/114.0.0.0'; +const WEBKITGTK_LINUX_UA = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'; + +describe('origin (quick-260918-gza)', () => { + it('Test 1: Edge auf Windows — Edge gewinnt vor Chrome', () => { + expect(parseUserAgent(EDGE_WINDOWS_UA)).toEqual({ browser: 'Edge 129', os: 'Windows' }); + expect(describeOrigin({ clientKind: 'browser', userAgent: EDGE_WINDOWS_UA })).toEqual({ + tag: '[Browser]', + line: 'Browser — Edge 129 auf Windows', + }); + }); + + it('Test 2: derselbe UA ohne Edg/ — Chrome auf Windows', () => { + expect(parseUserAgent(CHROME_WINDOWS_UA)).toEqual({ browser: 'Chrome 129', os: 'Windows' }); + }); + + it('Test 3: Firefox auf Linux', () => { + expect(parseUserAgent(FIREFOX_LINUX_UA)).toEqual({ browser: 'Firefox 130', os: 'Linux' }); + expect(describeOrigin({ userAgent: FIREFOX_LINUX_UA }).line).toBe('Browser — Firefox 130 auf Linux'); + }); + + it('Test 4: Safari auf macOS — Version aus Version/, nur ohne Chrome-Token', () => { + expect(parseUserAgent(SAFARI_MACOS_UA)).toEqual({ browser: 'Safari 17', os: 'macOS' }); + }); + + it('Test 5: Mobil — Android statt Linux, iOS statt macOS, Opera erkannt', () => { + expect(parseUserAgent(ANDROID_CHROME_UA).os).toBe('Android'); + const ipad = parseUserAgent(IPAD_SAFARI_UA); + expect(ipad.os).toBe('iOS'); + expect(ipad.browser).toBe('Safari 17'); + expect(parseUserAgent(OPERA_UA).browser).toBe('Opera 114'); + }); + + it('Test 6: WebKitGTK-Client als Desktop — der User-Agent spielt keine Rolle', () => { + expect( + describeOrigin({ + userAgent: WEBKITGTK_LINUX_UA, + clientKind: 'desktop', + clientOs: 'linux', + clientVersion: '1.2.0', + clientCommit: 'a6d1a64', + }), + ).toEqual({ + tag: '[Desktop/Linux]', + line: 'Desktop-App (Linux), Tessera-App 1.2.0 · Stand a6d1a64', + }); + }); + + it('Test 7: Desktop ohne Commit — kein "· Stand"; Leerraum wird wie ein leerer Commit behandelt', () => { + expect( + describeOrigin({ clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: '' }), + ).toEqual({ tag: '[Desktop/Windows]', line: 'Desktop-App (Windows), Tessera-App 1.2.0' }); + + expect( + describeOrigin({ clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: ' ' }), + ).toEqual({ tag: '[Desktop/Windows]', line: 'Desktop-App (Windows), Tessera-App 1.2.0' }); + }); + + it('Test 8: Desktop ohne Details (alter Client, Cookie fehlt) — unbekanntes Betriebssystem ebenfalls', () => { + expect( + describeOrigin({ clientKind: 'desktop', clientOs: '', clientVersion: '', clientCommit: '' }), + ).toEqual({ tag: '[Desktop]', line: 'Desktop-App (unbekannt)' }); + + expect( + describeOrigin({ clientKind: 'desktop', clientOs: 'freebsd', clientVersion: '', clientCommit: '' }), + ).toEqual({ tag: '[Desktop]', line: 'Desktop-App (unbekannt)' }); + }); + + it('Test 9: Rueckfall auf den Browser-Pfad, wenn alle vier Client-Felder fehlen (alter Web-Bau)', () => { + expect(describeOrigin({ userAgent: 'UA' })).toEqual({ + tag: '[Browser]', + line: 'Browser — unbekannt auf unbekannt', + }); + expect(describeOrigin({ clientKind: 'browser', userAgent: '' })).toEqual({ + tag: '[Browser]', + line: 'Browser — unbekannt auf unbekannt', + }); + }); + + it('Test 10: Bereinigung — kein Zeilenumbruch, kein Markup, hoechstens 40 Zeichen je Wert (T-GZA-01)', () => { + const origin = describeOrigin({ + clientKind: 'desktop', + clientOs: 'windows', + clientVersion: '1.2.0\nBenutzer: admin', + clientCommit: 'a6d1a64', + }); + expect(origin.line).not.toMatch(/[\n\r<>]/); + expect(origin.line).toBe('Desktop-App (Windows), Tessera-App 1.2.0Benutzeradmin · Stand a6d1a64b'); + + const longValue = 'x'.repeat(60); + const truncated = describeOrigin({ + clientKind: 'desktop', + clientOs: 'linux', + clientVersion: longValue, + clientCommit: longValue, + }); + expect(truncated.line).toBe(`Desktop-App (Linux), Tessera-App ${'x'.repeat(40)} · Stand ${'x'.repeat(40)}`); + }); +}); diff --git a/apps/api/src/bug-reports/origin.ts b/apps/api/src/bug-reports/origin.ts new file mode 100644 index 0000000..266f2a8 --- /dev/null +++ b/apps/api/src/bug-reports/origin.ts @@ -0,0 +1,149 @@ +/** + * origin.ts — Herkunft einer Fehlermeldung ausweisen (quick-260918-gza). + * + * Zweck: WebView2 (Windows-Desktop-App) sieht im User-Agent aus wie Edge, + * WebKitGTK (Linux-Desktop-App) wie Safari — im Postfach des Betreibers ist + * damit nicht erkennbar, ob eine Meldung aus einem Client oder einem + * echten Browser stammt. `describeOrigin()` leitet aus den vier optionalen + * Client-Feldern (gesetzt durch die Desktop-App ueber das Cookie + * `tessera_desktop_client`) und, im Browser-Fall, aus dem User-Agent ein + * kurzes Betreff-Kuerzel und eine Textzeile ab. + * + * Trust-Modell (T-GZA-01): alle Eingaben stammen unbeglaubigt vom Client, + * wie der bestehende User-Agent auch. Die Ableitung ist rein informativ — + * sie entscheidet nie ueber Routing, Berechtigung oder Speicherung, und + * nichts davon wird in der Datenbank abgelegt. `clean()` laesst nur ein + * begrenztes Alphabet und hoechstens 40 Zeichen zu, damit weder ein + * Zeilenumbruch noch Markup in die E-Mail gelangt; das Betriebssystem wird + * ausserdem auf eine feste Menge von Labels abgebildet. + * + * Warum Regex statt Bibliothek: fuenf Browser und fuenf Betriebssysteme + * reichen aus, um ein Postfach sinnvoll zu sortieren — ein neues Paket + * (etwa ua-parser-js) waere fuer diesen Umfang unverhaeltnismaessig. + */ + +export type ClientKind = 'desktop' | 'browser'; + +export interface OriginInput { + clientKind?: string; + clientOs?: string; + clientVersion?: string; + clientCommit?: string; + userAgent?: string; +} + +export interface Origin { + /** Betreff-Kuerzel in eckigen Klammern, z. B. `[Browser]`, `[Desktop/Windows]`. */ + tag: string; + /** Text NACH dem Label `Herkunft: ` — der Dienst setzt das Label davor. */ + line: string; +} + +export interface ParsedUserAgent { + browser: string; + os: string; +} + +const UNKNOWN = 'unbekannt'; + +/** + * Entfernt alles ausser `[A-Za-z0-9.+_-]` und kuerzt auf `max` Zeichen + * (Vorgabe 40) — kein Zeilenumbruch, kein Markup in Betreff oder Text + * (T-GZA-01). + */ +function clean(value: unknown, max = 40): string { + return String(value ?? '') + .replace(/[^A-Za-z0-9.+_-]/g, '') + .slice(0, max); +} + +/** + * Leitet Browsername samt Hauptversion und Betriebssystem aus einem + * User-Agent ab. Reihenfolge ist entscheidend: Android-UAs enthalten + * `Linux`, iPad-UAs enthalten `like Mac OS X`, Edge- und Opera-UAs + * enthalten sowohl `Chrome/` als auch `Safari/` — die spezifischeren + * Muster muessen deshalb zuerst gewinnen. + */ +export function parseUserAgent(ua: string): ParsedUserAgent { + const value = ua ?? ''; + + let browser = UNKNOWN; + const edg = value.match(/Edg\/(\d+)/); + const opr = value.match(/OPR\/(\d+)/); + const firefox = value.match(/Firefox\/(\d+)/); + const chrome = value.match(/(?:Chrome|CriOS)\/(\d+)/); + const hasSafariToken = /Safari\//.test(value); + const hasChromeToken = /Chrome\//.test(value); + + if (edg) { + browser = `Edge ${edg[1]}`; + } else if (opr) { + browser = `Opera ${opr[1]}`; + } else if (firefox) { + browser = `Firefox ${firefox[1]}`; + } else if (chrome) { + browser = `Chrome ${chrome[1]}`; + } else if (hasSafariToken && !hasChromeToken) { + const version = value.match(/Version\/(\d+)/); + browser = version ? `Safari ${version[1]}` : 'Safari'; + } + + let os = UNKNOWN; + if (/Windows NT/.test(value)) { + os = 'Windows'; + } else if (/Android/.test(value)) { + os = 'Android'; + } else if (/iPhone|iPad|iPod/.test(value)) { + os = 'iOS'; + } else if (/Mac OS X|Macintosh/.test(value)) { + os = 'macOS'; + } else if (/Linux|X11/.test(value)) { + os = 'Linux'; + } + + return { browser, os }; +} + +/** Bildet `clean(clientOs)` auf eines von drei festen Labels ab. */ +function desktopOsLabel(clientOs: string | undefined): string { + switch (clean(clientOs).toLowerCase()) { + case 'windows': + return 'Windows'; + case 'linux': + return 'Linux'; + case 'macos': + return 'macOS'; + default: + return UNKNOWN; + } +} + +/** + * Leitet Betreff-Kuerzel und Herkunftszeile ab. Desktop-Fall: der + * User-Agent spielt keine Rolle, nur die vier Client-Felder zaehlen + * (dieselbe Regel wie `client_info_label` im Rust-Client — Version und + * Commit werden nur zusammen mit " · Stand " verbunden, ein leerer + * Commit faellt weg). Alles andere (auch fehlende Felder, alter + * Web-Bau) faellt auf den Browser-Pfad mit `parseUserAgent` zurueck. + */ +export function describeOrigin(input: OriginInput): Origin { + if (input.clientKind === 'desktop') { + const osLabel = desktopOsLabel(input.clientOs); + const version = clean(input.clientVersion); + const commit = clean(input.clientCommit); + + let appLabel = ''; + if (version && commit) { + appLabel = `Tessera-App ${version} · Stand ${commit}`; + } else if (version) { + appLabel = `Tessera-App ${version}`; + } + + const line = appLabel ? `Desktop-App (${osLabel}), ${appLabel}` : `Desktop-App (${osLabel})`; + const tag = osLabel === UNKNOWN ? '[Desktop]' : `[Desktop/${osLabel}]`; + return { tag, line }; + } + + const { browser, os } = parseUserAgent(input.userAgent ?? ''); + return { tag: '[Browser]', line: `Browser — ${browser} auf ${os}` }; +}