feat(bug-reports): Herkunft der Fehlermeldung im Betreff-Kuerzel und als Zeile Herkunft ausweisen

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016g2npLxzH5gZpg8s2S6vKh
This commit is contained in:
2026-09-18 12:32:29 +02:00
parent ab99a9ab5d
commit 716947228e
6 changed files with 411 additions and 6 deletions
@@ -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,
);
});
});
@@ -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');
});
});
@@ -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 };
}
@@ -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;
}
+130
View File
@@ -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<b>',
});
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)}`);
});
});
+149
View File
@@ -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}` };
}