import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { PrismaService } from '../prisma/prisma.service'; import { DASHBOARD_IMAGE_MAX_COUNT, type DashboardImageMime, detectImageMime, } from './dashboard-image-rules'; /** * DashboardImagesService — hochgeladene Bilder des Bilderrahmen-Widgets * (quick-260921-pi9). * * Ein Bild gehoert dem hochladenden Benutzer: Besitz = gleicher Mandant UND * gleicher Benutzer. Die Besitzpruefung in `getBytes`/`remove` (Zeile holen, * `userId` UND `tenantId` gegen den Sitzungsnachweis vergleichen, sonst 404) * ist NICHT dekorativ: die RLS-Regel auf `DashboardImage` (Migration * 20260921120000, mit Benutzerdimension) wirkt erst, wenn die Anwendung als * Rolle ohne Umgehungsrecht verbindet — der Schalter ist heute AUS * (docs/mandantentrennung-datenbankrolle.md). Bis dahin ist der Vergleich * hier der einzige wirksame Schutz gegen Quer-Lesen und Quer-Loeschen; die * `forTenant()`-Bindung je Methode LEGT eine Mandantengrenze obendrauf, sie * ersetzt den Vergleich nicht (Muster dashboard.service.ts). Nach dem * Scharfschalten liefert `findUnique` fuer eine fremde Zeile bereits `null` * — die Antwort bleibt 404, nur der Weg dorthin aendert sich. * * Warum 404 und nie 403 (T-PI9-04): ein 403 wuerde verraten, dass die * Kennung existiert. Kennungen sind `uuid()`, nicht erratbar. * * Warum der Typ aus den Magic Bytes kommt (T-PI9-01, T-PI9-08): * `file.mimetype` und `originalname` behauptet der Browser; gespeichert und * spaeter als `Content-Type` ausgeliefert wird ausschliesslich das, was * `detectImageMime` an den Bytes erkannt hat. Der Dateiname wird nur als * Anzeigetext gefuehrt (auf 255 Zeichen gekuerzt) und erscheint nie in * einem HTTP-Header (T-PI9-06). * * Zaehler (T-PI9-03): `count` je Mandant+Benutzer vor `create` im selben * Dienst. Zwei gleichzeitige Uploads desselben Benutzers koennen die Grenze * um wenige Bilder ueberschreiten — Restrisiko bewusst angenommen, es * betrifft nur den eigenen Speicher. * * Der Dienst ruft NIE eine Webadresse ab (T-PI9-05): URL-Eintraege des * Widgets sind fuer die API undurchsichtige Config-Werte, der Browser des * Benutzers laedt sie selbst. */ const ORIGINAL_NAME_MAX = 255; /** Metadaten-Auswahl fuer Liste und Upload-Antwort — `data` NIE dabei. */ const META_SELECT = { id: true, originalName: true, mimeType: true, size: true, createdAt: true, } as const; export interface DashboardImageMeta { id: string; originalName: string; mimeType: string; size: number; createdAt: Date; } @Injectable() export class DashboardImagesService { constructor(private readonly prisma: PrismaService) {} /** Eigene Bilder, aelteste zuerst, nur Metadaten. */ async list(userId: string, tenantId: string): Promise { const tenantPrisma = forTenant(this.prisma, tenantId, userId); return tenantPrisma.dashboardImage.findMany({ where: { tenantId, userId }, select: META_SELECT, orderBy: { createdAt: 'asc' }, }); } /** * Nimmt eine hochgeladene Datei an: Magic Bytes entscheiden, der Zaehler * begrenzt, gespeichert wird der erkannte Typ. */ async upload(user: AuthUser, file: UploadedFileLike | undefined): Promise { if (!file) { throw new BadRequestException('Bitte wählen Sie eine Bilddatei aus.'); } const mimeType: DashboardImageMime | null = detectImageMime(file.buffer); if (mimeType === null) { throw new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'); } const tenantPrisma = forTenant(this.prisma, user.tenantId, user.id); const existing = await tenantPrisma.dashboardImage.count({ where: { tenantId: user.tenantId, userId: user.id }, }); if (existing >= DASHBOARD_IMAGE_MAX_COUNT) { throw new BadRequestException( `Sie haben die Höchstzahl von ${DASHBOARD_IMAGE_MAX_COUNT} Bildern erreicht. Bitte löschen Sie zuerst ein Bild.`, ); } return tenantPrisma.dashboardImage.create({ data: { userId: user.id, tenantId: user.tenantId, originalName: file.originalname.slice(0, ORIGINAL_NAME_MAX), mimeType, size: file.buffer.length, // Befund am Typsystem (TS 5.9 + Prisma 6): `Bytes` verlangt // `Uint8Array`, multers `Buffer` ist aber ueber // `ArrayBufferLike` getypt (koennte ein SharedArrayBuffer sein) und // wird ohne Zusicherung abgelehnt. `new Uint8Array(buffer)` kopiert in // einen frischen ArrayBuffer — hoechstens 5 MiB, einmal je Upload — // und ist damit ehrlich getypt statt zugesichert. data: new Uint8Array(file.buffer), }, select: META_SELECT, }); } /** Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt -> 404. */ async getBytes( id: string, userId: string, tenantId: string, ): Promise<{ mimeType: string; data: Uint8Array }> { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const row = await tenantPrisma.dashboardImage.findUnique({ where: { id } }); if (!row || row.userId !== userId || row.tenantId !== tenantId) { throw new NotFoundException(`Image with id '${id}' not found`); } return { mimeType: row.mimeType, data: row.data }; } /** Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht. */ async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const row = await tenantPrisma.dashboardImage.findUnique({ where: { id } }); if (!row || row.userId !== userId || row.tenantId !== tenantId) { throw new NotFoundException(`Image with id '${id}' not found`); } await tenantPrisma.dashboardImage.delete({ where: { id } }); return { id }; } }