diff --git a/apps/api/prisma/migrations/20260923160000_favorite_icon_upload/migration.sql b/apps/api/prisma/migrations/20260923160000_favorite_icon_upload/migration.sql new file mode 100644 index 0000000..4a8388c --- /dev/null +++ b/apps/api/prisma/migrations/20260923160000_favorite_icon_upload/migration.sql @@ -0,0 +1,37 @@ +-- quick-260923-lrr — Favoriten: eigenes Symbol hochladen, Zwischenspeicher +-- nach Aenderung erneuern. +-- +-- Zwei neue Spalten auf "FavoriteLink": +-- +-- "uploadedIconMime" TEXT NULL — der an den Bytes ERKANNTE Typ eines +-- hochgeladenen eigenen Symbols (PNG/JPEG/GIF/WebP/ICO/SVG); NULL, wenn +-- kein eigenes Symbol hochgeladen wurde. Es wird KEIN Pfad gespeichert: +-- die Datei liegt vollstaendig ableitbar unter +-- "user-files/favorite-icons//." (Endung aus dem +-- erkannten Typ) — damit gelangt auch bei einer vollstaendigen +-- Zeilenauslieferung (Prisma liefert die ganze Zeile an den Client) kein +-- Serverpfad in eine API-Antwort. Vorrang vor "iconUrl": ist der Wert +-- gesetzt, liefert GET /favorites/:id/icon die hochgeladene Datei statt +-- die gespeicherte Logo-Adresse abzurufen. +-- +-- "iconVersion" INTEGER NOT NULL DEFAULT 0 — Zaehler fuer die ausgelieferte +-- Symbol-Adresse (?v=). Steigt genau dann (Prisma +-- { increment: 1 }), wenn sich die angezeigte Symbolquelle aendert: eine +-- gespeicherte "iconUrl" weicht vom alten Wert ab, ein Symbol wird +-- hochgeladen, oder ein hochgeladenes Symbol wird entfernt. NICHT bei +-- Titel-, Link-Adress- oder Reihenfolgeaenderung ohne Symbolwechsel. +-- Bestandszeilen starten bei 0 — das unterscheidet sich von der vorher +-- unversionierten Adresse, ein bereits 24 Stunden im Browser +-- zwischengespeichertes Symbol wird dadurch beim naechsten Laden sofort +-- ungueltig. +-- +-- Die bestehende RLS-Regel "tenant_isolation_policy" auf "FavoriteLink" +-- (Migration 20260911120000, zeilenbezogen ueber "tenantId"/"userId") +-- braucht fuer zwei zusaetzliche Spalten KEINE Anpassung — sie schuetzt +-- Zeilen, nicht Spalten. Kein CREATE/DROP POLICY in dieser Migration. +-- +-- "migrate deploy" wendet diese Migration beim Start an +-- (apps/api/scripts/migrate-and-start.sh) — kein manueller Schritt. + +ALTER TABLE "FavoriteLink" ADD COLUMN "uploadedIconMime" TEXT; +ALTER TABLE "FavoriteLink" ADD COLUMN "iconVersion" INTEGER NOT NULL DEFAULT 0; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 1fc6bbb..1b6ff37 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -419,6 +419,16 @@ model FavoriteLink { title String url String iconUrl String? + // quick-260923-lrr: Typ eines hochgeladenen eigenen Symbols (erkannt an + // den Bytes, NIE aus der Anfrage uebernommen); null = kein hochgeladenes + // Symbol. Kein Pfad gespeichert — die Datei liegt ableitbar unter + // user-files/favorite-icons//.. + uploadedIconMime String? + // quick-260923-lrr: Zaehler fuer die ausgelieferte Symbol-Adresse + // (?v=), steigt bei jeder Aenderung der Symbolquelle + // (neue iconUrl, Upload, Entfernen) — macht den 24-h-Browser-Zwischenspeicher + // nach einer Aenderung sofort ungueltig. + iconVersion Int @default(0) position Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/apps/api/src/dashboard/dashboard.service.spec.ts b/apps/api/src/dashboard/dashboard.service.spec.ts index 84a1b14..3a560cf 100644 --- a/apps/api/src/dashboard/dashboard.service.spec.ts +++ b/apps/api/src/dashboard/dashboard.service.spec.ts @@ -1,4 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; /** * WIDGET_MODULE_MAP wird je Testfall über eine gemeinsame, gemockte @@ -45,6 +48,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({ import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; +import { favoriteIconAbsolutePath } from '../favorites/favorite-icon-files'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { DashboardService } from './dashboard.service'; @@ -52,8 +56,16 @@ import { DashboardService } from './dashboard.service'; * Herkunfts-Tenant protokollierenden Wrapper versieht. `module` ist bewusst * NICHT enthalten — der Katalogzugriff bleibt ungebunden. `searchProvider` * ergaenzt seit Aufgabe 3 (260910-krx). `dashboard` ergaenzt seit - * quick-260923-ad9 (Task 1). */ -const BOUND_MODEL_NAMES = ['dashboard', 'dashboardLayout', 'widgetInstance', 'searchProvider']; + * quick-260923-ad9 (Task 1). `favoriteLink` ergaenzt seit quick-260923-lrr + * (T-LRR-07: `removeWidget`/`deleteDashboard` lesen vor der Kaskade die + * betroffenen Favoriten mit hochgeladenem Symbol). */ +const BOUND_MODEL_NAMES = [ + 'dashboard', + 'dashboardLayout', + 'widgetInstance', + 'searchProvider', + 'favoriteLink', +]; /** Standard-Reiter-Kennung, die die meisten Tests verwenden — ein Reiter * `dash-1`, der `user-1`/`tenant-1` gehört (Standard-Fixture unten). */ @@ -125,6 +137,27 @@ function makeSearchProvider( }; } +/** Minimale FavoriteLink-Fixture fuer T-LRR-07 (quick-260923-lrr) — nur die + * Felder, die `cleanUpFavoriteIconFiles` und die vorbereitenden `findMany`- + * Aufrufe in `removeWidget`/`deleteDashboard` lesen. */ +function makeFavorite( + overrides: Partial<{ + id: string; + userId: string; + tenantId: string; + widgetId: string; + uploadedIconMime: string | null; + }> = {}, +) { + return { + id: overrides.id ?? 'fav-1', + userId: overrides.userId ?? 'user-1', + tenantId: overrides.tenantId ?? 'tenant-1', + widgetId: overrides.widgetId ?? 'w1', + uploadedIconMime: overrides.uploadedIconMime === undefined ? null : overrides.uploadedIconMime, + }; +} + function makeFakePrisma( opts: { widgets?: ReturnType[]; @@ -132,12 +165,14 @@ function makeFakePrisma( layout?: { dashboardId: string; userId: string; tenantId: string; layouts: unknown } | null; searchProviders?: ReturnType[]; dashboards?: ReturnType[]; + favorites?: ReturnType[]; } = {}, ) { const widgets = opts.widgets ?? []; const modules = opts.modules ?? []; let layoutRow = opts.layout ?? null; const searchProviders = opts.searchProviders ?? []; + const favorites = opts.favorites ?? []; // Standard-Fixture: GENAU EIN Reiter `dash-1`, der user-1/tenant-1 gehört // — die meisten Tests wollen sich um Reiter-Verwaltung nicht kümmern. // Tests, die eine andere Besitzlage brauchen (fremder Reiter, ADMIN mit @@ -256,6 +291,24 @@ function makeFakePrisma( return removed; }), }, + // T-LRR-07 (quick-260923-lrr): nur `findMany` — `removeWidget`/ + // `deleteDashboard` LESEN die betroffenen Favoriten vor der Kaskade, + // sie schreiben nie auf `favoriteLink` (das Loeschen selbst passiert + // ueber die Datenbank-Kaskade auf `widgetInstance`, nicht hier). + favoriteLink: { + findMany: vi.fn(async ({ where }: any) => { + let rows = favorites.filter((f) => f.userId === where.userId); + if (where.widgetId?.in) { + rows = rows.filter((f) => where.widgetId.in.includes(f.widgetId)); + } else if (where.widgetId) { + rows = rows.filter((f) => f.widgetId === where.widgetId); + } + if (where.uploadedIconMime?.not === null) { + rows = rows.filter((f) => f.uploadedIconMime !== null); + } + return rows; + }), + }, module: { findMany: vi.fn(async ({ where }: any) => { const slugs: string[] = where.slug.in; @@ -667,6 +720,69 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26 ); }); + describe('removeWidget — T-LRR-07 (quick-260923-lrr): Datei-Leichen nach Kaskadenloeschung', () => { + let iconsDir: string; + const ORIGINAL_DIR_ENV = process.env.FAVORITE_ICONS_DIR; + + beforeEach(() => { + iconsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-dashboard-')); + process.env.FAVORITE_ICONS_DIR = iconsDir; + }); + + afterEach(() => { + fs.rmSync(iconsDir, { recursive: true, force: true }); + if (ORIGINAL_DIR_ENV === undefined) { + delete process.env.FAVORITE_ICONS_DIR; + } else { + process.env.FAVORITE_ICONS_DIR = ORIGINAL_DIR_ENV; + } + }); + + it('entfernt die Symboldatei eines Favoriten, dessen Zeile die Datenbank-Kaskade mitloescht', async () => { + const widget = makeWidget({ id: 'w1', userId: 'user-1' }); + const favorite = makeFavorite({ + id: 'fav-1', + userId: 'user-1', + widgetId: 'w1', + uploadedIconMime: 'image/png', + }); + const prisma = makeFakePrisma({ widgets: [widget], favorites: [favorite] }); + const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any); + + const absolute = favoriteIconAbsolutePath('user-1', 'fav-1', 'image/png') as string; + fs.mkdirSync(path.dirname(absolute), { recursive: true }); + fs.writeFileSync(absolute, Buffer.from([1, 2, 3])); + + await service.removeWidget('w1', 'user-1', 'tenant-1'); + + expect(fs.existsSync(absolute)).toBe(false); + }); + + it('fehlende Datei wird geschluckt — removeWidget scheitert nicht daran', async () => { + const widget = makeWidget({ id: 'w1', userId: 'user-1' }); + const favorite = makeFavorite({ + id: 'fav-1', + userId: 'user-1', + widgetId: 'w1', + uploadedIconMime: 'image/png', + }); + const prisma = makeFakePrisma({ widgets: [widget], favorites: [favorite] }); + const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any); + + await expect(service.removeWidget('w1', 'user-1', 'tenant-1')).resolves.toBeTruthy(); + }); + + it('Favoriten OHNE hochgeladenes Symbol loesen keinen Dateizugriff aus', async () => { + const widget = makeWidget({ id: 'w1', userId: 'user-1' }); + const favorite = makeFavorite({ id: 'fav-1', userId: 'user-1', widgetId: 'w1', uploadedIconMime: null }); + const prisma = makeFakePrisma({ widgets: [widget], favorites: [favorite] }); + const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any); + + await expect(service.removeWidget('w1', 'user-1', 'tenant-1')).resolves.toBeTruthy(); + expect(fs.existsSync(path.join(iconsDir, 'user-1'))).toBe(false); + }); + }); + it('keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf', async () => { const widget = makeWidget({ id: 'w1', userId: 'user-1' }); const prisma = makeFakePrisma({ @@ -1234,6 +1350,63 @@ describe('DashboardService.deleteDashboard — Reiter löschen (quick-260923-ad9 NotFoundException, ); }); + + describe('T-LRR-07 (quick-260923-lrr): Datei-Leichen nach Kaskadenloeschung eines ganzen Reiters', () => { + let iconsDir: string; + const ORIGINAL_DIR_ENV = process.env.FAVORITE_ICONS_DIR; + + beforeEach(() => { + iconsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-dashboard-tab-')); + process.env.FAVORITE_ICONS_DIR = iconsDir; + }); + + afterEach(() => { + fs.rmSync(iconsDir, { recursive: true, force: true }); + if (ORIGINAL_DIR_ENV === undefined) { + delete process.env.FAVORITE_ICONS_DIR; + } else { + process.env.FAVORITE_ICONS_DIR = ORIGINAL_DIR_ENV; + } + }); + + it('entfernt die Symboldateien ALLER Favoriten der Widgets dieses Reiters', async () => { + const prisma = makeFakePrisma({ + dashboards: [makeDashboard({ id: 'd1', position: 0 }), makeDashboard({ id: 'd2', position: 1 })], + widgets: [ + makeWidget({ id: 'w1', dashboardId: 'd1' }), + makeWidget({ id: 'w2', dashboardId: 'd1' }), + ], + favorites: [ + makeFavorite({ id: 'fav-1', widgetId: 'w1', uploadedIconMime: 'image/png' }), + makeFavorite({ id: 'fav-2', widgetId: 'w2', uploadedIconMime: 'image/svg+xml' }), + makeFavorite({ id: 'fav-3', widgetId: 'w2', uploadedIconMime: null }), + ], + }); + const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any); + + const abs1 = favoriteIconAbsolutePath('user-1', 'fav-1', 'image/png') as string; + const abs2 = favoriteIconAbsolutePath('user-1', 'fav-2', 'image/svg+xml') as string; + for (const absolute of [abs1, abs2]) { + fs.mkdirSync(path.dirname(absolute), { recursive: true }); + fs.writeFileSync(absolute, Buffer.from([1])); + } + + await service.deleteDashboard('d1', 'user-1', 'tenant-1'); + + expect(fs.existsSync(abs1)).toBe(false); + expect(fs.existsSync(abs2)).toBe(false); + }); + + it('ein Reiter ohne Widgets loest keinen Dateizugriff aus', async () => { + const prisma = makeFakePrisma({ + dashboards: [makeDashboard({ id: 'd1', position: 0 }), makeDashboard({ id: 'd2', position: 1 })], + }); + const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any); + + await expect(service.deleteDashboard('d1', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'd1' }); + expect(fs.existsSync(path.join(iconsDir, 'user-1'))).toBe(false); + }); + }); }); describe('DashboardService.reorderDashboards — Reiter umsortieren (quick-260923-ad9, Task 2, T-AD9-04)', () => { diff --git a/apps/api/src/dashboard/dashboard.service.ts b/apps/api/src/dashboard/dashboard.service.ts index 81790d5..c4444f4 100644 --- a/apps/api/src/dashboard/dashboard.service.ts +++ b/apps/api/src/dashboard/dashboard.service.ts @@ -2,9 +2,11 @@ import { BadRequestException, ConflictException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { Prisma, Role } from '@prisma/client'; +import { removeFavoriteIconFileBestEffort } from '../favorites/favorite-icon-files'; import { ModuleAccessService } from '../module-registry/module-access.service'; import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension'; import { PrismaService } from '../prisma/prisma.service'; @@ -91,11 +93,40 @@ const DEFAULT_SEARCH_PROVIDERS = [ */ @Injectable() export class DashboardService { + private readonly logger = new Logger(DashboardService.name); + constructor( private readonly prisma: PrismaService, private readonly moduleAccessService: ModuleAccessService, ) {} + /** + * T-LRR-07 (quick-260923-lrr, Restrisiko aus dem Favoriten-Plan + * geschlossen): loescht ein Widget seine `FavoriteLink`-Zeilen ueber die + * Datenbank-Kaskade (`onDelete: Cascade` auf `FavoriteLink.widgetId`), + * OHNE `FavoritesService` zu durchlaufen — dessen Datei-Aufraeumung in + * `remove()` greift hier also nicht. Diese Hilfsfunktion entfernt die + * Symboldateien der betroffenen Favoriten NACHTRAEGLICH, best effort + * (Muster T-HK4-04): ein Dateifehler wird protokolliert und geschluckt, + * er darf das Loeschen des Widgets/Reiters nie verhindern oder + * zuruecknehmen — deshalb laeuft dieser Aufruf immer NACH der + * erfolgreichen Datenbankoperation, nie innerhalb ihrer Transaktion. + */ + private async cleanUpFavoriteIconFiles( + userId: string, + rows: Array<{ id: string; uploadedIconMime: string | null }>, + ): Promise { + for (const row of rows) { + if (row.uploadedIconMime === null) continue; + const removed = await removeFavoriteIconFileBestEffort(userId, row.id, row.uploadedIconMime); + if (!removed) { + this.logger.warn( + `Symboldatei des kaskadiert geloeschten Favoriten ${row.id} konnte nicht entfernt werden (T-LRR-07)`, + ); + } + } + } + /** * Reiter (quick-260923-ad9, D-01/D-08/D-09): liest die Dashboards des * Benutzers, nach `position` aufsteigend — Position 0 ist der Standard @@ -243,7 +274,25 @@ export class DashboardService { throw new ConflictException('Der letzte verbleibende Reiter kann nicht gelöscht werden.'); } - return withTenantTransaction(this.prisma, tenantId, async (tx) => { + // T-LRR-07: VOR der Kaskade merken, welche Favoriten dieses Reiters ein + // eigenes hochgeladenes Symbol tragen — siehe `cleanUpFavoriteIconFiles`. + // Nur ein Lesezugriff, kein Schreiben; laeuft ausserhalb der Transaktion + // unten, weil die Dateiraeumung selbst NICHT transaktional sein muss + // (und best effort niemals einen Rollback ausloesen darf). + const widgetsOnTab = await tenantPrisma.widgetInstance.findMany({ + where: { dashboardId: id, userId }, + select: { id: true }, + }); + const widgetIds = widgetsOnTab.map((w: { id: string }) => w.id); + const iconRows = + widgetIds.length === 0 + ? [] + : await tenantPrisma.favoriteLink.findMany({ + where: { widgetId: { in: widgetIds }, userId, uploadedIconMime: { not: null } }, + select: { id: true, uploadedIconMime: true }, + }); + + const result = await withTenantTransaction(this.prisma, tenantId, async (tx) => { await tx.widgetInstance.deleteMany({ where: { dashboardId: id, userId } }); await tx.dashboardLayout.deleteMany({ where: { dashboardId: id, userId } }); await tx.dashboard.deleteMany({ where: { id, userId } }); @@ -262,6 +311,10 @@ export class DashboardService { return { id }; }); + + await this.cleanUpFavoriteIconFiles(userId, iconRows); + + return result; } /** @@ -505,6 +558,11 @@ export class DashboardService { * Verifies ownership by userId before deleting (T-05-01) — same real * ownership check as `updateWidgetConfig` above, same reasoning: both * queries run over the SAME bound client and tenant id. + * + * T-LRR-07 (quick-260923-lrr): dieselbe Kaskade wie in `deleteDashboard` + * trifft hier ein einzelnes Widget — vor dem Loeschen werden dessen + * Favoriten mit hochgeladenem Symbol gemerkt, danach werden ihre Dateien + * best effort entfernt (siehe `cleanUpFavoriteIconFiles`). */ async removeWidget(id: string, userId: string, tenantId: string) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); @@ -518,9 +576,18 @@ export class DashboardService { ); } - return tenantPrisma.widgetInstance.delete({ + const iconRows = await tenantPrisma.favoriteLink.findMany({ + where: { widgetId: id, userId, uploadedIconMime: { not: null } }, + select: { id: true, uploadedIconMime: true }, + }); + + const result = await tenantPrisma.widgetInstance.delete({ where: { id }, }); + + await this.cleanUpFavoriteIconFiles(userId, iconRows); + + return result; } // --- Search Providers (05-02, D-15) --- diff --git a/apps/api/src/favorites/favorite-icon-files.spec.ts b/apps/api/src/favorites/favorite-icon-files.spec.ts new file mode 100644 index 0000000..f2805a4 --- /dev/null +++ b/apps/api/src/favorites/favorite-icon-files.spec.ts @@ -0,0 +1,199 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { + FAVORITE_ICON_MAX_BYTES, + detectFavoriteIconMime, + favoriteIconAbsolutePath, + favoriteIconExtension, + removeFavoriteIconFileBestEffort, + resolveFavoriteIconsDir, +} from './favorite-icon-files'; + +/** + * favorite-icon-files.spec — NEU (quick-260923-lrr). + * + * Erkennung (Muster dashboard-image-rules.spec.ts): PNG/JPEG/GIF/WebP wie + * `detectImageMime`, dazu ICO (Kopfstueck, kein CUR) und SVG (Praefix-Form, + * kein `` davor). Pfadbildung: Endung aus dem Typ, Segmente nur aus + * Buchstaben/Ziffern/Bindestrich, Ergebnis muss im Symbolverzeichnis liegen + * (T-LRR-01). `FAVORITE_ICONS_DIR` steuert das Verzeichnis in Tests, wie + * `DASHBOARD_IMAGES_DIR` es fuer die Bilderrahmen-Bilder tut. + */ +function bytes(...parts: (number[] | string)[]): Uint8Array { + const out: number[] = []; + for (const p of parts) { + if (typeof p === 'string') { + for (const ch of p) out.push(ch.charCodeAt(0)); + } else { + out.push(...p); + } + } + return Uint8Array.from(out); +} + +describe('detectFavoriteIconMime (quick-260923-lrr)', () => { + it('PNG/JPEG/GIF/WebP-Signaturen ergeben dieselben Typen wie detectImageMime', () => { + expect( + detectFavoriteIconMime(bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], [0, 0, 0, 13])), + ).toBe('image/png'); + expect(detectFavoriteIconMime(bytes([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10], 'JFIF'))).toBe('image/jpeg'); + expect(detectFavoriteIconMime(bytes('GIF89a', [1, 0, 1, 0]))).toBe('image/gif'); + expect(detectFavoriteIconMime(bytes('RIFF', [0x24, 0x00, 0x00, 0x00], 'WEBP', 'VP8 '))).toBe( + 'image/webp', + ); + }); + + it('Bytes 00 00 01 00 (mindestens 6 Bytes) ergeben image/x-icon', () => { + expect(detectFavoriteIconMime(bytes([0x00, 0x00, 0x01, 0x00, 0x01, 0x00]))).toBe('image/x-icon'); + }); + + it('zu kurzes ICO-Kopfstueck (weniger als 6 Bytes) ergibt null', () => { + expect(detectFavoriteIconMime(bytes([0x00, 0x00, 0x01, 0x00]))).toBeNull(); + }); + + it('00 00 02 00 (CUR-Cursor-Datei) ergibt null — nur Typ 1 (ICO) wird erkannt', () => { + expect(detectFavoriteIconMime(bytes([0x00, 0x00, 0x02, 0x00, 0x01, 0x00]))).toBeNull(); + }); + + it('gueltige SVG-Formen ergeben image/svg+xml', () => { + expect(detectFavoriteIconMime(bytes(''))).toBe( + 'image/svg+xml', + ); + expect( + detectFavoriteIconMime(bytes('\n')), + ).toBe('image/svg+xml'); + // fuehrendes BOM + expect( + detectFavoriteIconMime(bytes([0xef, 0xbb, 0xbf], '')), + ).toBe('image/svg+xml'); + // fuehrendes Leerzeichen + expect(detectFavoriteIconMime(bytes(' '))).toBe('image/svg+xml'); + // Kommentar vor \n')), + ).toBe('image/svg+xml'); + // DOCTYPE svg vor \n', + ), + ), + ).toBe('image/svg+xml'); + }); + + it('ungueltige Formen ergeben null', () => { + expect(detectFavoriteIconMime(bytes(''))).toBeNull(); + expect(detectFavoriteIconMime(bytes('\n'))).toBeNull(); + expect(detectFavoriteIconMime(new Uint8Array(0))).toBeNull(); + expect(detectFavoriteIconMime(bytes('Dies ist keine Bilddatei, sondern Text.'))).toBeNull(); + expect(detectFavoriteIconMime(bytes('%PDF-1.7\n%\xe2\xe3'))).toBeNull(); + expect(detectFavoriteIconMime(bytes([0x00, 0x00, 0x02, 0x00, 0x01, 0x00]))).toBeNull(); + }); +}); + +describe('favoriteIconExtension (quick-260923-lrr)', () => { + it('bildet die sechs bekannten Typen ab, unbekannter Typ ergibt null', () => { + expect(favoriteIconExtension('image/png')).toBe('png'); + expect(favoriteIconExtension('image/jpeg')).toBe('jpg'); + expect(favoriteIconExtension('image/gif')).toBe('gif'); + expect(favoriteIconExtension('image/webp')).toBe('webp'); + expect(favoriteIconExtension('image/x-icon')).toBe('ico'); + expect(favoriteIconExtension('image/svg+xml')).toBe('svg'); + expect(favoriteIconExtension('application/pdf')).toBeNull(); + }); +}); + +describe('resolveFavoriteIconsDir / favoriteIconAbsolutePath (quick-260923-lrr)', () => { + let dir: string; + const ORIGINAL_ENV = process.env.FAVORITE_ICONS_DIR; + + beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-')); + process.env.FAVORITE_ICONS_DIR = dir; + }); + + afterAll(() => { + fs.rmSync(dir, { recursive: true, force: true }); + if (ORIGINAL_ENV === undefined) { + delete process.env.FAVORITE_ICONS_DIR; + } else { + process.env.FAVORITE_ICONS_DIR = ORIGINAL_ENV; + } + }); + + it('resolveFavoriteIconsDir beachtet FAVORITE_ICONS_DIR', () => { + expect(resolveFavoriteIconsDir()).toBe(path.resolve(dir)); + }); + + it('liegt unter resolveFavoriteIconsDir()//.', () => { + const result = favoriteIconAbsolutePath('user-1', 'fav-1', 'image/png'); + expect(result).toBe(path.join(resolveFavoriteIconsDir(), 'user-1', 'fav-1.png')); + }); + + it('unbekannter Typ ergibt null', () => { + expect(favoriteIconAbsolutePath('user-1', 'fav-1', 'application/pdf')).toBeNull(); + }); + + it('Segmente mit .., /, \\ oder leer ergeben null', () => { + expect(favoriteIconAbsolutePath('..', 'fav-1', 'image/png')).toBeNull(); + expect(favoriteIconAbsolutePath('user-1', '../etc/passwd', 'image/png')).toBeNull(); + expect(favoriteIconAbsolutePath('a/b', 'fav-1', 'image/png')).toBeNull(); + expect(favoriteIconAbsolutePath('a\\b', 'fav-1', 'image/png')).toBeNull(); + expect(favoriteIconAbsolutePath('', 'fav-1', 'image/png')).toBeNull(); + expect(favoriteIconAbsolutePath('user-1', '', 'image/png')).toBeNull(); + }); +}); + +describe('removeFavoriteIconFileBestEffort (quick-260923-lrr, T-LRR-07)', () => { + let dir: string; + const ORIGINAL_ENV = process.env.FAVORITE_ICONS_DIR; + + beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-rm-')); + process.env.FAVORITE_ICONS_DIR = dir; + }); + + afterAll(() => { + fs.rmSync(dir, { recursive: true, force: true }); + if (ORIGINAL_ENV === undefined) { + delete process.env.FAVORITE_ICONS_DIR; + } else { + process.env.FAVORITE_ICONS_DIR = ORIGINAL_ENV; + } + }); + + afterEach(() => { + fs.rmSync(path.join(dir, 'user-1'), { recursive: true, force: true }); + }); + + it('entfernt eine vorhandene Datei und liefert true', async () => { + const absolute = favoriteIconAbsolutePath('user-1', 'fav-1', 'image/png'); + expect(absolute).not.toBeNull(); + fs.mkdirSync(path.dirname(absolute as string), { recursive: true }); + fs.writeFileSync(absolute as string, Buffer.from([1, 2, 3])); + + const result = await removeFavoriteIconFileBestEffort('user-1', 'fav-1', 'image/png'); + + expect(result).toBe(true); + expect(fs.existsSync(absolute as string)).toBe(false); + }); + + it('fehlende Datei -> false, wirft nicht', async () => { + await expect(removeFavoriteIconFileBestEffort('user-1', 'fehlt', 'image/png')).resolves.toBe(false); + }); + + it('unbekannter Typ -> false, wirft nicht', async () => { + await expect( + removeFavoriteIconFileBestEffort('user-1', 'fav-1', 'application/pdf'), + ).resolves.toBe(false); + }); +}); + +describe('Grenzen (quick-260923-lrr)', () => { + it('FAVORITE_ICON_MAX_BYTES ist 512 KiB', () => { + expect(FAVORITE_ICON_MAX_BYTES).toBe(512 * 1024); + }); +}); diff --git a/apps/api/src/favorites/favorite-icon-files.ts b/apps/api/src/favorites/favorite-icon-files.ts new file mode 100644 index 0000000..22d4c2b --- /dev/null +++ b/apps/api/src/favorites/favorite-icon-files.ts @@ -0,0 +1,173 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { detectImageMime } from '../dashboard/dashboard-image-rules'; + +/** + * favorite-icon-files — reine Regeln und Ablage-Hilfen fuer ein + * hochgeladenes Favoriten-Symbol (quick-260923-lrr). Kein Nest, kein + * Prisma: Grenzen, Erkennung und Pfadbildung, damit Dienst und Controller + * dieselben Werte anwenden und die Erkennung direkt an den Bytes testbar + * ist — Muster `dashboard-image-rules.ts` (quick-260921-pi9). + * + * Warum ZUSAETZLICH ICO und SVG (ueber die vier Typen aus + * `detectImageMime` hinaus): Favicons liegen haeufig als `.ico` vor, und + * ein selbst gezeichnetes Symbol oft als `.svg`. Beide Formate haben keine + * fuehrende Signatur wie PNG/JPEG/GIF/WebP im klassischen Sinn — ICO traegt + * nur ein vier Byte langes Kopfstueck (Typ-Feld `0x0001`, NICHT `0x0002` = + * CUR-Cursor-Dateien, die deshalb bewusst NICHT erkannt werden), SVG ist + * Text und wird ueber eine Praefix-Pruefung erkannt (XML-Deklaration, + * Kommentare, ein optionales DOCTYPE mit Wurzel `svg`, dann `` oder `/`. Alles andere (z. B. `` + * vor ``, ein DOCTYPE auf `html`) ergibt kein Treffer. + */ +const SVG_PREFIX_RE = + /^(?:<\?xml[^>]*\?>\s*)?(?:(?:|]*>)\s*)*/]/i; + +function startsWithIcoHeader(buffer: Uint8Array): boolean { + if (buffer.length < 6) return false; + for (let i = 0; i < ICO_HEADER.length; i++) { + if (buffer[i] !== ICO_HEADER[i]) return false; + } + return true; +} + +/** + * Prueft die ersten 4096 Bytes als UTF-8 gegen `SVG_PREFIX_RE`. Ein + * fuehrendes BOM oder Leerraum vor der eigentlichen Deklaration wird + * entfernt, bevor die Praefix-Form geprueft wird. Wirft nie — ein Puffer, + * der sich nicht als UTF-8 lesen laesst, ist schlicht kein SVG. + */ +function looksLikeSvg(buffer: Uint8Array): boolean { + let text: string; + try { + text = Buffer.from(buffer.subarray(0, 4096)).toString('utf-8'); + } catch { + return false; + } + text = text.replace(/^/, '').replace(/^\s+/, ''); + return SVG_PREFIX_RE.test(text); +} + +/** + * Erkennt PNG, JPEG, GIF, WebP (ueber `detectImageMime`), ICO und SVG an + * den Bytes; alles andere ergibt `null`. Wirft nie. + */ +export function detectFavoriteIconMime(buffer: Uint8Array): FavoriteIconMime | null { + const known = detectImageMime(buffer); + if (known !== null) return known; + if (startsWithIcoHeader(buffer)) return 'image/x-icon'; + if (looksLikeSvg(buffer)) return 'image/svg+xml'; + return null; +} + +/** Endung aus dem ERKANNTEN Typ; alles andere ergibt `null`, nie eine Vermutung. */ +export function favoriteIconExtension(mime: string): string | null { + switch (mime) { + case 'image/png': + return 'png'; + case 'image/jpeg': + return 'jpg'; + case 'image/gif': + return 'gif'; + case 'image/webp': + return 'webp'; + case 'image/x-icon': + return 'ico'; + case 'image/svg+xml': + return 'svg'; + default: + return null; + } +} + +/** + * Loest das Symbolverzeichnis relativ zur Monorepo-Wurzel auf — Muster + * `resolveDashboardImagesDir()` (dashboard-images.service.ts): zur Laufzeit + * ist `__dirname` = apps/api/dist/favorites/, also vier Ebenen hoch. + * + * `FAVORITE_ICONS_DIR` ist ein Testschalter und im Betrieb nie gesetzt; die + * Tests zeigen damit auf ein Wegwerfverzeichnis unter `os.tmpdir()`. + */ +export function resolveFavoriteIconsDir(): string { + const override = process.env.FAVORITE_ICONS_DIR; + if (override !== undefined && override !== '') { + return path.resolve(override); + } + return path.resolve(__dirname, '..', '..', '..', '..', 'user-files', 'favorite-icons'); +} + +/** Nur Buchstaben, Ziffern und Bindestrich — kein Segment aus der Anfrage geht ungeprueft in einen Pfad. */ +const SAFE_SEGMENT_RE = /^[A-Za-z0-9-]+$/; + +/** + * Bildet den absoluten Ablagepfad `//.`. + * `null`, wenn der Typ unbekannt ist, `userId`/`id` nicht ausschliesslich aus + * Buchstaben/Ziffern/Bindestrich bestehen (schliesst `..`, `/`, `\`, leere + * Segmente aus), oder das Ergebnis nicht unter dem Symbolverzeichnis liegt + * (T-LRR-01). Kein Byte aus der Anfrage — insbesondere nicht `originalname` + * — geht je in diesen Pfad ein: `id` ist die Zeilen-UUID, `ext` kommt aus + * dem an den Bytes ERKANNTEN Typ. + */ +export function favoriteIconAbsolutePath(userId: string, id: string, mime: string): string | null { + const ext = favoriteIconExtension(mime); + if (ext === null) return null; + if (!SAFE_SEGMENT_RE.test(userId) || !SAFE_SEGMENT_RE.test(id)) return null; + + const base = resolveFavoriteIconsDir(); + const absolute = path.resolve(base, userId, `${id}.${ext}`); + if (absolute !== base && !absolute.startsWith(base + path.sep)) return null; + return absolute; +} + +/** + * Entfernt die Symboldatei eines hochgeladenen Favoriten-Symbols, falls sie + * existiert — best effort, wirft NIE (Muster T-HK4-04: eine Dateileiche ist + * harmloser als eine haengende Operation). Fuer Aufrufer ausserhalb von + * `FavoritesService`, deren Vorgang (Loeschen ueber Datenbank-Kaskade, + * T-LRR-07) nicht an einem Dateifehler scheitern darf: `DashboardService` + * beim Loeschen eines Widgets oder eines ganzen Reiters, siehe dortigen + * Kommentar. Liefert `true`, wenn eine Datei tatsaechlich entfernt wurde + * (fuer eine Protokollzeile beim Aufrufer), sonst `false` — auch das ist + * kein Fehlerzustand: die Datei kann bereits gefehlt haben. + */ +export async function removeFavoriteIconFileBestEffort( + userId: string, + id: string, + mime: string, +): Promise { + const absolute = favoriteIconAbsolutePath(userId, id, mime); + if (absolute === null) return false; + try { + await fs.unlink(absolute); + return true; + } catch { + return false; + } +} diff --git a/apps/api/src/favorites/favorites.controller.spec.ts b/apps/api/src/favorites/favorites.controller.spec.ts new file mode 100644 index 0000000..2c9cd0c --- /dev/null +++ b/apps/api/src/favorites/favorites.controller.spec.ts @@ -0,0 +1,144 @@ +import 'reflect-metadata'; +import { ForbiddenException } from '@nestjs/common'; +import { describe, expect, it, vi } from 'vitest'; + +/** + * `FileInterceptor` wird als Attrappe eingesetzt, damit die Grenzen der + * Upload-Route (T-LRR-04) am AUFRUF pruefbar sind — Muster + * `dashboard-images.controller.spec.ts` (quick-260921-pi9). + */ +const { fileInterceptorMock } = vi.hoisted(() => ({ + fileInterceptorMock: vi.fn(() => class FakeInterceptor {}), +})); +vi.mock('@nestjs/platform-express', () => ({ FileInterceptor: fileInterceptorMock })); + +import { FAVORITE_ICON_MAX_BYTES } from './favorite-icon-files'; +import { FavoritesController } from './favorites.controller'; + +/** + * favorites.controller.spec — NEU (quick-260923-lrr). + * + * Fuenf Bereiche: Interceptor-Grenzen der Upload-Route (Feld `icon`, 512 KB, + * genau eine Datei), die Header der Symbol-Antwort (jetzt `private` statt + * `public`, 260923-lrr), Weitergabe von Mandant/Benutzer ausschliesslich aus + * dem Sitzungsnachweis (`req.tenantId` VOR `req.user.tenantId`, Muster + * `extractContext`), Abweisung ohne Mandantenkontext, und die + * Routen-Metadaten der zwei neuen Wege. + */ +function makeService() { + return { + list: vi.fn(async () => []), + create: vi.fn(async () => ({ id: 'new' })), + reorder: vi.fn(async () => []), + getIconBytes: vi.fn(async () => ({ + contentType: 'image/png', + body: Buffer.from([0x89, 0x50, 0x4e, 0x47]), + })), + uploadIcon: vi.fn(async (_tenantId: string, id: string, _userId: string) => ({ + id, + uploadedIconMime: 'image/png', + iconVersion: 1, + })), + removeUploadedIcon: vi.fn(async (_tenantId: string, id: string, _userId: string) => ({ + id, + uploadedIconMime: null, + iconVersion: 2, + })), + update: vi.fn(async () => ({})), + remove: vi.fn(async () => undefined), + }; +} + +function makeRes() { + const headers: Record = {}; + return { + headers, + setHeader: vi.fn((name: string, value: string) => { + headers[name] = value; + }), + send: vi.fn(), + }; +} + +function makeReq(overrides: Partial<{ tenantId: string | null; user: any }> = {}) { + return { + tenantId: overrides.tenantId, + user: overrides.user ?? { id: 'user-1', tenantId: 'tenant-from-user' }, + } as any; +} + +describe('FavoritesController (quick-260923-lrr)', () => { + it('Test 1: FileInterceptor wird mit dem Feld icon und { limits: { fileSize: 512 * 1024, files: 1 } } aufgerufen', () => { + expect(fileInterceptorMock).toHaveBeenCalledWith('icon', { + limits: { fileSize: FAVORITE_ICON_MAX_BYTES, files: 1 }, + }); + expect(FAVORITE_ICON_MAX_BYTES).toBe(512 * 1024); + }); + + it('Test 2: getIcon setzt Content-Type aus dem Dienst, Cache-Control private, nosniff, CSP sandbox', async () => { + const service = makeService(); + const controller = new FavoritesController(service as never); + const res = makeRes(); + + await controller.getIcon('fav-1', makeReq({ tenantId: 'tenant-1' }), res as never); + + expect(service.getIconBytes).toHaveBeenCalledWith('tenant-1', 'fav-1', 'user-1'); + expect(res.headers['Content-Type']).toBe('image/png'); + expect(res.headers['Cache-Control']).toBe('private, max-age=86400'); + expect(res.headers['X-Content-Type-Options']).toBe('nosniff'); + expect(res.headers['Content-Security-Policy']).toBe("default-src 'none'; sandbox"); + }); + + it('Test 3: uploadIcon reicht tenantId aus req.tenantId (VOR req.user.tenantId) und userId aus req.user.id an den Dienst', async () => { + const service = makeService(); + const controller = new FavoritesController(service as never); + const file = { buffer: Buffer.from([1]), originalname: 'x.png', mimetype: 'image/png', size: 1 }; + + await controller.uploadIcon('fav-1', makeReq({ tenantId: 'tenant-1' }), file); + + expect(service.uploadIcon).toHaveBeenCalledWith('tenant-1', 'fav-1', 'user-1', file); + }); + + it('Test 4: uploadIcon faellt auf req.user.tenantId zurueck, wenn req.tenantId fehlt', async () => { + const service = makeService(); + const controller = new FavoritesController(service as never); + const file = { buffer: Buffer.from([1]), originalname: 'x.png', mimetype: 'image/png', size: 1 }; + + await controller.uploadIcon('fav-1', makeReq({ tenantId: undefined }), file); + + expect(service.uploadIcon).toHaveBeenCalledWith('tenant-from-user', 'fav-1', 'user-1', file); + }); + + it('Test 5: removeUploadedIcon reicht tenantId/userId ebenso weiter', async () => { + const service = makeService(); + const controller = new FavoritesController(service as never); + + await controller.removeUploadedIcon('fav-1', makeReq({ tenantId: 'tenant-1' })); + + expect(service.removeUploadedIcon).toHaveBeenCalledWith('tenant-1', 'fav-1', 'user-1'); + }); + + it('Test 6: ohne Mandantenkontext -> ForbiddenException, Dienst wird NICHT aufgerufen', async () => { + const service = makeService(); + const controller = new FavoritesController(service as never); + const file = { buffer: Buffer.from([1]), originalname: 'x.png', mimetype: 'image/png', size: 1 }; + + await expect( + controller.uploadIcon('fav-1', makeReq({ tenantId: null, user: { id: 'user-1' } }), file), + ).rejects.toThrow(ForbiddenException); + expect(service.uploadIcon).not.toHaveBeenCalled(); + + await expect( + controller.removeUploadedIcon('fav-1', makeReq({ tenantId: null, user: { id: 'user-1' } })), + ).rejects.toThrow(ForbiddenException); + expect(service.removeUploadedIcon).not.toHaveBeenCalled(); + }); + + it('Test 7: POST :id/icon und DELETE :id/icon sind als Routen-Metadaten vorhanden', () => { + const proto = FavoritesController.prototype; + expect(Reflect.getMetadata('path', proto.uploadIcon)).toBe(':id/icon'); + expect(Reflect.getMetadata('method', proto.uploadIcon)).toBe(1); // RequestMethod.POST + expect(Reflect.getMetadata('path', proto.removeUploadedIcon)).toBe(':id/icon'); + expect(Reflect.getMetadata('method', proto.removeUploadedIcon)).toBe(3); // RequestMethod.DELETE + }); +}); diff --git a/apps/api/src/favorites/favorites.controller.ts b/apps/api/src/favorites/favorites.controller.ts index 43e1fe3..83e99c4 100644 --- a/apps/api/src/favorites/favorites.controller.ts +++ b/apps/api/src/favorites/favorites.controller.ts @@ -12,12 +12,16 @@ import { Query, Req, Res, + UploadedFile, + UseInterceptors, } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; import { Response } from 'express'; -import type { AuthenticatedRequest } from '../auth/types/auth-user'; +import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user'; 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 } from './favorite-icon-files'; import { FavoritesService } from './favorites.service'; /** @@ -39,7 +43,10 @@ import { FavoritesService } from './favorites.service'; * - GET /favorites?widgetId= — list favorites for a widget instance * - POST /favorites — create a favorite (triggers server-side icon discovery) * - PUT /favorites/order — reorder favorites for a widget instance (260917-jdd) - * - GET /favorites/:id/icon — stream a favorite's stored icon bytes + * - GET /favorites/:id/icon — stream a favorite's stored icon bytes (append `?v=` + * client-side to bust the 24h cache after any change to the icon source, 260923-lrr) + * - POST /favorites/:id/icon — upload a custom icon (multipart field `icon`, ≤512 KB, 260923-lrr) + * - DELETE /favorites/:id/icon — remove a previously uploaded icon (260923-lrr) * - PATCH /favorites/:id — update a favorite (ownership verified in service) * - DELETE /favorites/:id — delete a favorite (ownership verified in service) */ @@ -122,7 +129,12 @@ export class FavoritesController { ); res.setHeader('Content-Type', contentType); - res.setHeader('Cache-Control', 'public, max-age=86400'); + // 260923-lrr: private statt public — kein gemeinsamer Zwischenspeicher + // (Nginx Proxy Manager) haelt benutzerbezogene Symbole vor. Die Adresse + // traegt clientseitig `?v=` (T-LRR-05), damit der lange + // 24h-Browser-Zwischenspeicher nach einer Aenderung trotzdem sofort + // ungueltig wird. + res.setHeader('Cache-Control', 'private, max-age=86400'); // 260917-jdd: die Bytes kommen jetzt auch von Hosts ohne gueltiges // Zertifikat. Als -Unterressource ignoriert der Browser diese // Header, aber ein direkt im Tab geoeffnetes SVG laeuft damit ohne @@ -132,6 +144,42 @@ export class FavoritesController { res.send(body); } + /** + * POST /favorites/:id/icon — laedt ein eigenes Symbol fuer einen + * Favoriten hoch (260923-lrr). Groessengrenze JE ROUTE (Muster + * `dashboard-images.controller.ts` T-PI9-02): `FileInterceptor` nimmt + * genau eine Datei bis 512 KB; multers `LIMIT_FILE_SIZE` bildet Nest auf + * 413 ab. Typ und Besitzpruefung laufen im Dienst (T-LRR-01/T-LRR-03). + */ + @Post(':id/icon') + @UseInterceptors( + FileInterceptor('icon', { limits: { fileSize: FAVORITE_ICON_MAX_BYTES, files: 1 } }), + ) + async uploadIcon( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: AuthenticatedRequest, + @UploadedFile() file?: UploadedFileLike, + ) { + const { userId, tenantId } = this.extractContext(req); + + return this.favoritesService.uploadIcon(tenantId, id, userId, file); + } + + /** + * DELETE /favorites/:id/icon — entfernt ein zuvor hochgeladenes Symbol + * wieder; die Kachel faellt danach auf `iconUrl` bzw. automatische + * Erkennung zurueck (260923-lrr). + */ + @Delete(':id/icon') + async removeUploadedIcon( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: AuthenticatedRequest, + ) { + const { userId, tenantId } = this.extractContext(req); + + return this.favoritesService.removeUploadedIcon(tenantId, id, userId); + } + @Patch(':id') async update( @Param('id') id: string, diff --git a/apps/api/src/favorites/favorites.service.spec.ts b/apps/api/src/favorites/favorites.service.spec.ts index ffd0877..476e116 100644 --- a/apps/api/src/favorites/favorites.service.spec.ts +++ b/apps/api/src/favorites/favorites.service.spec.ts @@ -1,5 +1,14 @@ -import { BadRequestException, HttpException, NotFoundException } from '@nestjs/common'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + BadRequestException, + HttpException, + NotFoundException, + PayloadTooLargeException, + UnprocessableEntityException, +} from '@nestjs/common'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FavoritesService } from './favorites.service'; import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension'; @@ -39,6 +48,9 @@ interface FakeFavoriteRow { position: number; createdAt?: Date; updatedAt?: Date; + /** 260923-lrr — Bestandszeilen im Fake bekommen die Vorgabe null/0. */ + uploadedIconMime?: string | null; + iconVersion?: number; } interface FakeWidgetRow { @@ -71,7 +83,9 @@ function throwP2025(action: 'update' | 'delete'): never { * deshalb strukturell nie. */ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWidgetRow[] = []) { - const favorites = new Map(favoriteRows.map((f) => [f.id, { ...f }])); + const favorites = new Map( + favoriteRows.map((f) => [f.id, { uploadedIconMime: null, iconVersion: 0, ...f }]), + ); const widgets = new Map(widgetRows.map((w) => [w.id, { ...w }])); const boundCallLog: BoundCall[] = []; let autoId = favoriteRows.length; @@ -107,7 +121,16 @@ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWi boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'create' }); const id = data.id ?? `fav-${++autoId}`; const now = new Date(); - const record = { iconUrl: null, position: 0, createdAt: now, updatedAt: now, ...data, id }; + const record = { + iconUrl: null, + position: 0, + uploadedIconMime: null, + iconVersion: 0, + createdAt: now, + updatedAt: now, + ...data, + id, + }; favorites.set(id, record); return record; }, @@ -115,7 +138,12 @@ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWi boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'update' }); const row = favorites.get(where.id); if (!row || row.tenantId !== tenantId) throwP2025('update'); - const updated = { ...row, ...data, updatedAt: new Date() }; + const updated: any = { ...row, ...data, updatedAt: new Date() }; + // 260923-lrr: `iconVersion: { increment: n }` — Prisma's atomic + // increment form, angewendet auf den bisherigen Zaehlerstand. + if (data.iconVersion && typeof data.iconVersion === 'object' && 'increment' in data.iconVersion) { + updated.iconVersion = (row.iconVersion ?? 0) + data.iconVersion.increment; + } favorites.set(where.id, updated); return updated; }, @@ -625,4 +653,323 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => { expect(vi.mocked(withTenantTransaction).mock.calls.length).toBe(1); }); }); + + // --- 260923-lrr: eigenes Symbol, Vorrang, Versionszaehler, Abrufprobe --- + + describe('uploadIcon/removeUploadedIcon/getIconBytes — eigenes Symbol (260923-lrr)', () => { + let iconsDir: string; + const ORIGINAL_DIR_ENV = process.env.FAVORITE_ICONS_DIR; + const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]); + const SVG = Buffer.from(''); + const TEXT = Buffer.from('nur Text, kein Bild'); + + beforeEach(() => { + iconsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-svc-')); + process.env.FAVORITE_ICONS_DIR = iconsDir; + }); + + afterEach(() => { + fs.rmSync(iconsDir, { recursive: true, force: true }); + if (ORIGINAL_DIR_ENV === undefined) { + delete process.env.FAVORITE_ICONS_DIR; + } else { + process.env.FAVORITE_ICONS_DIR = ORIGINAL_DIR_ENV; + } + }); + + const baseRow: FakeFavoriteRow = { + id: 'f1', + userId: 'user-a1', + tenantId: 't1', + widgetId: 'widget-a1', + title: 'X', + url: 'https://x.invalid', + iconUrl: 'https://x.invalid/icon.png', + position: 0, + uploadedIconMime: null, + iconVersion: 0, + }; + + function fileFor(userId: string, id: string, ext: string): string { + return path.join(iconsDir, userId, `${id}.${ext}`); + } + + describe('uploadIcon', () => { + it('PNG: Datei liegt unter //.png mit genau den Bytes, Zeile hat uploadedIconMime image/png und iconVersion +1', async () => { + const prisma = makeFakePrisma([baseRow]); + const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); + const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length }; + + const updated = await service.uploadIcon('t1', 'f1', 'user-a1', file); + + expect(updated.uploadedIconMime).toBe('image/png'); + expect(updated.iconVersion).toBe(1); + const written = fs.readFileSync(fileFor('user-a1', 'f1', 'png')); + expect(written.equals(PNG)).toBe(true); + }); + + it('ohne Datei -> BadRequestException', async () => { + const prisma = makeFakePrisma([baseRow]); + const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); + + await expect(service.uploadIcon('t1', 'f1', 'user-a1', undefined)).rejects.toThrow( + BadRequestException, + ); + }); + + it('Klartext-Puffer -> BadRequestException, keine Datei, Zeile unveraendert', async () => { + const prisma = makeFakePrisma([baseRow]); + const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); + const file = { buffer: TEXT, originalname: 'x.txt', mimetype: 'text/plain', size: TEXT.length }; + + await expect(service.uploadIcon('t1', 'f1', 'user-a1', file)).rejects.toThrow( + BadRequestException, + ); + expect(fs.existsSync(path.join(iconsDir, 'user-a1'))).toBe(false); + expect(prisma.__favorites.get('f1').uploadedIconMime).toBeNull(); + }); + + it('Puffer groesser 512 KB -> PayloadTooLargeException (zweites Netz)', async () => { + const prisma = makeFakePrisma([baseRow]); + const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); + const big = Buffer.concat([PNG, Buffer.alloc(513 * 1024)]); + const file = { buffer: big, originalname: 'x.png', mimetype: 'image/png', size: big.length }; + + await expect(service.uploadIcon('t1', 'f1', 'user-a1', file)).rejects.toThrow( + PayloadTooLargeException, + ); + }); + + it('fremder Benutzer, fremder Mandant, unbekannte Kennung -> NotFoundException, keine Datei geschrieben', async () => { + const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length }; + + const prismaForeignUser = makeFakePrisma([{ ...baseRow, userId: 'user-a2' }]); + const serviceForeignUser = new FavoritesService( + prismaForeignUser as any, + makeIconDiscovery() as any, + ); + await expect( + serviceForeignUser.uploadIcon('t1', 'f1', 'user-a1', file), + ).rejects.toThrow(NotFoundException); + + const prismaForeignTenant = makeFakePrisma([baseRow]); + const serviceForeignTenant = new FavoritesService( + prismaForeignTenant as any, + makeIconDiscovery() as any, + ); + await expect( + serviceForeignTenant.uploadIcon('t2', 'f1', 'user-a1', file), + ).rejects.toThrow(NotFoundException); + + const prismaUnknown = makeFakePrisma([]); + const serviceUnknown = new FavoritesService(prismaUnknown as any, makeIconDiscovery() as any); + await expect( + serviceUnknown.uploadIcon('t1', 'fehlt', 'user-a1', file), + ).rejects.toThrow(NotFoundException); + + expect(fs.existsSync(path.join(iconsDir, 'user-a1'))).toBe(false); + expect(fs.existsSync(path.join(iconsDir, 'user-a2'))).toBe(false); + }); + + it('erneuter Upload mit anderem Typ (erst PNG, dann SVG): .png entfernt, .svg vorhanden, iconVersion insgesamt +2', async () => { + const prisma = makeFakePrisma([baseRow]); + const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); + const pngFile = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length }; + const svgFile = { buffer: SVG, originalname: 'x.svg', mimetype: 'image/svg+xml', size: SVG.length }; + + await service.uploadIcon('t1', 'f1', 'user-a1', pngFile); + const updated = await service.uploadIcon('t1', 'f1', 'user-a1', svgFile); + + expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false); + expect(fs.existsSync(fileFor('user-a1', 'f1', 'svg'))).toBe(true); + expect(updated.uploadedIconMime).toBe('image/svg+xml'); + expect(updated.iconVersion).toBe(2); + }); + }); + + describe('getIconBytes — Vorrang des hochgeladenen Symbols', () => { + it('hochgeladenes Symbol: liefert Dateibytes und gespeicherten Typ, fetchIconBytes wird NICHT aufgerufen', async () => { + const prisma = makeFakePrisma([baseRow]); + const iconDiscovery = makeIconDiscovery(); + const service = new FavoritesService(prisma as any, iconDiscovery as any); + const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length }; + await service.uploadIcon('t1', 'f1', 'user-a1', file); + + const result = await service.getIconBytes('t1', 'f1', 'user-a1'); + + expect(result.contentType).toBe('image/png'); + expect((result.body as Buffer).equals(PNG)).toBe(true); + expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled(); + }); + + it('Typ gesetzt, aber Datei fehlt, iconUrl vorhanden -> faellt auf fetchIconBytes(iconUrl) zurueck', async () => { + const prisma = makeFakePrisma([{ ...baseRow, uploadedIconMime: 'image/png' }]); + const iconDiscovery = makeIconDiscovery(); + const service = new FavoritesService(prisma as any, iconDiscovery as any); + + const result = await service.getIconBytes('t1', 'f1', 'user-a1'); + + expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith(baseRow.iconUrl); + expect(result).toEqual({ contentType: 'image/png', body: Buffer.from('png') }); + }); + + it('Typ gesetzt, Datei fehlt, KEINE iconUrl -> NotFoundException', async () => { + const prisma = makeFakePrisma([ + { ...baseRow, iconUrl: null, uploadedIconMime: 'image/png' }, + ]); + const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); + + await expect(service.getIconBytes('t1', 'f1', 'user-a1')).rejects.toThrow( + 'FavoriteLink not found', + ); + }); + }); + + describe('removeUploadedIcon', () => { + it('Datei weg, uploadedIconMime null, iconVersion +1', async () => { + const prisma = makeFakePrisma([baseRow]); + const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); + const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length }; + await service.uploadIcon('t1', 'f1', 'user-a1', file); + + const updated = await service.removeUploadedIcon('t1', 'f1', 'user-a1'); + + expect(updated.uploadedIconMime).toBeNull(); + expect(updated.iconVersion).toBe(2); + expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false); + }); + + it('ohne vorhandenen Upload -> Zeile unveraendert, keine Erhoehung', async () => { + const prisma = makeFakePrisma([baseRow]); + const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); + + const updated = await service.removeUploadedIcon('t1', 'f1', 'user-a1'); + + expect(updated.iconVersion).toBe(0); + expect(updated.uploadedIconMime).toBeNull(); + }); + + it('fremder Benutzer -> NotFoundException', async () => { + const prisma = makeFakePrisma([{ ...baseRow, userId: 'user-a2' }]); + const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); + + await expect(service.removeUploadedIcon('t1', 'f1', 'user-a1')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('remove() mit hochgeladenem Symbol', () => { + it('Zeile und Datei weg', async () => { + const prisma = makeFakePrisma([baseRow]); + const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); + const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length }; + await service.uploadIcon('t1', 'f1', 'user-a1', file); + + await service.remove('t1', 'f1', 'user-a1'); + + expect(prisma.__favorites.has('f1')).toBe(false); + expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false); + }); + + it('Fehler beim Datei-Entfernen wird geschluckt — das Loeschen der Zeile gelingt trotzdem', async () => { + const prisma = makeFakePrisma([baseRow]); + const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); + const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length }; + await service.uploadIcon('t1', 'f1', 'user-a1', file); + // Datei vorab entfernen, damit fs.unlink() im Dienst scheitert. + fs.unlinkSync(fileFor('user-a1', 'f1', 'png')); + + await expect(service.remove('t1', 'f1', 'user-a1')).resolves.toBeUndefined(); + expect(prisma.__favorites.has('f1')).toBe(false); + }); + }); + }); + + describe('create/update — Abrufprobe fuer eine explizite iconUrl (260923-lrr)', () => { + it('create mit expliziter iconUrl: Probe genau einmal; wirft -> UnprocessableEntityException, favoriteLink.create NICHT aufgerufen', async () => { + const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]); + const iconDiscovery = makeIconDiscovery({ + fetchIconBytes: vi.fn(async () => { + throw new Error('blocked'); + }), + }); + const service = new FavoritesService(prisma as any, iconDiscovery as any); + + await expect( + service.create('t1', 'user-a1', { + widgetId: 'widget-a1', + title: 'X', + url: 'https://x.invalid', + iconUrl: 'https://x.invalid/logo.png', + } as any), + ).rejects.toThrow(UnprocessableEntityException); + expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1); + expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://x.invalid/logo.png'); + expect(prisma.__favorites.size).toBe(0); + }); + + const baseRow: FakeFavoriteRow = { + id: 'f1', + userId: 'user-a1', + tenantId: 't1', + widgetId: 'widget-a1', + title: 'Alt', + url: 'https://alt.invalid', + iconUrl: 'https://alt.invalid/icon.png', + position: 0, + uploadedIconMime: null, + iconVersion: 0, + }; + + it('update mit neuer, abweichender iconUrl: fetchIconBytes genau einmal mit dieser Adresse; wirft -> UnprocessableEntityException, favoriteLink.update NICHT aufgerufen', async () => { + const prisma = makeFakePrisma([baseRow]); + const iconDiscovery = makeIconDiscovery({ + fetchIconBytes: vi.fn(async () => { + throw new Error('blocked'); + }), + }); + const service = new FavoritesService(prisma as any, iconDiscovery as any); + + await expect( + service.update('t1', 'f1', 'user-a1', { iconUrl: 'https://neu.invalid/icon.png' } as any), + ).rejects.toThrow(UnprocessableEntityException); + expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1); + expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://neu.invalid/icon.png'); + expect(prisma.__favorites.get('f1').iconUrl).toBe(baseRow.iconUrl); + }); + + it('update mit UNVERAENDERTER iconUrl: keine Probe, keine Erhoehung', async () => { + const prisma = makeFakePrisma([baseRow]); + const iconDiscovery = makeIconDiscovery(); + const service = new FavoritesService(prisma as any, iconDiscovery as any); + + const updated = await service.update('t1', 'f1', 'user-a1', { iconUrl: baseRow.iconUrl } as any); + + expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled(); + expect(updated.iconVersion).toBe(0); + }); + + it('update nur Titel: keine Erhoehung', async () => { + const prisma = makeFakePrisma([baseRow]); + const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); + + const updated = await service.update('t1', 'f1', 'user-a1', { title: 'Neu' } as any); + + expect(updated.iconVersion).toBe(0); + }); + + it('update mit neuer, erreichbarer iconUrl: iconVersion +1', async () => { + const prisma = makeFakePrisma([baseRow]); + const iconDiscovery = makeIconDiscovery(); + const service = new FavoritesService(prisma as any, iconDiscovery as any); + + const updated = await service.update('t1', 'f1', 'user-a1', { + iconUrl: 'https://neu.invalid/icon.png', + } as any); + + expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://neu.invalid/icon.png'); + expect(updated.iconVersion).toBe(1); + }); + }); }); diff --git a/apps/api/src/favorites/favorites.service.ts b/apps/api/src/favorites/favorites.service.ts index 4a99827..3a51b22 100644 --- a/apps/api/src/favorites/favorites.service.ts +++ b/apps/api/src/favorites/favorites.service.ts @@ -1,15 +1,27 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; import { BadRequestException, HttpException, HttpStatus, Injectable, + InternalServerErrorException, + Logger, NotFoundException, + PayloadTooLargeException, + UnprocessableEntityException, } 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'; /** @@ -50,9 +62,31 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service'; * 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. + * - Abrufprobe: `assertIconUrlLoadable()` ruft `fetchIconBytes` einmal ab, + * um eine im Formular NICHT abrufbare Logo-Adresse (z. B. hinter einer + * Cloudflare-Pruefung) mit `UnprocessableEntityException` (422) statt + * stiller Speicherung abzuweisen — keine Umgehung von Bot-Sperren, nur + * derselbe Abruf, den `GET /favorites/:id/icon` ohnehin ausloest. */ @Injectable() export class FavoritesService { + private readonly logger = new Logger(FavoritesService.name); + constructor( private readonly prisma: PrismaService, private readonly iconDiscovery: IconDiscoveryService, @@ -72,11 +106,31 @@ export class FavoritesService { }); } + /** + * Prueft, ob sich das Bild unter `iconUrl` serverseitig abrufen laesst + * (260923-lrr) — derselbe `fetchIconBytes`-Aufruf, den `getIconBytes` + * ohnehin ausloest, hier nur zur Speicherzeit als Probe. Jeder Fehler + * (SSRF-Ablehnung, Zeitgrenze, kein `image/*`, Cloudflare-Pruefung o. ae.) + * wird zu derselben deutschen 422-Meldung — keine Unterscheidung, aus der + * sich etwas ueber die gepruefte Adresse ablesen liesse. + */ + private async assertIconUrlLoadable(iconUrl: string): Promise { + try { + await this.iconDiscovery.fetchIconBytes(iconUrl); + } catch { + throw new UnprocessableEntityException( + 'Das Bild unter dieser Adresse konnte nicht geladen werden. Die Seite blockiert vermutlich automatische Abrufe (zum Beispiel durch eine Cloudflare-Prüfung) oder ist nicht erreichbar. Bitte laden Sie das Symbol stattdessen hoch.', + ); + } + } + /** * 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 (260923-lrr), it must load successfully or the + * create is rejected with 422 — nothing is written on a failed probe. */ async create(tenantId: string, userId: string, dto: CreateFavoriteDto) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); @@ -99,8 +153,11 @@ export class FavoritesService { const url = normalizeUrl(dto.url); let iconUrl = dto.iconUrl ?? null; - // Server-side icon discovery (D-05) — only when caller did not supply an icon - if (!iconUrl) { + if (iconUrl) { + // 260923-lrr: explizit uebergebene Adresse wird einmal probiert. + await this.assertIconUrlLoadable(iconUrl); + } else { + // Server-side icon discovery (D-05) — only when caller did not supply an icon iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url); } @@ -121,6 +178,11 @@ export class FavoritesService { * Updates an existing favorite. * Verifies userId ownership before applying changes (T-08-06). * Accepts null as an explicit value for iconUrl (clears stored icon). + * + * 260923-lrr: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl` + * durchlaeuft die Abrufprobe (`assertIconUrlLoadable`), bevor irgendetwas + * geschrieben wird; misslingt sie, bleibt die Zeile unveraendert. Jede + * tatsaechliche Aenderung der Symbolquelle erhoeht `iconVersion`. */ async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); @@ -142,6 +204,10 @@ export class FavoritesService { if ('iconUrl' in dto) { if (dto.iconUrl) { + if (dto.iconUrl !== link.iconUrl) { + // 260923-lrr: nur eine NEUE, abweichende Adresse wird probiert. + await this.assertIconUrlLoadable(dto.iconUrl); + } // Explicit icon URL supplied — respect it as-is. data.iconUrl = dto.iconUrl; } else { @@ -153,6 +219,10 @@ export class FavoritesService { } } + if (data.iconUrl !== undefined && data.iconUrl !== link.iconUrl) { + data.iconVersion = { increment: 1 }; + } + return tenantPrisma.favoriteLink.update({ where: { id }, data, @@ -162,6 +232,10 @@ export class FavoritesService { /** * 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); @@ -172,6 +246,10 @@ export class FavoritesService { } await tenantPrisma.favoriteLink.delete({ where: { id } }); + + if (link.uploadedIconMime !== null) { + await this.removeIconFile(id, link.userId, link.uploadedIconMime, 'geloeschten'); + } } /** @@ -242,16 +320,148 @@ export class FavoritesService { }); } + /** + * 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 no icon on record. Throws a 502 HttpException - * if the upstream fetch fails (unreachable, timeout, non-image, or - * SSRF-blocked) -- never returns a placeholder image. + * 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) -- never returns a placeholder image. */ async getIconBytes( tenantId: string, @@ -261,7 +471,29 @@ export class FavoritesService { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } }); - if (!link || link.userId !== userId || !link.iconUrl) { + 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'); } diff --git a/docs/anleitung-betrieb.md b/docs/anleitung-betrieb.md index b85b94f..78adf6a 100644 --- a/docs/anleitung-betrieb.md +++ b/docs/anleitung-betrieb.md @@ -285,9 +285,12 @@ Restores stattfinden. ihn sind alle per `pg_dump` gesicherten verschlüsselten Zugangsdaten wertlos. - **Hochgeladene Dateien** (Avatare unter `user-files/avatars/`, Bilder des Bilderrahmen-Widgets unter `user-files/dashboard-images//`, - generierte DKV-Exporte unter `user-files/`, siehe + Symbole des Favoriten-Widgets unter + `user-files/favorite-icons//`, generierte DKV-Exporte unter + `user-files/`, siehe `apps/api/src/user/user.controller.ts`, - `apps/api/src/dashboard/dashboard-images.service.ts` und + `apps/api/src/dashboard/dashboard-images.service.ts`, + `apps/api/src/favorites/favorites.service.ts` und `apps/api/src/dkv/dkv-export.service.ts`): Diese Dateien liegen im benannten Docker-Volume `user-files`, gemountet auf `/app/user-files` im Dienst `api`. Der Mount ist in `docker-compose.yml` und `docker-compose.prod.yml` eingetragen: