7704372c3c
- 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>
507 lines
21 KiB
TypeScript
507 lines
21 KiB
TypeScript
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';
|
||
|
||
/**
|
||
* Service for managing per-user, per-widget favorite links.
|
||
*
|
||
* Mandantengebunden (260911-gwh): jede Methode nimmt `tenantId` als ERSTEN
|
||
* Parameter und laeuft ueber GENAU EINEN Klienten `tenantPrisma` — der
|
||
* Mandant kommt aus `extractContext` im Controller, DERSELBEN Quelle wie
|
||
* `dashboard.controller.ts` (nicht dem auth-Praezedenzfall/Claim): der Link
|
||
* haengt ueber `widgetId` an `WidgetInstance`, und `WidgetInstance` ist
|
||
* unter der dashboard-Mandantenquelle gebunden. Eine abweichende Quelle
|
||
* wuerde Widget und Link unter einem `x-tenant-id`-Wechsel eines
|
||
* SUPER_ADMIN in verschiedenen Mandanten auseinanderreissen.
|
||
*
|
||
* Die Regel auf `FavoriteLink` trug bei der Messung 260911-gwh (Aufgabe 1,
|
||
* Pruefung 4) KEINE Benutzerdimension — dieselbe Lehre wie `CalendarSource`/
|
||
* `DashboardLayout`/`WidgetInstance`. Nachtrag (260911-nke, Etappe 3b): seit
|
||
* Migration 20260911120000 traegt die Regel auf `FavoriteLink` die
|
||
* Benutzerdimension (`current_user_id() IS NULL OR "userId" = current_user_id()`)
|
||
* — jeder `forTenant()`-Aufruf unten reicht `userId` als drittes Argument
|
||
* durch. Die `userId`-Filter unten bleiben trotzdem UNVERAENDERT bestehen:
|
||
* zweites Netz, kein Ersatz — ein Aufrufer, der `userId` vergisst, saehe
|
||
* ohne sie den ganzen Mandanten (siehe .planning/WINDOWS.md).
|
||
*
|
||
* Access control (T-08-06 / Pitfall 3):
|
||
* - Every query is scoped by userId (prevents cross-user access).
|
||
* - list() additionally scopes by widgetId so each widget instance has its own set.
|
||
* - update() and remove() verify userId ownership before mutating.
|
||
* - reorder() runs as one withTenantTransaction() (T-JDD-03) and scopes
|
||
* every updateMany by userId AND widgetId (see reorder() doc below).
|
||
*
|
||
* `create()` prueft zusaetzlich, dass das Ziel-Widget dem Aufrufer gehoert
|
||
* (T-GWH-05): der Fremdschluessel `FavoriteLink.widgetId` prueft an der
|
||
* Zeilenschutz-Regel von `WidgetInstance` VORBEI (dokumentiertes
|
||
* PostgreSQL-Verhalten, gemessen in Aufgabe 1, Pruefung 7) — ohne den
|
||
* Riegel waere der Unterschied zwischen "Widget existiert nicht" (500) und
|
||
* "gehoert einem fremden Mandanten" (gelingt) ein Existenzorakel ueber
|
||
* 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,
|
||
) {}
|
||
|
||
/**
|
||
* Returns all favorites for a user's widget instance, ordered by position asc.
|
||
* Scoped by userId AND widgetId (Pitfall 3 — separate widgets must not share links).
|
||
*/
|
||
async list(tenantId: string, userId: string, widgetId: string) {
|
||
if (!widgetId) throw new BadRequestException('widgetId is required');
|
||
|
||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||
return tenantPrisma.favoriteLink.findMany({
|
||
where: { userId, widgetId },
|
||
orderBy: [{ position: 'asc' }, { title: 'asc' }],
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
|
||
// T-GWH-05: der Fremdschluessel prueft an der Zeilenschutz-Regel von
|
||
// WidgetInstance vorbei (Aufgabe 1, Pruefung 7) — ohne diesen Riegel
|
||
// wuerde ein gebundenes create mit einer fremdmandantigen widgetId
|
||
// gelingen. Eine Antwort fuer alle drei Faelle: existiert nicht,
|
||
// gehoert einem Kollegen, liegt bei einem fremden Mandanten.
|
||
const widget = await tenantPrisma.widgetInstance.findUnique({
|
||
where: { id: dto.widgetId },
|
||
select: { userId: true },
|
||
});
|
||
if (!widget || widget.userId !== userId) {
|
||
throw new NotFoundException('Widget not found');
|
||
}
|
||
|
||
// Normalize so a scheme-less entry like "ctl.de" is stored (and discovered)
|
||
// as "https://ctl.de" — otherwise the link and icon discovery both break.
|
||
const url = normalizeUrl(dto.url);
|
||
let iconUrl = dto.iconUrl ?? null;
|
||
|
||
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);
|
||
}
|
||
|
||
return tenantPrisma.favoriteLink.create({
|
||
data: {
|
||
userId,
|
||
tenantId,
|
||
widgetId: dto.widgetId,
|
||
title: dto.title,
|
||
url,
|
||
iconUrl,
|
||
position: dto.position ?? 0,
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||
|
||
if (!link || link.userId !== userId) {
|
||
throw new NotFoundException('FavoriteLink not found');
|
||
}
|
||
|
||
const data: Record<string, unknown> = {};
|
||
|
||
if (dto.title !== undefined) data.title = dto.title;
|
||
|
||
const normalizedUrl =
|
||
dto.url !== undefined ? normalizeUrl(dto.url) : undefined;
|
||
if (normalizedUrl !== undefined) data.url = normalizedUrl;
|
||
|
||
if (dto.position !== undefined) data.position = dto.position;
|
||
|
||
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 {
|
||
// Icon cleared (empty/null) — re-run discovery against the effective
|
||
// (new or existing) url so editing a broken favorite repairs its icon.
|
||
const effectiveUrl = normalizedUrl ?? link.url;
|
||
data.iconUrl =
|
||
await this.iconDiscovery.discoverFavoriteIconUrl(effectiveUrl);
|
||
}
|
||
}
|
||
|
||
if (data.iconUrl !== undefined && data.iconUrl !== link.iconUrl) {
|
||
data.iconVersion = { increment: 1 };
|
||
}
|
||
|
||
return tenantPrisma.favoriteLink.update({
|
||
where: { id },
|
||
data,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||
|
||
if (!link || link.userId !== userId) {
|
||
throw new NotFoundException('FavoriteLink not found');
|
||
}
|
||
|
||
await tenantPrisma.favoriteLink.delete({ where: { id } });
|
||
|
||
if (link.uploadedIconMime !== null) {
|
||
await this.removeIconFile(id, link.userId, link.uploadedIconMime, 'geloeschten');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Persists the display order of a user's favorites for one widget
|
||
* instance (260917-jdd, PUT /favorites/order).
|
||
*
|
||
* Laeuft als EINE Transaktion ueber `withTenantTransaction()` — die
|
||
* einzige gemessene atomare Form fuer einen Mehrschritt-Zugriff
|
||
* (prisma-tenant.extension.ts Z. 33-49/104-109); die Array-Form von
|
||
* `$transaction` auf einem mit `forTenant()` gebundenen Klienten ist
|
||
* gemessen NICHT atomar, die interaktive Form auf dem gebundenen Klienten
|
||
* faellt unter Last aus (siehe dortige Messung). `withTenantTransaction()`
|
||
* setzt KEINE Benutzerdimension in der Sitzung (nur `app.current_tenant`)
|
||
* — die Regel auf `FavoriteLink` faellt deshalb in ihren `IS NULL`-Zweig
|
||
* und zeigt den ganzen Mandanten. Darum traegt JEDE Bedingung unten
|
||
* `userId` UND `widgetId` selbst (zweites Netz, wie der Kopfkommentar
|
||
* dieser Klasse es fuer alle Methoden vorsieht).
|
||
*
|
||
* `updateMany` statt `update({ where: { id } })`, weil `update` nur nach
|
||
* `id` filtern koennte — der Ownership-Check muesste dann als separater
|
||
* Lese-Schritt VOR dem Schreiben stehen, mit derselben TOCTOU-Luecke wie
|
||
* ein fehlendes zweites Netz. `updateMany` traegt die Bedingung direkt in
|
||
* der Schreiboperation und liefert `count`, das sofort geprueft wird.
|
||
*
|
||
* Existenzorakel-Vermeidung (T-JDD-06): EINE BadRequestException mit
|
||
* DERSELBEN Meldung fuer fremde id, unbekannte id, Teilmenge sowie
|
||
* fremdes/unbekanntes Widget oder Mandant — kein Fall verraet, welcher
|
||
* Grund zutraf (Muster T-GWH-05).
|
||
*
|
||
* Altbestand: alle Zeilen mit `position = 0` (vor diesem Plan gab es
|
||
* keine Sortierung) normalisiert sich beim ERSTEN Aufruf zu `0..n-1` —
|
||
* kein Migrations- oder Sonderpfad noetig.
|
||
*/
|
||
async reorder(tenantId: string, userId: string, dto: ReorderFavoritesDto) {
|
||
if (new Set(dto.ids).size !== dto.ids.length) {
|
||
throw new BadRequestException('ids must match the favorites of this widget exactly');
|
||
}
|
||
|
||
return withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||
const existing = await tx.favoriteLink.findMany({
|
||
where: { userId, widgetId: dto.widgetId },
|
||
select: { id: true },
|
||
});
|
||
const existingIds = new Set(existing.map((r: { id: string }) => r.id));
|
||
|
||
if (
|
||
existing.length !== dto.ids.length ||
|
||
dto.ids.some((id) => !existingIds.has(id))
|
||
) {
|
||
throw new BadRequestException('ids must match the favorites of this widget exactly');
|
||
}
|
||
|
||
for (const [index, id] of dto.ids.entries()) {
|
||
const { count } = await tx.favoriteLink.updateMany({
|
||
where: { id, userId, widgetId: dto.widgetId },
|
||
data: { position: index },
|
||
});
|
||
|
||
if (count !== 1) {
|
||
throw new BadRequestException('ids must match the favorites of this widget exactly');
|
||
}
|
||
}
|
||
|
||
return tx.favoriteLink.findMany({
|
||
where: { userId, widgetId: dto.widgetId },
|
||
orderBy: [{ position: 'asc' }, { title: 'asc' }],
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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 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,
|
||
id: string,
|
||
userId: string,
|
||
): Promise<{ contentType: string; body: Buffer }> {
|
||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||
|
||
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');
|
||
}
|
||
|
||
try {
|
||
return await this.iconDiscovery.fetchIconBytes(link.iconUrl);
|
||
} catch {
|
||
throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY);
|
||
}
|
||
}
|
||
}
|