import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { BadRequestException, HttpException, HttpStatus, Injectable, InternalServerErrorException, Logger, NotFoundException, PayloadTooLargeException, } from '@nestjs/common'; import type { UploadedFileLike } from '../auth/types/auth-user'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension'; import { CreateFavoriteDto } from './dto/create-favorite.dto'; import { ReorderFavoritesDto } from './dto/reorder-favorites.dto'; import { UpdateFavoriteDto } from './dto/update-favorite.dto'; import { FAVORITE_ICON_MAX_BYTES, detectFavoriteIconMime, favoriteIconAbsolutePath, } from './favorite-icon-files'; import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service'; /** * Service for managing per-user, per-widget favorite links. * * Mandantengebunden (260911-gwh): jede Methode nimmt `tenantId` als ERSTEN * Parameter und laeuft ueber GENAU EINEN Klienten `tenantPrisma` — der * Mandant kommt aus `extractContext` im Controller, DERSELBEN Quelle wie * `dashboard.controller.ts` (nicht dem auth-Praezedenzfall/Claim): der Link * haengt ueber `widgetId` an `WidgetInstance`, und `WidgetInstance` ist * unter der dashboard-Mandantenquelle gebunden. Eine abweichende Quelle * wuerde Widget und Link unter einem `x-tenant-id`-Wechsel eines * SUPER_ADMIN in verschiedenen Mandanten auseinanderreissen. * * Die Regel auf `FavoriteLink` trug bei der Messung 260911-gwh (Aufgabe 1, * Pruefung 4) KEINE Benutzerdimension — dieselbe Lehre wie `CalendarSource`/ * `DashboardLayout`/`WidgetInstance`. Nachtrag (260911-nke, Etappe 3b): seit * Migration 20260911120000 traegt die Regel auf `FavoriteLink` die * Benutzerdimension (`current_user_id() IS NULL OR "userId" = current_user_id()`) * — jeder `forTenant()`-Aufruf unten reicht `userId` als drittes Argument * durch. Die `userId`-Filter unten bleiben trotzdem UNVERAENDERT bestehen: * zweites Netz, kein Ersatz — ein Aufrufer, der `userId` vergisst, saehe * ohne sie den ganzen Mandanten (siehe .planning/WINDOWS.md). * * Access control (T-08-06 / Pitfall 3): * - Every query is scoped by userId (prevents cross-user access). * - list() additionally scopes by widgetId so each widget instance has its own set. * - update() and remove() verify userId ownership before mutating. * - reorder() runs as one withTenantTransaction() (T-JDD-03) and scopes * every updateMany by userId AND widgetId (see reorder() doc below). * * `create()` prueft zusaetzlich, dass das Ziel-Widget dem Aufrufer gehoert * (T-GWH-05): der Fremdschluessel `FavoriteLink.widgetId` prueft an der * Zeilenschutz-Regel von `WidgetInstance` VORBEI (dokumentiertes * PostgreSQL-Verhalten, gemessen in Aufgabe 1, Pruefung 7) — ohne den * Riegel waere der Unterschied zwischen "Widget existiert nicht" (500) und * "gehoert einem fremden Mandanten" (gelingt) ein Existenzorakel ueber * Mandantengrenzen. Der Riegel antwortet fuer alle drei Faelle * ("existiert nicht", "gehoert einem Kollegen", "liegt bei einem fremden * Mandanten") mit derselben `NotFoundException('Widget not found')`. * * 260923-lrr — eigenes Symbol, Vorrang, Versionszaehler, Abrufprobe: * - Ablage nach dem Muster `dashboard-images.service.ts` (quick-260922-hk4): * `user-files/favorite-icons//.`, Dateiname IMMER aus * Zeilen-UUID und ERKANNTEM Typ, nie aus der Anfrage (T-LRR-01). * - Vorrang: `getIconBytes` liefert bei gesetztem `uploadedIconMime` immer * die Datei, nie `fetchIconBytes` — fehlt die Datei trotz gesetztem Typ, * wird protokolliert und auf `iconUrl` zurueckgefallen. * - `iconVersion` steigt (Prisma `{ increment: 1 }`) genau dann, wenn sich * die angezeigte Quelle aendert (neue, abweichende `iconUrl`; Upload; * Entfernen des Uploads) — nicht bei Titel/Position/unveraenderter URL. * - Halbe Zustaende (T-LRR-08, Muster T-HK4-04): Upload schreibt zuerst die * Datei, dann die Zeile; scheitert die Zeile, wird die neue Datei wieder * entfernt. Entfernen/Loeschen aktualisiert zuerst die Zeile, ein * Dateifehler wird protokolliert und geschluckt. * - 260929-lh3 (loest die Abrufprobe von 260923-lrr ab): eine ausdrueckliche * Logo-Adresse wird nur auf Form (http/https, <= 2048 Zeichen) geprueft und * auch gespeichert, wenn der Server sie nicht abrufen kann — der Browser der * Kachel laedt sie dann direkt. Ein hochgeladenes Symbol wird von einer * neuen, abweichenden Adresse verdraengt (Vorrang der Datei sonst: Adresse * gespeichert, aber unsichtbar). */ /** Hoechstlaenge einer ausdruecklichen Logo-Adresse (260929-lh3). */ const ICON_URL_MAX_LENGTH = 2048; @Injectable() export class FavoritesService { private readonly logger = new Logger(FavoritesService.name); constructor( private readonly prisma: PrismaService, private readonly iconDiscovery: IconDiscoveryService, ) {} /** * Returns all favorites for a user's widget instance, ordered by position asc. * Scoped by userId AND widgetId (Pitfall 3 — separate widgets must not share links). */ async list(tenantId: string, userId: string, widgetId: string) { if (!widgetId) throw new BadRequestException('widgetId is required'); const tenantPrisma = forTenant(this.prisma, tenantId, userId); return tenantPrisma.favoriteLink.findMany({ where: { userId, widgetId }, orderBy: [{ position: 'asc' }, { title: 'asc' }], }); } /** * Prueft eine ausdruecklich eingetragene Logo-Adresse NUR auf Form (260929-lh3): * gueltige http/https-Adresse, hoechstens 2048 Zeichen. Bewusst KEIN * serverseitiger Abruf mehr — Server wie docuvita liefern dem Server ein * 404/HTML, dem Browser aber das Bild; die fruehere Abrufprobe (422, * 260923-lrr) machte genau diese Adressen unspeicherbar. Entscheidung: auch * eine Antwort, die der Server sieht und die kein Bild ist, weist NICHT ab — * "Server bekommt kein Bild" heisst nicht "Browser bekommt keins", und der * Server kann beides nicht unterscheiden. Der SSRF-Schutz bleibt unveraendert * dort, wo der Server tatsaechlich abruft (`getIconBytes`/Erkennung); scheitert * der Proxy, laedt die Kachel die Adresse direkt im Browser. */ private assertIconUrlWellFormed(iconUrl: string): void { let parsed: URL | null = null; try { parsed = new URL(iconUrl); } catch { parsed = null; } if ( parsed === null || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || iconUrl.length > ICON_URL_MAX_LENGTH ) { throw new BadRequestException( 'Die Logo-Adresse muss eine gültige http- oder https-Adresse sein (höchstens 2048 Zeichen).', ); } } /** * Creates a new favorite link. * Verifies the target widget belongs to the caller BEFORE any icon * discovery network call (T-GWH-05). * If iconUrl is not provided, triggers server-side icon discovery with SSRF protection. * If iconUrl IS provided it is stored as given after a form check only * (260929-lh3, see assertIconUrlWellFormed) — no server-side fetch. */ async create(tenantId: string, userId: string, dto: CreateFavoriteDto) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); // T-GWH-05: der Fremdschluessel prueft an der Zeilenschutz-Regel von // WidgetInstance vorbei (Aufgabe 1, Pruefung 7) — ohne diesen Riegel // wuerde ein gebundenes create mit einer fremdmandantigen widgetId // gelingen. Eine Antwort fuer alle drei Faelle: existiert nicht, // gehoert einem Kollegen, liegt bei einem fremden Mandanten. const widget = await tenantPrisma.widgetInstance.findUnique({ where: { id: dto.widgetId }, select: { userId: true }, }); if (!widget || widget.userId !== userId) { throw new NotFoundException('Widget not found'); } // Normalize so a scheme-less entry like "ctl.de" is stored (and discovered) // as "https://ctl.de" — otherwise the link and icon discovery both break. const url = normalizeUrl(dto.url); let iconUrl = dto.iconUrl ?? null; if (iconUrl) { // 260929-lh3: nur Formpruefung, kein serverseitiger Abruf. this.assertIconUrlWellFormed(iconUrl); } else { // Server-side icon discovery (D-05) — only when caller did not supply an icon iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url); } return tenantPrisma.favoriteLink.create({ data: { userId, tenantId, widgetId: dto.widgetId, title: dto.title, url, iconUrl, position: dto.position ?? 0, }, }); } /** * Updates an existing favorite. * Verifies userId ownership before applying changes (T-08-06). * Accepts null as an explicit value for iconUrl (clears stored icon). * * 260929-lh3: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl` * durchlaeuft nur die Formpruefung (`assertIconUrlWellFormed`), bevor * irgendetwas geschrieben wird; sie wird auch gespeichert, wenn der Server * sie nicht abrufen kann. Jede tatsaechliche Aenderung der Symbolquelle * erhoeht `iconVersion`. */ async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } }); if (!link || link.userId !== userId) { throw new NotFoundException('FavoriteLink not found'); } const data: Record = {}; if (dto.title !== undefined) data.title = dto.title; const normalizedUrl = dto.url !== undefined ? normalizeUrl(dto.url) : undefined; if (normalizedUrl !== undefined) data.url = normalizedUrl; if (dto.position !== undefined) data.position = dto.position; if ('iconUrl' in dto) { if (dto.iconUrl) { if (dto.iconUrl !== link.iconUrl) { // 260929-lh3: nur eine NEUE, abweichende Adresse wird geprueft (Form). this.assertIconUrlWellFormed(dto.iconUrl); } // Explicit icon URL supplied — respect it as-is. data.iconUrl = dto.iconUrl; } else { // Icon cleared (empty/null) — re-run discovery against the effective // (new or existing) url so editing a broken favorite repairs its icon. const effectiveUrl = normalizedUrl ?? link.url; data.iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(effectiveUrl); } } // 260929-lh3: eine NEUE, ausdruecklich eingetragene Logo-Adresse muss // Vorrang vor einem frueher hochgeladenen Symbol haben. `getIconBytes` // liefert bei gesetztem `uploadedIconMime` IMMER die Datei — ohne diesen // Schritt blieb die neue Adresse gespeichert, aber unsichtbar (die Kachel // zeigte weiter das alte hochgeladene Bild). Nur bei einer tatsaechlichen // Aenderung: das Formular schickt die unveraenderte Adresse bei jedem // Speichern mit, das darf ein hochgeladenes Symbol nicht verdraengen. const iconUrlChanged = data.iconUrl !== undefined && data.iconUrl !== link.iconUrl; const explicitUrlReplacesUpload = iconUrlChanged && Boolean(dto.iconUrl) && link.uploadedIconMime !== null; if (explicitUrlReplacesUpload) { data.uploadedIconMime = null; } if (iconUrlChanged) { data.iconVersion = { increment: 1 }; } const updated = await tenantPrisma.favoriteLink.update({ where: { id }, data, }); if (explicitUrlReplacesUpload && link.uploadedIconMime !== null) { await this.removeIconFile(id, link.userId, link.uploadedIconMime, 'ersetzte'); } return updated; } /** * Deletes a favorite link. * Verifies userId ownership before deleting (T-08-06). * 260923-lrr: hat die Zeile ein hochgeladenes Symbol, wird dessen Datei * NACH dem Loeschen der Zeile entfernt — ein Dateifehler wird * protokolliert und geschluckt (Muster T-HK4-04), das Loeschen der Zeile * gelingt in jedem Fall. */ async remove(tenantId: string, id: string, userId: string) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } }); if (!link || link.userId !== userId) { throw new NotFoundException('FavoriteLink not found'); } await tenantPrisma.favoriteLink.delete({ where: { id } }); if (link.uploadedIconMime !== null) { await this.removeIconFile(id, link.userId, link.uploadedIconMime, 'geloeschten'); } } /** * Persists the display order of a user's favorites for one widget * instance (260917-jdd, PUT /favorites/order). * * Laeuft als EINE Transaktion ueber `withTenantTransaction()` — die * einzige gemessene atomare Form fuer einen Mehrschritt-Zugriff * (prisma-tenant.extension.ts Z. 33-49/104-109); die Array-Form von * `$transaction` auf einem mit `forTenant()` gebundenen Klienten ist * gemessen NICHT atomar, die interaktive Form auf dem gebundenen Klienten * faellt unter Last aus (siehe dortige Messung). `withTenantTransaction()` * setzt KEINE Benutzerdimension in der Sitzung (nur `app.current_tenant`) * — die Regel auf `FavoriteLink` faellt deshalb in ihren `IS NULL`-Zweig * und zeigt den ganzen Mandanten. Darum traegt JEDE Bedingung unten * `userId` UND `widgetId` selbst (zweites Netz, wie der Kopfkommentar * dieser Klasse es fuer alle Methoden vorsieht). * * `updateMany` statt `update({ where: { id } })`, weil `update` nur nach * `id` filtern koennte — der Ownership-Check muesste dann als separater * Lese-Schritt VOR dem Schreiben stehen, mit derselben TOCTOU-Luecke wie * ein fehlendes zweites Netz. `updateMany` traegt die Bedingung direkt in * der Schreiboperation und liefert `count`, das sofort geprueft wird. * * Existenzorakel-Vermeidung (T-JDD-06): EINE BadRequestException mit * DERSELBEN Meldung fuer fremde id, unbekannte id, Teilmenge sowie * fremdes/unbekanntes Widget oder Mandant — kein Fall verraet, welcher * Grund zutraf (Muster T-GWH-05). * * Altbestand: alle Zeilen mit `position = 0` (vor diesem Plan gab es * keine Sortierung) normalisiert sich beim ERSTEN Aufruf zu `0..n-1` — * kein Migrations- oder Sonderpfad noetig. */ async reorder(tenantId: string, userId: string, dto: ReorderFavoritesDto) { if (new Set(dto.ids).size !== dto.ids.length) { throw new BadRequestException('ids must match the favorites of this widget exactly'); } return withTenantTransaction(this.prisma, tenantId, async (tx) => { const existing = await tx.favoriteLink.findMany({ where: { userId, widgetId: dto.widgetId }, select: { id: true }, }); const existingIds = new Set(existing.map((r: { id: string }) => r.id)); if ( existing.length !== dto.ids.length || dto.ids.some((id) => !existingIds.has(id)) ) { throw new BadRequestException('ids must match the favorites of this widget exactly'); } for (const [index, id] of dto.ids.entries()) { const { count } = await tx.favoriteLink.updateMany({ where: { id, userId, widgetId: dto.widgetId }, data: { position: index }, }); if (count !== 1) { throw new BadRequestException('ids must match the favorites of this widget exactly'); } } return tx.favoriteLink.findMany({ where: { userId, widgetId: dto.widgetId }, orderBy: [{ position: 'asc' }, { title: 'asc' }], }); }); } /** * Nimmt ein eigenes Symbol fuer einen Favoriten an (260923-lrr). Reihenfolge * (Muster T-HK4-04): Groesse/Typ zuerst (kein DB-Zugriff bei offensichtlich * ungueltiger Datei), dann Besitzpruefung, dann Datei, dann Zeile — * scheitert die Zeile, wird eine neu geschriebene Datei zurueckgenommen. * Hatte der Favorit vorher ein Symbol MIT ANDERER Endung, wird die alte * Datei danach entfernt (Fehler protokolliert und geschluckt). */ async uploadIcon( tenantId: string, id: string, userId: string, file: UploadedFileLike | undefined, ) { if (!file) { throw new BadRequestException('Bitte wählen Sie eine Bilddatei aus.'); } if (file.buffer.length > FAVORITE_ICON_MAX_BYTES) { // Zweites Netz — multer (`limits.fileSize` an der Route) faengt das // in der Regel bereits vorher ab. throw new PayloadTooLargeException( 'Die Datei ist zu groß – erlaubt sind höchstens 512 KB.', ); } const mime = detectFavoriteIconMime(file.buffer); if (mime === null) { throw new BadRequestException( 'Nur Bilder im Format PNG, JPEG, GIF, WebP, ICO oder SVG sind erlaubt.', ); } const tenantPrisma = forTenant(this.prisma, tenantId, userId); const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } }); if (!link || link.userId !== userId || link.tenantId !== tenantId) { throw new NotFoundException('FavoriteLink not found'); } const absolute = favoriteIconAbsolutePath(link.userId, link.id, mime); if (absolute === null) { throw new InternalServerErrorException('Das Symbol konnte nicht gespeichert werden.'); } try { await fs.mkdir(path.dirname(absolute), { recursive: true }); await fs.writeFile(absolute, file.buffer); } catch (error) { this.logger.error( `Symbol des Favoriten ${id} konnte nicht gespeichert werden: ${ error instanceof Error ? error.message : String(error) }`, ); throw new InternalServerErrorException('Das Symbol konnte nicht gespeichert werden.'); } const previousMime = link.uploadedIconMime; let updated: typeof link; try { updated = await tenantPrisma.favoriteLink.update({ where: { id }, data: { uploadedIconMime: mime, iconVersion: { increment: 1 } }, }); } catch (error) { // Ruecknahme (T-LRR-08): die neu geschriebene Datei nur entfernen, // wenn sie einen ANDEREN Pfad als eine vorhandene alte Datei traegt — // sonst wuerde ein fehlgeschlagenes Update auf demselben Typ die // weiterhin gueltige alte Datei loeschen. if (previousMime !== mime) { await fs.unlink(absolute).catch(() => undefined); } throw error; } if (previousMime !== null && previousMime !== mime) { await this.removeIconFile(id, link.userId, previousMime, 'alte'); } return updated; } /** * Entfernt ein hochgeladenes Symbol wieder (260923-lrr). Ohne gesetztes * `uploadedIconMime` liefert die Methode die Zeile unveraendert — kein * unnoetiger Versionssprung. Die Datei wird NACH dem Update entfernt, * ein Fehler dabei wird protokolliert und geschluckt (Muster T-HK4-04). */ async removeUploadedIcon(tenantId: string, id: string, userId: string) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } }); if (!link || link.userId !== userId || link.tenantId !== tenantId) { throw new NotFoundException('FavoriteLink not found'); } if (link.uploadedIconMime === null) { return link; } const previousMime = link.uploadedIconMime; const updated = await tenantPrisma.favoriteLink.update({ where: { id }, data: { uploadedIconMime: null, iconVersion: { increment: 1 } }, }); await this.removeIconFile(id, link.userId, previousMime, 'entfernte'); return updated; } /** Best-effort-Entfernung einer Symboldatei — protokolliert, wirft nie (Muster T-HK4-04). */ private async removeIconFile( favoriteId: string, userId: string, mime: string, label: string, ): Promise { const absolute = favoriteIconAbsolutePath(userId, favoriteId, mime); if (absolute === null) return; try { await fs.unlink(absolute); } catch (error) { this.logger.warn( `${label} Symboldatei des Favoriten ${favoriteId} konnte nicht entfernt werden: ${ error instanceof Error ? error.message : String(error) }`, ); } } /** * Fetches the raw bytes of a favorite's stored icon, scoped to the * requesting user (T-08-06 — same ownership check as update/remove). * Never accepts a client-supplied URL — only the stored iconUrl on a * row the caller owns is fetched (T-QFIP-01). * * 260923-lrr: ein hochgeladenes Symbol hat VORRANG vor `iconUrl` — fehlt * die Datei trotz gesetztem Typ (sollte praktisch nie vorkommen), wird * protokolliert und auf `iconUrl` zurueckgefallen, statt 404 zu werfen. * * Throws NotFoundException (404) if the row doesn't exist, isn't owned * by the caller, or has neither an uploaded icon nor a stored iconUrl. * Throws a 502 HttpException if the upstream fetch fails (unreachable, * timeout, non-image, or SSRF-blocked) AND the public icon service fallback * (quick-261001-hbi, public pages only) has no icon either -- never returns * a placeholder image. */ async getIconBytes( tenantId: string, id: string, userId: string, ): Promise<{ contentType: string; body: Buffer }> { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } }); if (!link || link.userId !== userId) { throw new NotFoundException('FavoriteLink not found'); } if (link.uploadedIconMime !== null) { const absolute = favoriteIconAbsolutePath(link.userId, link.id, link.uploadedIconMime); if (absolute !== null) { try { const body = await fs.readFile(absolute); return { contentType: link.uploadedIconMime, body }; } catch (error) { this.logger.warn( `Hochgeladenes Symbol des Favoriten ${id} fehlt im Dateibereich, falle auf iconUrl zurueck: ${ error instanceof Error ? error.message : String(error) }`, ); } } else { this.logger.warn(`Hochgeladenes Symbol des Favoriten ${id} hat keinen gueltigen Ablageort`); } } if (!link.iconUrl) { throw new NotFoundException('FavoriteLink not found'); } try { return await this.iconDiscovery.fetchIconBytes(link.iconUrl); } catch { // quick-261001-hbi: Seite liefert kein abrufbares Symbol (z. B. per // JavaScript gesetzt) -- einmal beim oeffentlichen Symbol-Dienst fragen, // nur fuer oeffentlich erreichbare Seiten. try { return await this.iconDiscovery.fetchPublicServiceIconBytes(link.url); } catch { throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY); } } } }