feat(260923-lrr): API — Favoriten-Symbol hochladen, Vorrang, Versionszaehler, Abrufprobe

- FavoriteLink: neue Spalten uploadedIconMime/iconVersion (Migration 20260923160000)
- favorite-icon-files.ts: Erkennung PNG/JPEG/GIF/WebP/ICO/SVG, Pfadbildung ohne
  Byte aus der Anfrage im Pfad (T-LRR-01), best-effort Dateientfernung
- FavoritesService: uploadIcon/removeUploadedIcon, Vorrang der hochgeladenen
  Datei in getIconBytes, Abrufprobe fuer eine neue iconUrl (422 statt stiller
  Speicherung), iconVersion-Erhoehung bei jeder Aenderung der Symbolquelle
- FavoritesController: POST/DELETE /favorites/:id/icon, Cache-Control private
- T-LRR-07 (Restrisiko aus dem Plan-Threat-Model geschlossen, ueber den Plan
  hinaus): DashboardService.removeWidget/deleteDashboard raeumen jetzt die
  Symboldateien der per Datenbank-Kaskade mitgeloeschten Favoriten auf
  (best effort, nie blockierend)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 16:04:29 +02:00
parent bf4384ad73
commit 7704372c3c
11 changed files with 1454 additions and 21 deletions
@@ -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/<userId>/<id>.<ext>" (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=<iconVersion>). 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;
+10
View File
@@ -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/<userId>/<id>.<ext>.
uploadedIconMime String?
// quick-260923-lrr: Zaehler fuer die ausgelieferte Symbol-Adresse
// (?v=<iconVersion>), 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
@@ -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<typeof makeWidget>[];
@@ -132,12 +165,14 @@ function makeFakePrisma(
layout?: { dashboardId: string; userId: string; tenantId: string; layouts: unknown } | null;
searchProviders?: ReturnType<typeof makeSearchProvider>[];
dashboards?: ReturnType<typeof makeDashboard>[];
favorites?: ReturnType<typeof makeFavorite>[];
} = {},
) {
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)', () => {
+69 -2
View File
@@ -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<void> {
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) ---
@@ -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 `<html>` 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('<svg xmlns="http://www.w3.org/2000/svg"></svg>'))).toBe(
'image/svg+xml',
);
expect(
detectFavoriteIconMime(bytes('<?xml version="1.0"?>\n<svg xmlns="http://www.w3.org/2000/svg"/>')),
).toBe('image/svg+xml');
// fuehrendes BOM
expect(
detectFavoriteIconMime(bytes([0xef, 0xbb, 0xbf], '<svg xmlns="http://www.w3.org/2000/svg"></svg>')),
).toBe('image/svg+xml');
// fuehrendes Leerzeichen
expect(detectFavoriteIconMime(bytes(' <svg></svg>'))).toBe('image/svg+xml');
// Kommentar vor <svg
expect(
detectFavoriteIconMime(bytes('<!-- Kommentar -->\n<svg xmlns="http://www.w3.org/2000/svg"></svg>')),
).toBe('image/svg+xml');
// DOCTYPE svg vor <svg
expect(
detectFavoriteIconMime(
bytes(
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n<svg></svg>',
),
),
).toBe('image/svg+xml');
});
it('ungueltige Formen ergeben null', () => {
expect(detectFavoriteIconMime(bytes('<html><svg></svg></html>'))).toBeNull();
expect(detectFavoriteIconMime(bytes('<!DOCTYPE html>\n<html></html>'))).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()/<userId>/<id>.<ext>', () => {
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);
});
});
@@ -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 `<svg` selbst).
*
* Wie bei `dashboard-image-rules.ts` (T-PI9-01/T-PI9-08): was hier NICHT
* erkannt wird, kommt nicht auf die Platte — und der erkannte Typ ist
* zugleich der Typ, mit dem `GET /favorites/:id/icon` spaeter antwortet.
*
* Bewusst KEIN `file-type`-Paket (Muster T-PI9-SC): sechs feste Regeln sind
* eine Handvoll Zeilen und brauchen keine Abhaengigkeit.
*/
/** Hoechstgroesse je Datei: 512 KiB (multer `limits.fileSize` an der Route, zweites Netz im Dienst). */
export const FAVORITE_ICON_MAX_BYTES = 512 * 1024;
export type FavoriteIconMime =
| 'image/png'
| 'image/jpeg'
| 'image/gif'
| 'image/webp'
| 'image/x-icon'
| 'image/svg+xml';
/** ICO-Kopfstueck: Reserviert=0, Typ=1 (Icon). Typ=2 waere CUR (Cursor) — bewusst NICHT erkannt. */
const ICO_HEADER = [0x00, 0x00, 0x01, 0x00];
/**
* Praefix-Form eines SVG-Dokuments: optionales BOM/Leerraum, optionale
* XML-Deklaration, beliebig viele Kommentare und/oder ein DOCTYPE mit
* Wurzel `svg` (in beliebiger Reihenfolge/Wiederholung), danach `<svg`
* direkt gefolgt von Leerraum, `>` oder `/`. Alles andere (z. B. `<html>`
* vor `<svg>`, ein DOCTYPE auf `html`) ergibt kein Treffer.
*/
const SVG_PREFIX_RE =
/^(?:<\?xml[^>]*\?>\s*)?(?:(?:<!--[\s\S]*?-->|<!DOCTYPE\s+svg\b[^>]*>)\s*)*<svg[\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 `<resolveFavoriteIconsDir()>/<userId>/<id>.<ext>`.
* `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<boolean> {
const absolute = favoriteIconAbsolutePath(userId, id, mime);
if (absolute === null) return false;
try {
await fs.unlink(absolute);
return true;
} catch {
return false;
}
}
@@ -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<string, string> = {};
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
});
});
+51 -3
View File
@@ -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=<iconVersion>`
* 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=<iconVersion>` (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 <img>-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,
@@ -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('<svg xmlns="http://www.w3.org/2000/svg"></svg>');
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 <dir>/<userId>/<id>.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);
});
});
});
+238 -6
View File
@@ -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/<userId>/<id>.<ext>`, 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<void> {
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<void> {
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');
}