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