import { BadGatewayException, BadRequestException, ConflictException, HttpException, HttpStatus, Injectable, Logger, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { formatAppVersionLine } from '../health/app-version'; import { MailService } from '../mail/mail.service'; 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). * * Zweck: ein angemeldeter Anwender schickt aus der Kopfzeile ein * Bildschirmfoto der aktuellen Seite samt Beschreibung und Kontext; der * Dienst baut daraus EINE E-Mail mit PNG-Anhang und verschickt sie ueber * den Transport des Sitzungs-Mandanten an das eingestellte Postfach * (`SmtpConfig.bugReportRecipient`, Rueckfall `TESSERA_BUGREPORT_TO`). * * Warum Multipart (Controller) statt JSON mit Base64: das Groessenlimit * gilt dann NUR fuer diese Route (`FileInterceptor`, 4 MiB), `main.ts` * bleibt ohne globales Body-Limit — ein globales JSON-Limit waere eine * DoS-Flaeche fuer jede Route inklusive `/auth/login` (T-M97-03). * * Warum kein Speichern: die Meldung ist eine E-Mail an den Betreiber, * nichts weiter. Tessera legt keine Tabelle dafuer an — kein Bild, keine * Beschreibung landet in der Datenbank oder im Protokoll (T-M97-01). * * Drossel-Semantik: hoechstens 5 Berichte je Benutzer je 10 Minuten, * gezaehlt im Speicher dieses Prozesses (keine Drossel-Bibliothek im * Projekt). Ein Versuch zaehlt auch dann, wenn der Versand danach * scheitert — Fehlversuche sperren nicht zusaetzlich, sie zaehlen nur. * * Sicherheit: T-M97-03 (Limit je Route + Drossel), T-M97-04 * (PNG-Signatur, fester Dateiname und Typ), T-M97-06 (Mandant und * 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; const MAX_PER_WINDOW = 5; const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); interface SessionUser { id: string; username: string; role: string; tenantId: string; } interface UploadedPng { buffer: Buffer; size: number; mimetype?: string; } @Injectable() export class BugReportsService { private readonly logger = new Logger(BugReportsService.name); /** Zeitstempel der letzten Berichte je Benutzerkennung (Drossel). */ private readonly recent = new Map(); constructor( private readonly settingsService: SettingsService, private readonly mailService: MailService, private readonly configService: ConfigService, private readonly prisma: PrismaService, ) {} async submit(user: SessionUser, dto: BugReportDto, file?: UploadedPng): Promise<{ sent: true }> { // (1) Drossel: alte Zeitstempel verwerfen, Grenze pruefen, Versuch zaehlen. const now = Date.now(); const stamps = (this.recent.get(user.id) ?? []).filter((t) => now - t < WINDOW_MS); if (stamps.length >= MAX_PER_WINDOW) { this.recent.set(user.id, stamps); throw new HttpException( 'Zu viele Fehlermeldungen in kurzer Zeit. Bitte versuchen Sie es in einigen Minuten erneut.', HttpStatus.TOO_MANY_REQUESTS, ); } stamps.push(now); this.recent.set(user.id, stamps); // (2) Bild pruefen: nur echte PNG-Dateien (T-M97-04). if (file) { if (file.buffer.length < PNG_SIGNATURE.length || !file.buffer.subarray(0, 8).equals(PNG_SIGNATURE)) { throw new BadRequestException('Das Bildschirmfoto ist keine gültige PNG-Datei.'); } } // (3) Empfaenger: Feld des Mandanten, sonst Umgebungs-Rueckfall (Leerstring = ungesetzt). const to = (await this.settingsService.getBugReportRecipient(user.tenantId)) || (this.configService.get('TESSERA_BUGREPORT_TO') || '').trim() || null; if (!to) { throw new ConflictException( 'Für Fehlermeldungen ist noch kein Postfach eingerichtet. Ein Administrator legt es unter Administrator → SMTP im Feld „Fehlermeldungen an“ fest.', ); } // (4) Benutzerzeile: gebunden an den Sitzungs-Mandanten, nie an Rumpfdaten // (T-M97-06; Zeile in docs/mandantentrennung-zugriffsklassifikation.md). const tenantPrisma = forTenant(this.prisma, user.tenantId); const row = await tenantPrisma.user.findUnique({ where: { id: user.id }, select: { username: true, displayName: true, email: true, role: true }, }); const username: string = row?.username ?? user.username; const displayName: string = row?.displayName || username; const email: string = row?.email ?? '-'; const role: string = row?.role ?? user.role; // (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] ${origin.tag} ${dto.webVersion} ${dto.webChannel} - ${pageShort}`; // (6) Text const bytes = file ? file.buffer.length : 0; const errors = dto.errors ?? []; const text = [ 'Ein Anwender hat über den Knopf „Fehler melden“ eine Meldung geschickt.', '', 'Was ist passiert?', dto.description && dto.description.trim().length > 0 ? dto.description : '(keine Beschreibung)', '', `Seite: ${dto.page}`, `Zeitpunkt (Server): ${new Date().toISOString()}`, `Zeitpunkt (Browser): ${dto.clientTime}`, `Benutzer: ${displayName} (${username}), Rolle ${role}, E-Mail ${email}`, `Mandant: ${user.tenantId}`, `Web: ${dto.webVersion} (${dto.webChannel}) ${dto.webCommit}`.trimEnd(), `API: ${formatAppVersionLine()}`, `Herkunft: ${origin.line}`, `Browser: ${dto.userAgent}`, `Fenster: ${dto.viewport}`, '', `Letzte Fehlermeldungen im Browser (${errors.length}):`, ...(errors.length > 0 ? errors.map((e) => `- ${e}`) : ['- keine']), '', file ? `Bildschirmfoto: im Anhang (${bytes} Bytes)` : 'Bildschirmfoto: nicht beigefügt', ].join('\n'); // (7) Anhang: fester Name und Typ — der Client bestimmt beides nicht (T-M97-04). const attachments = file ? [{ filename: `fehlermeldung-${formatStamp(new Date())}.png`, content: file.buffer, contentType: 'image/png' }] : []; // (8) Versand: Fehler sichtbar machen (502), nie still verschlucken. try { await this.mailService.sendBugReport(user.tenantId, to, { subject, text, attachments }); } catch (error) { this.logger.error('Bug report mail failed', error instanceof Error ? error.stack : String(error)); throw new BadGatewayException( 'E-Mail konnte nicht gesendet werden. Bitte versuchen Sie es später erneut oder wenden Sie sich an Ihren Administrator.', ); } // (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 ${origin.tag} from ${user.username} (tenant ${user.tenantId}) sent to ${to} — page ${pageShort}, screenshot ${bytes} bytes`, ); return { sent: true }; } } /** `yyyymmdd-hhmm` in UTC fuer den Anhangsnamen. */ function formatStamp(d: Date): string { const p = (n: number, w = 2) => String(n).padStart(w, '0'); return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}-${p(d.getUTCHours())}${p(d.getUTCMinutes())}`; }