import { BadRequestException, Injectable, InternalServerErrorException, Logger, NotFoundException, type OnApplicationBootstrap, } from '@nestjs/common'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user'; import { forSystem, 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), seit quick-260922-hk4 im Dateibereich statt in der * Datenbank. * * WO DIE BYTES LIEGEN (hk4): unter * `user-files/dashboard-images//.`, die Zeile * haelt nur noch den relativen Pfad in `storagePath` — dasselbe Muster wie * `User.avatarPath` (user.controller.ts) und die DKV-Ausfuhren * (dkv-export.service.ts). Grund ist die Sicherung: gesichert wird von Hand * per `pg_dump` (docs/anleitung-betrieb.md Kap. 6), und 30 Bilder à 5 MiB je * Benutzer waeren im Extremfall 150 MB pro Benutzer in jedem Abzug. Das * Volume `user-files` wird daneben gesichert. Geschwindigkeit war NICHT das * Argument (ein Bild wird je Browser einmal taeglich geladen). * * DER DATEINAME KOMMT IMMER VOM SERVER (T-HK4-01, Muster T-07-09 aus * `dkv-export.service.ts`): er ist die UUID der Zeile plus die Endung aus * dem an den Magic Bytes ERKANNTEN Mime-Typ. `originalName` ist reiner * Anzeigetext und erscheint weder im Pfad noch in einem Header (T-PI9-06). * `absoluteImagePath()` prueft zusaetzlich, dass der aus der Zeile * gelesene Pfad im Bilderverzeichnis liegt — ein Wert aus der Datenbank * wird nie ungeprueft an `path.join` gereicht. * * EIN EIGENER ORDNER JE BENUTZER IST KEIN SCHUTZ: wer welches Bild sehen * darf, entscheidet weiterhin dieser Dienst. Die Datei wird nie direkt * ausgeliefert, nur ueber `GET /dashboard/images/:id` mit Besitzpruefung * (T-HK4-02); das Volume haengt in keinem Webserver. * * HALBE ZUSTAENDE (T-HK4-04, bewusst benannt): beim Upload entsteht ZUERST * die Zeile (erst danach steht die UUID fest), dann die Datei; scheitert * das Schreiben, wird die Zeile wieder geloescht und 500 geworfen. Beim * Loeschen faellt ZUERST die Zeile, ein Fehler beim Entfernen der Datei * wird protokolliert und geschluckt — eine Dateileiche ist harmloser als * eine haengende Loeschung. Fehlt die Datei beim Lesen, ist die Antwort * 404 und die Kachel zeigt „Bild nicht verfügbar". * * Besitz: ein Bild gehoert dem hochladenden Benutzer (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. * * 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; /** Ablageort unterhalb der Monorepo-Wurzel, so wie er in der Zeile steht. */ const STORAGE_PREFIX = 'user-files/dashboard-images/'; /** Metadaten-Auswahl fuer Liste und Upload-Antwort — nie Bytes, nie Pfad. */ 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; } /** * Loest das Bilderverzeichnis relativ zur Monorepo-Wurzel auf — Muster * `resolveAvatarsDir()` (user.controller.ts): zur Laufzeit ist * `__dirname` = apps/api/dist/dashboard/, also vier Ebenen hoch. * * `DASHBOARD_IMAGES_DIR` ist ein Testschalter (Muster `DESKTOP_DIST_DIR`, * desktop.service.ts) und im Betrieb nie gesetzt; die Tests zeigen damit * auf ein Wegwerfverzeichnis unter `os.tmpdir()`, statt `fs` nachzubauen. */ export function resolveDashboardImagesDir(): string { const override = process.env.DASHBOARD_IMAGES_DIR; if (override !== undefined && override !== '') { return path.resolve(override); } return path.resolve(__dirname, '..', '..', '..', '..', 'user-files', 'dashboard-images'); } /** Endung aus dem ERKANNTEN Typ; alles andere ergibt `null`, nie eine Vermutung. */ function extensionFor(mimeType: string): string | null { switch (mimeType) { case 'image/png': return 'png'; case 'image/jpeg': return 'jpg'; case 'image/gif': return 'gif'; case 'image/webp': return 'webp'; default: return null; } } /** Relativer Pfad, wie er in der Zeile steht (`storagePath`). */ function relativeStoragePath(userId: string, id: string, extension: string): string { return `${STORAGE_PREFIX}${userId}/${id}.${extension}`; } /** * Wandelt den in der Zeile gespeicherten Pfad in einen absoluten Pfad im * Bilderverzeichnis um — und gibt `null` zurueck, sobald der Wert nicht * die erwartete Form hat oder aus dem Verzeichnis herausfuehren wuerde * (T-HK4-01). Der Aufrufer behandelt `null` wie eine fehlende Datei. */ function absoluteImagePath(storagePath: string): string | null { const normalized = storagePath.split('\\').join('/'); if (!normalized.startsWith(STORAGE_PREFIX)) return null; const base = resolveDashboardImagesDir(); const absolute = path.resolve(base, normalized.slice(STORAGE_PREFIX.length)); if (absolute !== base && !absolute.startsWith(base + path.sep)) return null; return absolute; } @Injectable() export class DashboardImagesService implements OnApplicationBootstrap { private readonly logger = new Logger(DashboardImagesService.name); constructor(private readonly prisma: PrismaService) {} /** * Einmaliger Umzug der Bestandsbilder beim Start (T-HK4-03), damit der * Betreiber nichts von Hand ausfuehren muss. * * ZWEISTUFIG, und deshalb steht die Spalte `data` noch im Schema: die * SQL-Migration 20260922120000 legt nur `storagePath` an und macht `data` * NULLbar; `migrate deploy` laeuft VOR dem Anwendungsstart, ein sofortiges * DROP haette die Bytes vernichtet, bevor dieser Umzug sie lesen konnte. * Die DROP-Migration 20260922120100 kommt erst, wenn alpha UND live * einmal mit einer Version >= dieser gelaufen sind (vorgemerkt in * `.planning/todos/pending/`). * * GELESEN WIRD SYSTEMGEBUNDEN (`forSystem()`, Muster * `DkvService.loadActiveConfigsForScheduler()`): der Umzug betrifft alle * Mandanten, ein Startpfad hat keinen Mandanten im Ruecken. Geschrieben * wird je Zeile MANDANTENGEBUNDEN (`forTenant()` mit Mandant UND Benutzer * dieser Zeile) — unter Systemkontext ist nur Lesen geoeffnet * (`system_read_policy ... FOR SELECT`, fuer `DashboardImage` angelegt in * 20260922120000). Einmal-lesen-viele-bedienen, genau wie beim * DKV-Planer. * * Wiederholbar: die Abfrage nimmt nur Zeilen ohne `storagePath`, ein * zweiter Lauf findet nichts mehr. Eine einzelne fehlgeschlagene Zeile * wird protokolliert und haelt den Start nicht auf. */ async onApplicationBootstrap(): Promise { const systemPrisma = forSystem(this.prisma); const pending = await systemPrisma.dashboardImage.findMany({ where: { storagePath: null }, select: { id: true, userId: true, tenantId: true, mimeType: true, data: true }, orderBy: { createdAt: 'asc' }, }); let moved = 0; for (const row of pending) { if (row.data === null) continue; try { const storagePath = await this.writeImageFile(row.userId, row.id, row.mimeType, row.data); const tenantPrisma = forTenant(this.prisma, row.tenantId, row.userId); await tenantPrisma.dashboardImage.update({ where: { id: row.id }, data: { storagePath }, }); moved += 1; } catch (error) { this.logger.error( `Bilderrahmen-Bild ${row.id} konnte nicht auf die Festplatte umgezogen werden: ${ error instanceof Error ? error.message : String(error) }`, ); } } if (moved > 0) { this.logger.log(`${moved} Bilderrahmen-Bilder auf die Festplatte umgezogen`); } } /** 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 — die Bytes auf der Platte, * die Zeile haelt den Pfad. * * Reihenfolge (T-HK4-04): Zeile zuerst, weil der Dateiname die UUID der * Zeile IST. Scheitert danach das Schreiben oder das Nachtragen des * Pfades, wird die Zeile wieder geloescht — lieber gar kein Bild als eine * Zeile ohne Datei. */ 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.`, ); } const created = await tenantPrisma.dashboardImage.create({ data: { userId: user.id, tenantId: user.tenantId, originalName: file.originalname.slice(0, ORIGINAL_NAME_MAX), mimeType, size: file.buffer.length, }, select: META_SELECT, }); try { const storagePath = await this.writeImageFile(user.id, created.id, mimeType, file.buffer); await tenantPrisma.dashboardImage.update({ where: { id: created.id }, data: { storagePath }, }); } catch (error) { this.logger.error( `Bilderrahmen-Bild ${created.id} konnte nicht gespeichert werden, Zeile wird zurueckgenommen: ${ error instanceof Error ? error.message : String(error) }`, ); await tenantPrisma.dashboardImage.delete({ where: { id: created.id } }); throw new InternalServerErrorException('Das Bild konnte nicht gespeichert werden.'); } return created; } /** * Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt -> * 404. Gelesen wird die Datei, nicht die Zeile — eine Zeile ohne Pfad * (noch nicht umgezogen) und eine fehlende Datei ergeben denselben 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`); } const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath); if (absolute === null) { this.logger.warn(`Bilderrahmen-Bild ${id} hat keinen gueltigen Ablageort`); throw new NotFoundException(`Image with id '${id}' not found`); } try { const data = await fs.readFile(absolute); return { mimeType: row.mimeType, data }; } catch (error) { // Selbstheilung waehrend der Umstellung (T-HK4-03): fehlt die Datei, // steckt aber noch die alte Spalte `data` in der Zeile, wird die Datei // daraus neu geschrieben und ausgeliefert. Der Fall ist real: ein // `pg_dump` aus der Zeit vor dem Umzug traegt die Bytes noch, das // Volume `user-files` wird getrennt gesichert — wer nur den Abzug // zurueckspielt, haette sonst Zeilen ohne Datei. Nach dem Entfernen der // Spalte (eigenes Todo) faellt dieser Zweig ersatzlos weg. if (row.data !== null) { try { await this.writeImageFile(row.userId, row.id, row.mimeType, row.data); this.logger.log(`Bilderrahmen-Bild ${id} aus der Datenbank wiederhergestellt`); return { mimeType: row.mimeType, data: row.data }; } catch (writeError) { this.logger.error( `Bilderrahmen-Bild ${id} konnte nicht wiederhergestellt werden: ${ writeError instanceof Error ? writeError.message : String(writeError) }`, ); } } this.logger.warn( `Bilderrahmen-Bild ${id} fehlt im Dateibereich: ${ error instanceof Error ? error.message : String(error) }`, ); throw new NotFoundException(`Image with id '${id}' not found`); } } /** * Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht. * Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird * protokolliert und geschluckt (T-HK4-04). */ 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 } }); const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath); if (absolute !== null) { try { await fs.unlink(absolute); } catch (error) { this.logger.warn( `Datei des geloeschten Bilderrahmen-Bildes ${id} konnte nicht entfernt werden: ${ error instanceof Error ? error.message : String(error) }`, ); } } return { id }; } /** * Schreibt die Bytes an den servergenerierten Ort und liefert den * relativen Pfad fuer die Zeile zurueck. Der Ordner je Benutzer entsteht * dabei (`recursive: true`). */ private async writeImageFile( userId: string, id: string, mimeType: string, bytes: Uint8Array, ): Promise { const extension = extensionFor(mimeType); if (extension === null) { throw new Error(`Unbekannter Bildtyp '${mimeType}'`); } const storagePath = relativeStoragePath(userId, id, extension); const absolute = absoluteImagePath(storagePath); if (absolute === null) { throw new Error(`Ungueltiger Ablageort fuer Bild ${id}`); } await fs.mkdir(path.dirname(absolute), { recursive: true }); await fs.writeFile(absolute, bytes); return storagePath; } }