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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 16:04:29 +02:00
parent bf4384ad73
commit 7704372c3c
11 changed files with 1454 additions and 21 deletions
+238 -6
View File
@@ -1,15 +1,27 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import {
BadRequestException,
HttpException,
HttpStatus,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
PayloadTooLargeException,
UnprocessableEntityException,
} from '@nestjs/common';
import type { UploadedFileLike } from '../auth/types/auth-user';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
import { CreateFavoriteDto } from './dto/create-favorite.dto';
import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
import {
FAVORITE_ICON_MAX_BYTES,
detectFavoriteIconMime,
favoriteIconAbsolutePath,
} from './favorite-icon-files';
import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
/**
@@ -50,9 +62,31 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
* Mandantengrenzen. Der Riegel antwortet fuer alle drei Faelle
* ("existiert nicht", "gehoert einem Kollegen", "liegt bei einem fremden
* Mandanten") mit derselben `NotFoundException('Widget not found')`.
*
* 260923-lrr — eigenes Symbol, Vorrang, Versionszaehler, Abrufprobe:
* - Ablage nach dem Muster `dashboard-images.service.ts` (quick-260922-hk4):
* `user-files/favorite-icons/<userId>/<id>.<ext>`, Dateiname IMMER aus
* Zeilen-UUID und ERKANNTEM Typ, nie aus der Anfrage (T-LRR-01).
* - Vorrang: `getIconBytes` liefert bei gesetztem `uploadedIconMime` immer
* die Datei, nie `fetchIconBytes` — fehlt die Datei trotz gesetztem Typ,
* wird protokolliert und auf `iconUrl` zurueckgefallen.
* - `iconVersion` steigt (Prisma `{ increment: 1 }`) genau dann, wenn sich
* die angezeigte Quelle aendert (neue, abweichende `iconUrl`; Upload;
* Entfernen des Uploads) — nicht bei Titel/Position/unveraenderter URL.
* - Halbe Zustaende (T-LRR-08, Muster T-HK4-04): Upload schreibt zuerst die
* Datei, dann die Zeile; scheitert die Zeile, wird die neue Datei wieder
* entfernt. Entfernen/Loeschen aktualisiert zuerst die Zeile, ein
* Dateifehler wird protokolliert und geschluckt.
* - Abrufprobe: `assertIconUrlLoadable()` ruft `fetchIconBytes` einmal ab,
* um eine im Formular NICHT abrufbare Logo-Adresse (z. B. hinter einer
* Cloudflare-Pruefung) mit `UnprocessableEntityException` (422) statt
* stiller Speicherung abzuweisen — keine Umgehung von Bot-Sperren, nur
* derselbe Abruf, den `GET /favorites/:id/icon` ohnehin ausloest.
*/
@Injectable()
export class FavoritesService {
private readonly logger = new Logger(FavoritesService.name);
constructor(
private readonly prisma: PrismaService,
private readonly iconDiscovery: IconDiscoveryService,
@@ -72,11 +106,31 @@ export class FavoritesService {
});
}
/**
* Prueft, ob sich das Bild unter `iconUrl` serverseitig abrufen laesst
* (260923-lrr) — derselbe `fetchIconBytes`-Aufruf, den `getIconBytes`
* ohnehin ausloest, hier nur zur Speicherzeit als Probe. Jeder Fehler
* (SSRF-Ablehnung, Zeitgrenze, kein `image/*`, Cloudflare-Pruefung o. ae.)
* wird zu derselben deutschen 422-Meldung — keine Unterscheidung, aus der
* sich etwas ueber die gepruefte Adresse ablesen liesse.
*/
private async assertIconUrlLoadable(iconUrl: string): Promise<void> {
try {
await this.iconDiscovery.fetchIconBytes(iconUrl);
} catch {
throw new UnprocessableEntityException(
'Das Bild unter dieser Adresse konnte nicht geladen werden. Die Seite blockiert vermutlich automatische Abrufe (zum Beispiel durch eine Cloudflare-Prüfung) oder ist nicht erreichbar. Bitte laden Sie das Symbol stattdessen hoch.',
);
}
}
/**
* Creates a new favorite link.
* Verifies the target widget belongs to the caller BEFORE any icon
* discovery network call (T-GWH-05).
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
* If iconUrl IS provided (260923-lrr), it must load successfully or the
* create is rejected with 422 — nothing is written on a failed probe.
*/
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
@@ -99,8 +153,11 @@ export class FavoritesService {
const url = normalizeUrl(dto.url);
let iconUrl = dto.iconUrl ?? null;
// Server-side icon discovery (D-05) — only when caller did not supply an icon
if (!iconUrl) {
if (iconUrl) {
// 260923-lrr: explizit uebergebene Adresse wird einmal probiert.
await this.assertIconUrlLoadable(iconUrl);
} else {
// Server-side icon discovery (D-05) — only when caller did not supply an icon
iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url);
}
@@ -121,6 +178,11 @@ export class FavoritesService {
* Updates an existing favorite.
* Verifies userId ownership before applying changes (T-08-06).
* Accepts null as an explicit value for iconUrl (clears stored icon).
*
* 260923-lrr: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl`
* durchlaeuft die Abrufprobe (`assertIconUrlLoadable`), bevor irgendetwas
* geschrieben wird; misslingt sie, bleibt die Zeile unveraendert. Jede
* tatsaechliche Aenderung der Symbolquelle erhoeht `iconVersion`.
*/
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
@@ -142,6 +204,10 @@ export class FavoritesService {
if ('iconUrl' in dto) {
if (dto.iconUrl) {
if (dto.iconUrl !== link.iconUrl) {
// 260923-lrr: nur eine NEUE, abweichende Adresse wird probiert.
await this.assertIconUrlLoadable(dto.iconUrl);
}
// Explicit icon URL supplied — respect it as-is.
data.iconUrl = dto.iconUrl;
} else {
@@ -153,6 +219,10 @@ export class FavoritesService {
}
}
if (data.iconUrl !== undefined && data.iconUrl !== link.iconUrl) {
data.iconVersion = { increment: 1 };
}
return tenantPrisma.favoriteLink.update({
where: { id },
data,
@@ -162,6 +232,10 @@ export class FavoritesService {
/**
* Deletes a favorite link.
* Verifies userId ownership before deleting (T-08-06).
* 260923-lrr: hat die Zeile ein hochgeladenes Symbol, wird dessen Datei
* NACH dem Loeschen der Zeile entfernt — ein Dateifehler wird
* protokolliert und geschluckt (Muster T-HK4-04), das Loeschen der Zeile
* gelingt in jedem Fall.
*/
async remove(tenantId: string, id: string, userId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
@@ -172,6 +246,10 @@ export class FavoritesService {
}
await tenantPrisma.favoriteLink.delete({ where: { id } });
if (link.uploadedIconMime !== null) {
await this.removeIconFile(id, link.userId, link.uploadedIconMime, 'geloeschten');
}
}
/**
@@ -242,16 +320,148 @@ export class FavoritesService {
});
}
/**
* Nimmt ein eigenes Symbol fuer einen Favoriten an (260923-lrr). Reihenfolge
* (Muster T-HK4-04): Groesse/Typ zuerst (kein DB-Zugriff bei offensichtlich
* ungueltiger Datei), dann Besitzpruefung, dann Datei, dann Zeile —
* scheitert die Zeile, wird eine neu geschriebene Datei zurueckgenommen.
* Hatte der Favorit vorher ein Symbol MIT ANDERER Endung, wird die alte
* Datei danach entfernt (Fehler protokolliert und geschluckt).
*/
async uploadIcon(
tenantId: string,
id: string,
userId: string,
file: UploadedFileLike | undefined,
) {
if (!file) {
throw new BadRequestException('Bitte wählen Sie eine Bilddatei aus.');
}
if (file.buffer.length > FAVORITE_ICON_MAX_BYTES) {
// Zweites Netz — multer (`limits.fileSize` an der Route) faengt das
// in der Regel bereits vorher ab.
throw new PayloadTooLargeException(
'Die Datei ist zu groß – erlaubt sind höchstens 512 KB.',
);
}
const mime = detectFavoriteIconMime(file.buffer);
if (mime === null) {
throw new BadRequestException(
'Nur Bilder im Format PNG, JPEG, GIF, WebP, ICO oder SVG sind erlaubt.',
);
}
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
if (!link || link.userId !== userId || link.tenantId !== tenantId) {
throw new NotFoundException('FavoriteLink not found');
}
const absolute = favoriteIconAbsolutePath(link.userId, link.id, mime);
if (absolute === null) {
throw new InternalServerErrorException('Das Symbol konnte nicht gespeichert werden.');
}
try {
await fs.mkdir(path.dirname(absolute), { recursive: true });
await fs.writeFile(absolute, file.buffer);
} catch (error) {
this.logger.error(
`Symbol des Favoriten ${id} konnte nicht gespeichert werden: ${
error instanceof Error ? error.message : String(error)
}`,
);
throw new InternalServerErrorException('Das Symbol konnte nicht gespeichert werden.');
}
const previousMime = link.uploadedIconMime;
let updated: typeof link;
try {
updated = await tenantPrisma.favoriteLink.update({
where: { id },
data: { uploadedIconMime: mime, iconVersion: { increment: 1 } },
});
} catch (error) {
// Ruecknahme (T-LRR-08): die neu geschriebene Datei nur entfernen,
// wenn sie einen ANDEREN Pfad als eine vorhandene alte Datei traegt —
// sonst wuerde ein fehlgeschlagenes Update auf demselben Typ die
// weiterhin gueltige alte Datei loeschen.
if (previousMime !== mime) {
await fs.unlink(absolute).catch(() => undefined);
}
throw error;
}
if (previousMime !== null && previousMime !== mime) {
await this.removeIconFile(id, link.userId, previousMime, 'alte');
}
return updated;
}
/**
* Entfernt ein hochgeladenes Symbol wieder (260923-lrr). Ohne gesetztes
* `uploadedIconMime` liefert die Methode die Zeile unveraendert — kein
* unnoetiger Versionssprung. Die Datei wird NACH dem Update entfernt,
* ein Fehler dabei wird protokolliert und geschluckt (Muster T-HK4-04).
*/
async removeUploadedIcon(tenantId: string, id: string, userId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
if (!link || link.userId !== userId || link.tenantId !== tenantId) {
throw new NotFoundException('FavoriteLink not found');
}
if (link.uploadedIconMime === null) {
return link;
}
const previousMime = link.uploadedIconMime;
const updated = await tenantPrisma.favoriteLink.update({
where: { id },
data: { uploadedIconMime: null, iconVersion: { increment: 1 } },
});
await this.removeIconFile(id, link.userId, previousMime, 'entfernte');
return updated;
}
/** Best-effort-Entfernung einer Symboldatei — protokolliert, wirft nie (Muster T-HK4-04). */
private async removeIconFile(
favoriteId: string,
userId: string,
mime: string,
label: string,
): Promise<void> {
const absolute = favoriteIconAbsolutePath(userId, favoriteId, mime);
if (absolute === null) return;
try {
await fs.unlink(absolute);
} catch (error) {
this.logger.warn(
`${label} Symboldatei des Favoriten ${favoriteId} konnte nicht entfernt werden: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
/**
* Fetches the raw bytes of a favorite's stored icon, scoped to the
* requesting user (T-08-06 — same ownership check as update/remove).
* Never accepts a client-supplied URL — only the stored iconUrl on a
* row the caller owns is fetched (T-QFIP-01).
*
* 260923-lrr: ein hochgeladenes Symbol hat VORRANG vor `iconUrl` — fehlt
* die Datei trotz gesetztem Typ (sollte praktisch nie vorkommen), wird
* protokolliert und auf `iconUrl` zurueckgefallen, statt 404 zu werfen.
*
* Throws NotFoundException (404) if the row doesn't exist, isn't owned
* by the caller, or has no icon on record. Throws a 502 HttpException
* if the upstream fetch fails (unreachable, timeout, non-image, or
* SSRF-blocked) -- never returns a placeholder image.
* by the caller, or has neither an uploaded icon nor a stored iconUrl.
* Throws a 502 HttpException if the upstream fetch fails (unreachable,
* timeout, non-image, or SSRF-blocked) -- never returns a placeholder image.
*/
async getIconBytes(
tenantId: string,
@@ -261,7 +471,29 @@ export class FavoritesService {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
if (!link || link.userId !== userId || !link.iconUrl) {
if (!link || link.userId !== userId) {
throw new NotFoundException('FavoriteLink not found');
}
if (link.uploadedIconMime !== null) {
const absolute = favoriteIconAbsolutePath(link.userId, link.id, link.uploadedIconMime);
if (absolute !== null) {
try {
const body = await fs.readFile(absolute);
return { contentType: link.uploadedIconMime, body };
} catch (error) {
this.logger.warn(
`Hochgeladenes Symbol des Favoriten ${id} fehlt im Dateibereich, falle auf iconUrl zurueck: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
} else {
this.logger.warn(`Hochgeladenes Symbol des Favoriten ${id} hat keinen gueltigen Ablageort`);
}
}
if (!link.iconUrl) {
throw new NotFoundException('FavoriteLink not found');
}