Files
tessera-ctl/apps/api/src/bug-reports/origin.ts
T
schalli 716947228e 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
2026-09-18 12:32:29 +02:00

150 lines
5.0 KiB
TypeScript

/**
* 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}` };
}