2dd11b439d
hosteurope.de liefert im HTML nur einen leeren data:-Platzhalter, das echte Symbol setzt erst JavaScript; /favicon.ico antwortet mit HTML. Die Kachel zeigte deshalb nur den Buchstaben. Scheitert das gespeicherte Symbol, fragt getIconBytes jetzt einmal den DuckDuckGo-Symboldienst - nur fuer oeffentlich erreichbare Seiten, interne Hostnamen verlassen das Haus nicht; kennt der Dienst nichts (404), bleibt es beim Buchstaben. Wirkt auch fuer bestehende Favoriten. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
552 lines
22 KiB
TypeScript
552 lines
22 KiB
TypeScript
import * as fs from 'node:fs/promises';
|
||
import * as path from 'node:path';
|
||
import {
|
||
BadRequestException,
|
||
HttpException,
|
||
HttpStatus,
|
||
Injectable,
|
||
InternalServerErrorException,
|
||
Logger,
|
||
NotFoundException,
|
||
PayloadTooLargeException,
|
||
} 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.
|
||
* - 260929-lh3 (loest die Abrufprobe von 260923-lrr ab): eine ausdrueckliche
|
||
* Logo-Adresse wird nur auf Form (http/https, <= 2048 Zeichen) geprueft und
|
||
* auch gespeichert, wenn der Server sie nicht abrufen kann — der Browser der
|
||
* Kachel laedt sie dann direkt. Ein hochgeladenes Symbol wird von einer
|
||
* neuen, abweichenden Adresse verdraengt (Vorrang der Datei sonst: Adresse
|
||
* gespeichert, aber unsichtbar).
|
||
*/
|
||
/** Hoechstlaenge einer ausdruecklichen Logo-Adresse (260929-lh3). */
|
||
const ICON_URL_MAX_LENGTH = 2048;
|
||
|
||
@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 eine ausdruecklich eingetragene Logo-Adresse NUR auf Form (260929-lh3):
|
||
* gueltige http/https-Adresse, hoechstens 2048 Zeichen. Bewusst KEIN
|
||
* serverseitiger Abruf mehr — Server wie docuvita liefern dem Server ein
|
||
* 404/HTML, dem Browser aber das Bild; die fruehere Abrufprobe (422,
|
||
* 260923-lrr) machte genau diese Adressen unspeicherbar. Entscheidung: auch
|
||
* eine Antwort, die der Server sieht und die kein Bild ist, weist NICHT ab —
|
||
* "Server bekommt kein Bild" heisst nicht "Browser bekommt keins", und der
|
||
* Server kann beides nicht unterscheiden. Der SSRF-Schutz bleibt unveraendert
|
||
* dort, wo der Server tatsaechlich abruft (`getIconBytes`/Erkennung); scheitert
|
||
* der Proxy, laedt die Kachel die Adresse direkt im Browser.
|
||
*/
|
||
private assertIconUrlWellFormed(iconUrl: string): void {
|
||
let parsed: URL | null = null;
|
||
try {
|
||
parsed = new URL(iconUrl);
|
||
} catch {
|
||
parsed = null;
|
||
}
|
||
if (
|
||
parsed === null ||
|
||
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
|
||
iconUrl.length > ICON_URL_MAX_LENGTH
|
||
) {
|
||
throw new BadRequestException(
|
||
'Die Logo-Adresse muss eine gültige http- oder https-Adresse sein (höchstens 2048 Zeichen).',
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 it is stored as given after a form check only
|
||
* (260929-lh3, see assertIconUrlWellFormed) — no server-side fetch.
|
||
*/
|
||
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) {
|
||
// 260929-lh3: nur Formpruefung, kein serverseitiger Abruf.
|
||
this.assertIconUrlWellFormed(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).
|
||
*
|
||
* 260929-lh3: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl`
|
||
* durchlaeuft nur die Formpruefung (`assertIconUrlWellFormed`), bevor
|
||
* irgendetwas geschrieben wird; sie wird auch gespeichert, wenn der Server
|
||
* sie nicht abrufen kann. 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) {
|
||
// 260929-lh3: nur eine NEUE, abweichende Adresse wird geprueft (Form).
|
||
this.assertIconUrlWellFormed(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);
|
||
}
|
||
}
|
||
|
||
// 260929-lh3: eine NEUE, ausdruecklich eingetragene Logo-Adresse muss
|
||
// Vorrang vor einem frueher hochgeladenen Symbol haben. `getIconBytes`
|
||
// liefert bei gesetztem `uploadedIconMime` IMMER die Datei — ohne diesen
|
||
// Schritt blieb die neue Adresse gespeichert, aber unsichtbar (die Kachel
|
||
// zeigte weiter das alte hochgeladene Bild). Nur bei einer tatsaechlichen
|
||
// Aenderung: das Formular schickt die unveraenderte Adresse bei jedem
|
||
// Speichern mit, das darf ein hochgeladenes Symbol nicht verdraengen.
|
||
const iconUrlChanged = data.iconUrl !== undefined && data.iconUrl !== link.iconUrl;
|
||
const explicitUrlReplacesUpload =
|
||
iconUrlChanged && Boolean(dto.iconUrl) && link.uploadedIconMime !== null;
|
||
if (explicitUrlReplacesUpload) {
|
||
data.uploadedIconMime = null;
|
||
}
|
||
|
||
if (iconUrlChanged) {
|
||
data.iconVersion = { increment: 1 };
|
||
}
|
||
|
||
const updated = await tenantPrisma.favoriteLink.update({
|
||
where: { id },
|
||
data,
|
||
});
|
||
|
||
if (explicitUrlReplacesUpload && link.uploadedIconMime !== null) {
|
||
await this.removeIconFile(id, link.userId, link.uploadedIconMime, 'ersetzte');
|
||
}
|
||
|
||
return updated;
|
||
}
|
||
|
||
/**
|
||
* 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) AND the public icon service fallback
|
||
* (quick-261001-hbi, public pages only) has no icon either -- 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 {
|
||
// quick-261001-hbi: Seite liefert kein abrufbares Symbol (z. B. per
|
||
// JavaScript gesetzt) -- einmal beim oeffentlichen Symbol-Dienst fragen,
|
||
// nur fuer oeffentlich erreichbare Seiten.
|
||
try {
|
||
return await this.iconDiscovery.fetchPublicServiceIconBytes(link.url);
|
||
} catch {
|
||
throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY);
|
||
}
|
||
}
|
||
}
|
||
}
|