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:
@@ -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;
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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/<Benutzerkennung>/`,
|
||||
generierte DKV-Exporte unter `user-files/`, siehe
|
||||
Symbole des Favoriten-Widgets unter
|
||||
`user-files/favorite-icons/<Benutzerkennung>/`, 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:
|
||||
|
||||
Reference in New Issue
Block a user