Files
tessera-ctl/apps/api/src/bug-reports/bug-reports.service.ts
T
schalli b188946e31 refactor(quick-260921-m34): Aufgabe 1 - Mandantenbindung entzaubert, 105 unnoetige any-Zusicherungen entfernt
- prisma-tenant.extension.ts: (prisma as any) und die Handannotation an
  $allOperations in forTenant()/forSystem() entfernt; Kopfkommentar
  unveraendert. .then((results: any[]) => ...) auf unknown[] umgestellt.
- 105 Aufrufstellen `const X = forTenant(...) as any` / `forSystem(...) as
  any` von der Zusicherung befreit, Zuweisungsform woertlich erhalten
  (rls-access-inventory.spec.ts bleibt scharf, 30/30 gruen einzeln
  geprueft).
- withTenantTransaction(): Prisma.TransactionClient fuer tx probiert,
  gemessen verworfen - bricht das Testdoppel in
  prisma-tenant.extension.spec.ts (TS2322 auf einem absichtlich
  unvollstaendigen Fake-Objekt). tx bleibt any, mit Begruendung am Typ.
- Gefolge des jetzt getypten Klienten entfernt: any[]-Annotationen und
  .map((x: any) => ...) in groups.service.ts, module-grants.service.ts,
  dkv.service.ts, ldap-config.service.ts, tenders.controller.ts:270.
- Befund (D-03): tender-matching.service.ts:159 trug eine Handannotation
  (match: { tender: unknown }), die den Wert nur deshalb auf unknown
  verengte, um TS7006 unter dem alten any-Klienten zu vermeiden - mit dem
  getypten Klienten war das falsch. Annotation geloescht, kein Ersatz
  durch Zusicherung.
- Zwei any bleiben gezielt in groups.service.ts (u/a in
  ensureDefaultGroup(), gefolge von tx: any) - Begruendung am Code.

noExplicitAny apps/api/src: 288 -> 149 (Schranke 155). type-check 4/4,
lint 5/5 (0 error). apps/api 72/1143 gruen, apps/web 73/531 gruen,
rls-access-inventory.spec.ts 30/30 gruen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 16:19:16 +02:00

192 lines
8.1 KiB
TypeScript

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<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);
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())}`;
}