716947228e
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
150 lines
5.0 KiB
TypeScript
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}` };
|
|
}
|