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
|
title String
|
||||||
url String
|
url String
|
||||||
iconUrl 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)
|
position Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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
|
* 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 { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { favoriteIconAbsolutePath } from '../favorites/favorite-icon-files';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { DashboardService } from './dashboard.service';
|
import { DashboardService } from './dashboard.service';
|
||||||
|
|
||||||
@@ -52,8 +56,16 @@ import { DashboardService } from './dashboard.service';
|
|||||||
* Herkunfts-Tenant protokollierenden Wrapper versieht. `module` ist bewusst
|
* Herkunfts-Tenant protokollierenden Wrapper versieht. `module` ist bewusst
|
||||||
* NICHT enthalten — der Katalogzugriff bleibt ungebunden. `searchProvider`
|
* NICHT enthalten — der Katalogzugriff bleibt ungebunden. `searchProvider`
|
||||||
* ergaenzt seit Aufgabe 3 (260910-krx). `dashboard` ergaenzt seit
|
* ergaenzt seit Aufgabe 3 (260910-krx). `dashboard` ergaenzt seit
|
||||||
* quick-260923-ad9 (Task 1). */
|
* quick-260923-ad9 (Task 1). `favoriteLink` ergaenzt seit quick-260923-lrr
|
||||||
const BOUND_MODEL_NAMES = ['dashboard', 'dashboardLayout', 'widgetInstance', 'searchProvider'];
|
* (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
|
/** Standard-Reiter-Kennung, die die meisten Tests verwenden — ein Reiter
|
||||||
* `dash-1`, der `user-1`/`tenant-1` gehört (Standard-Fixture unten). */
|
* `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(
|
function makeFakePrisma(
|
||||||
opts: {
|
opts: {
|
||||||
widgets?: ReturnType<typeof makeWidget>[];
|
widgets?: ReturnType<typeof makeWidget>[];
|
||||||
@@ -132,12 +165,14 @@ function makeFakePrisma(
|
|||||||
layout?: { dashboardId: string; userId: string; tenantId: string; layouts: unknown } | null;
|
layout?: { dashboardId: string; userId: string; tenantId: string; layouts: unknown } | null;
|
||||||
searchProviders?: ReturnType<typeof makeSearchProvider>[];
|
searchProviders?: ReturnType<typeof makeSearchProvider>[];
|
||||||
dashboards?: ReturnType<typeof makeDashboard>[];
|
dashboards?: ReturnType<typeof makeDashboard>[];
|
||||||
|
favorites?: ReturnType<typeof makeFavorite>[];
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const widgets = opts.widgets ?? [];
|
const widgets = opts.widgets ?? [];
|
||||||
const modules = opts.modules ?? [];
|
const modules = opts.modules ?? [];
|
||||||
let layoutRow = opts.layout ?? null;
|
let layoutRow = opts.layout ?? null;
|
||||||
const searchProviders = opts.searchProviders ?? [];
|
const searchProviders = opts.searchProviders ?? [];
|
||||||
|
const favorites = opts.favorites ?? [];
|
||||||
// Standard-Fixture: GENAU EIN Reiter `dash-1`, der user-1/tenant-1 gehört
|
// 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.
|
// — die meisten Tests wollen sich um Reiter-Verwaltung nicht kümmern.
|
||||||
// Tests, die eine andere Besitzlage brauchen (fremder Reiter, ADMIN mit
|
// Tests, die eine andere Besitzlage brauchen (fremder Reiter, ADMIN mit
|
||||||
@@ -256,6 +291,24 @@ function makeFakePrisma(
|
|||||||
return removed;
|
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: {
|
module: {
|
||||||
findMany: vi.fn(async ({ where }: any) => {
|
findMany: vi.fn(async ({ where }: any) => {
|
||||||
const slugs: string[] = where.slug.in;
|
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 () => {
|
it('keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf', async () => {
|
||||||
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
||||||
const prisma = makeFakePrisma({
|
const prisma = makeFakePrisma({
|
||||||
@@ -1234,6 +1350,63 @@ describe('DashboardService.deleteDashboard — Reiter löschen (quick-260923-ad9
|
|||||||
NotFoundException,
|
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)', () => {
|
describe('DashboardService.reorderDashboards — Reiter umsortieren (quick-260923-ad9, Task 2, T-AD9-04)', () => {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Prisma, Role } from '@prisma/client';
|
import { Prisma, Role } from '@prisma/client';
|
||||||
|
import { removeFavoriteIconFileBestEffort } from '../favorites/favorite-icon-files';
|
||||||
import { ModuleAccessService } from '../module-registry/module-access.service';
|
import { ModuleAccessService } from '../module-registry/module-access.service';
|
||||||
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -91,11 +93,40 @@ const DEFAULT_SEARCH_PROVIDERS = [
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DashboardService {
|
export class DashboardService {
|
||||||
|
private readonly logger = new Logger(DashboardService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly moduleAccessService: ModuleAccessService,
|
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
|
* Reiter (quick-260923-ad9, D-01/D-08/D-09): liest die Dashboards des
|
||||||
* Benutzers, nach `position` aufsteigend — Position 0 ist der Standard
|
* 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.');
|
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.widgetInstance.deleteMany({ where: { dashboardId: id, userId } });
|
||||||
await tx.dashboardLayout.deleteMany({ where: { dashboardId: id, userId } });
|
await tx.dashboardLayout.deleteMany({ where: { dashboardId: id, userId } });
|
||||||
await tx.dashboard.deleteMany({ where: { id, userId } });
|
await tx.dashboard.deleteMany({ where: { id, userId } });
|
||||||
@@ -262,6 +311,10 @@ export class DashboardService {
|
|||||||
|
|
||||||
return { id };
|
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
|
* Verifies ownership by userId before deleting (T-05-01) — same real
|
||||||
* ownership check as `updateWidgetConfig` above, same reasoning: both
|
* ownership check as `updateWidgetConfig` above, same reasoning: both
|
||||||
* queries run over the SAME bound client and tenant id.
|
* 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) {
|
async removeWidget(id: string, userId: string, tenantId: string) {
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
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 },
|
where: { id },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.cleanUpFavoriteIconFiles(userId, iconRows);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Search Providers (05-02, D-15) ---
|
// --- 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,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { Response } from '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 { CreateFavoriteDto } from './dto/create-favorite.dto';
|
||||||
import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
|
import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
|
||||||
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
||||||
|
import { FAVORITE_ICON_MAX_BYTES } from './favorite-icon-files';
|
||||||
import { FavoritesService } from './favorites.service';
|
import { FavoritesService } from './favorites.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,7 +43,10 @@ import { FavoritesService } from './favorites.service';
|
|||||||
* - GET /favorites?widgetId= — list favorites for a widget instance
|
* - GET /favorites?widgetId= — list favorites for a widget instance
|
||||||
* - POST /favorites — create a favorite (triggers server-side icon discovery)
|
* - POST /favorites — create a favorite (triggers server-side icon discovery)
|
||||||
* - PUT /favorites/order — reorder favorites for a widget instance (260917-jdd)
|
* - 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)
|
* - PATCH /favorites/:id — update a favorite (ownership verified in service)
|
||||||
* - DELETE /favorites/:id — delete 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('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
|
// 260917-jdd: die Bytes kommen jetzt auch von Hosts ohne gueltiges
|
||||||
// Zertifikat. Als <img>-Unterressource ignoriert der Browser diese
|
// Zertifikat. Als <img>-Unterressource ignoriert der Browser diese
|
||||||
// Header, aber ein direkt im Tab geoeffnetes SVG laeuft damit ohne
|
// Header, aber ein direkt im Tab geoeffnetes SVG laeuft damit ohne
|
||||||
@@ -132,6 +144,42 @@ export class FavoritesController {
|
|||||||
res.send(body);
|
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')
|
@Patch(':id')
|
||||||
async update(
|
async update(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { BadRequestException, HttpException, NotFoundException } from '@nestjs/common';
|
import * as fs from 'node:fs';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
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 { FavoritesService } from './favorites.service';
|
||||||
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||||
|
|
||||||
@@ -39,6 +48,9 @@ interface FakeFavoriteRow {
|
|||||||
position: number;
|
position: number;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
|
/** 260923-lrr — Bestandszeilen im Fake bekommen die Vorgabe null/0. */
|
||||||
|
uploadedIconMime?: string | null;
|
||||||
|
iconVersion?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FakeWidgetRow {
|
interface FakeWidgetRow {
|
||||||
@@ -71,7 +83,9 @@ function throwP2025(action: 'update' | 'delete'): never {
|
|||||||
* deshalb strukturell nie.
|
* deshalb strukturell nie.
|
||||||
*/
|
*/
|
||||||
function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWidgetRow[] = []) {
|
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 widgets = new Map(widgetRows.map((w) => [w.id, { ...w }]));
|
||||||
const boundCallLog: BoundCall[] = [];
|
const boundCallLog: BoundCall[] = [];
|
||||||
let autoId = favoriteRows.length;
|
let autoId = favoriteRows.length;
|
||||||
@@ -107,7 +121,16 @@ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWi
|
|||||||
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'create' });
|
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'create' });
|
||||||
const id = data.id ?? `fav-${++autoId}`;
|
const id = data.id ?? `fav-${++autoId}`;
|
||||||
const now = new Date();
|
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);
|
favorites.set(id, record);
|
||||||
return record;
|
return record;
|
||||||
},
|
},
|
||||||
@@ -115,7 +138,12 @@ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWi
|
|||||||
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'update' });
|
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'update' });
|
||||||
const row = favorites.get(where.id);
|
const row = favorites.get(where.id);
|
||||||
if (!row || row.tenantId !== tenantId) throwP2025('update');
|
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);
|
favorites.set(where.id, updated);
|
||||||
return updated;
|
return updated;
|
||||||
},
|
},
|
||||||
@@ -625,4 +653,323 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
|||||||
expect(vi.mocked(withTenantTransaction).mock.calls.length).toBe(1);
|
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 {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
PayloadTooLargeException,
|
||||||
|
UnprocessableEntityException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||||
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
||||||
import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
|
import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
|
||||||
import { UpdateFavoriteDto } from './dto/update-favorite.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';
|
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
|
* Mandantengrenzen. Der Riegel antwortet fuer alle drei Faelle
|
||||||
* ("existiert nicht", "gehoert einem Kollegen", "liegt bei einem fremden
|
* ("existiert nicht", "gehoert einem Kollegen", "liegt bei einem fremden
|
||||||
* Mandanten") mit derselben `NotFoundException('Widget not found')`.
|
* 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()
|
@Injectable()
|
||||||
export class FavoritesService {
|
export class FavoritesService {
|
||||||
|
private readonly logger = new Logger(FavoritesService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly iconDiscovery: IconDiscoveryService,
|
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.
|
* Creates a new favorite link.
|
||||||
* Verifies the target widget belongs to the caller BEFORE any icon
|
* Verifies the target widget belongs to the caller BEFORE any icon
|
||||||
* discovery network call (T-GWH-05).
|
* discovery network call (T-GWH-05).
|
||||||
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
|
* 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) {
|
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
@@ -99,8 +153,11 @@ export class FavoritesService {
|
|||||||
const url = normalizeUrl(dto.url);
|
const url = normalizeUrl(dto.url);
|
||||||
let iconUrl = dto.iconUrl ?? null;
|
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);
|
iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +178,11 @@ export class FavoritesService {
|
|||||||
* Updates an existing favorite.
|
* Updates an existing favorite.
|
||||||
* Verifies userId ownership before applying changes (T-08-06).
|
* Verifies userId ownership before applying changes (T-08-06).
|
||||||
* Accepts null as an explicit value for iconUrl (clears stored icon).
|
* 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) {
|
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
@@ -142,6 +204,10 @@ export class FavoritesService {
|
|||||||
|
|
||||||
if ('iconUrl' in dto) {
|
if ('iconUrl' in dto) {
|
||||||
if (dto.iconUrl) {
|
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.
|
// Explicit icon URL supplied — respect it as-is.
|
||||||
data.iconUrl = dto.iconUrl;
|
data.iconUrl = dto.iconUrl;
|
||||||
} else {
|
} else {
|
||||||
@@ -153,6 +219,10 @@ export class FavoritesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.iconUrl !== undefined && data.iconUrl !== link.iconUrl) {
|
||||||
|
data.iconVersion = { increment: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
return tenantPrisma.favoriteLink.update({
|
return tenantPrisma.favoriteLink.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
@@ -162,6 +232,10 @@ export class FavoritesService {
|
|||||||
/**
|
/**
|
||||||
* Deletes a favorite link.
|
* Deletes a favorite link.
|
||||||
* Verifies userId ownership before deleting (T-08-06).
|
* 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) {
|
async remove(tenantId: string, id: string, userId: string) {
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
@@ -172,6 +246,10 @@ export class FavoritesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await tenantPrisma.favoriteLink.delete({ where: { id } });
|
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
|
* Fetches the raw bytes of a favorite's stored icon, scoped to the
|
||||||
* requesting user (T-08-06 — same ownership check as update/remove).
|
* requesting user (T-08-06 — same ownership check as update/remove).
|
||||||
* Never accepts a client-supplied URL — only the stored iconUrl on a
|
* Never accepts a client-supplied URL — only the stored iconUrl on a
|
||||||
* row the caller owns is fetched (T-QFIP-01).
|
* 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
|
* 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
|
* by the caller, or has neither an uploaded icon nor a stored iconUrl.
|
||||||
* if the upstream fetch fails (unreachable, timeout, non-image, or
|
* Throws a 502 HttpException if the upstream fetch fails (unreachable,
|
||||||
* SSRF-blocked) -- never returns a placeholder image.
|
* timeout, non-image, or SSRF-blocked) -- never returns a placeholder image.
|
||||||
*/
|
*/
|
||||||
async getIconBytes(
|
async getIconBytes(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
@@ -261,7 +471,29 @@ export class FavoritesService {
|
|||||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
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');
|
throw new NotFoundException('FavoriteLink not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -285,9 +285,12 @@ Restores stattfinden.
|
|||||||
ihn sind alle per `pg_dump` gesicherten verschlüsselten Zugangsdaten wertlos.
|
ihn sind alle per `pg_dump` gesicherten verschlüsselten Zugangsdaten wertlos.
|
||||||
- **Hochgeladene Dateien** (Avatare unter `user-files/avatars/`, Bilder des
|
- **Hochgeladene Dateien** (Avatare unter `user-files/avatars/`, Bilder des
|
||||||
Bilderrahmen-Widgets unter `user-files/dashboard-images/<Benutzerkennung>/`,
|
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/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
|
`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
|
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:
|
Mount ist in `docker-compose.yml` und `docker-compose.prod.yml` eingetragen:
|
||||||
|
|||||||
Reference in New Issue
Block a user