feat(quick-260914-m97): Fehlermeldungen per E-Mail — Empfaenger in SmtpConfig (Migration), MailService-Anhaenge, Modul bug-reports mit Drossel, PNG-Pruefung und Mandant aus der Sitzung

- SmtpConfig.bugReportRecipient (nullable, additive Migration 20260914170000), DTO @IsOptional @IsEmail, SAFE_SELECT, getBugReportRecipient gebunden
- MailService: Versandkern deliver (wirft, Anhaenge), sendViaTenantTransport bleibt verschluckender Mantel (T-02-12), sendBugReport laesst Fehler durch
- POST /bug-reports: Multipart 4 MiB je Route, alle angemeldeten Rollen, Drossel 5/10 min -> 429, PNG-Signatur -> 400, kein Empfaenger -> 409, Versandfehler -> 502, eine Protokollzeile
- Falsifizierungen (a)-(d) als Specs; @Expose() im DTO, damit errors auch bei fehlendem Feld zu [] wird
- Doku-Zeile fuer rls-access-inventory, TESSERA_BUGREPORT_TO in docker-compose.prod.yml

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
2026-09-14 16:45:09 +02:00
parent 17a7e5ef9b
commit 54121c1721
16 changed files with 919 additions and 12 deletions
@@ -0,0 +1,178 @@
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';
/**
* 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).
*/
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<string, number[]>();
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<string>('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) as any;
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
const pageShort = dto.page.slice(0, 120);
const subject = `[Tessera Fehlermeldung] ${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()}`,
`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).
this.logger.log(
`Bug report 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())}`;
}