b188946e31
- 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
192 lines
8.1 KiB
TypeScript
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())}`;
|
|
}
|