Files
tessera-ctl/apps/api/src/favorites/favorites.service.ts
T
schalli 2dd11b439d
Tessera CI/CD / Lint & Type Check (push) Successful in 49s
Tessera CI/CD / Tests (push) Successful in 1m28s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 20s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m18s
fix(favorites): Logo auch fuer Seiten, die es per JavaScript setzen
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>
2026-10-01 12:28:32 +02:00

552 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}
}
}