From b15c74632bc73dd765deea990662304062a923a2 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 29 Sep 2026 15:34:58 +0200 Subject: [PATCH] fix(favorites): Symbol-Adresse auch speichern, wenn nur der Browser sie laden kann - API: ausdrueckliche iconUrl wird nur auf Form (http/https, <= 2048) geprueft und auch gespeichert, wenn der Server sie nicht abrufen kann; keine 422 mehr - Erkennung: Seite mit Fehlerstatus, aber HTML mit , liefert diesen Verweis (docuvita); og:image einer Fehlerseite zaehlt nicht - Kachel: Proxy -> iconUrl direkt im Browser (no-referrer, nur http/https) -> Origin-Favicon -> Buchstabe - Meldung iconUrlUnreachable (de/en) entfernt, Hinweis zum Vorrang angepasst Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/favorites/dto/create-favorite.dto.ts | 2 + .../src/favorites/dto/update-favorite.dto.ts | 4 +- .../src/favorites/favorites.service.spec.ts | 89 +++++++++++-------- apps/api/src/favorites/favorites.service.ts | 68 ++++++++------ .../favorites/icon-discovery.service.spec.ts | 48 ++++++++++ .../src/favorites/icon-discovery.service.ts | 31 ++++++- .../widgets/favorites-widget.test.tsx | 75 +++++++++++++++- .../dashboard/widgets/favorites-widget.tsx | 50 ++++++++--- apps/web/src/lib/favorites-api.test.ts | 28 +++--- apps/web/src/lib/favorites-api.ts | 18 ++-- apps/web/src/messages/de.json | 3 +- apps/web/src/messages/en.json | 3 +- 12 files changed, 307 insertions(+), 112 deletions(-) diff --git a/apps/api/src/favorites/dto/create-favorite.dto.ts b/apps/api/src/favorites/dto/create-favorite.dto.ts index 8d2db34..220af2d 100644 --- a/apps/api/src/favorites/dto/create-favorite.dto.ts +++ b/apps/api/src/favorites/dto/create-favorite.dto.ts @@ -5,6 +5,7 @@ import { IsString, IsUrl, IsUUID, + MaxLength, } from 'class-validator'; /** @@ -24,6 +25,7 @@ export class CreateFavoriteDto { @IsOptional() @IsString() + @MaxLength(2048) iconUrl?: string; @IsOptional() diff --git a/apps/api/src/favorites/dto/update-favorite.dto.ts b/apps/api/src/favorites/dto/update-favorite.dto.ts index e17c57b..cbfccad 100644 --- a/apps/api/src/favorites/dto/update-favorite.dto.ts +++ b/apps/api/src/favorites/dto/update-favorite.dto.ts @@ -1,4 +1,4 @@ -import { IsInt, IsOptional, IsString, IsUrl } from 'class-validator'; +import { IsInt, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator'; /** * DTO for updating an existing FavoriteLink. @@ -19,6 +19,8 @@ export class UpdateFavoriteDto { * No strict type validation so null passes through to Prisma. */ @IsOptional() + @IsString() + @MaxLength(2048) iconUrl?: string | null; @IsOptional() diff --git a/apps/api/src/favorites/favorites.service.spec.ts b/apps/api/src/favorites/favorites.service.spec.ts index b76ae2a..7dca421 100644 --- a/apps/api/src/favorites/favorites.service.spec.ts +++ b/apps/api/src/favorites/favorites.service.spec.ts @@ -6,7 +6,6 @@ import { HttpException, NotFoundException, PayloadTooLargeException, - UnprocessableEntityException, } from '@nestjs/common'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FavoritesService } from './favorites.service'; @@ -920,26 +919,50 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => { }); }); - 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'); - }), + describe('create/update — ausdrueckliche iconUrl: nur Formpruefung, kein Abruf (260929-lh3)', () => { + const widgets = [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]; + const failingFetch = () => + vi.fn(async () => { + throw new Error('server bekommt 404/HTML'); }); + + it('create mit einer Adresse, die der SERVER nicht abrufen kann: wird gespeichert, KEIN Abruf, KEINE Erkennung', async () => { + const prisma = makeFakePrisma([], widgets); + const iconDiscovery = makeIconDiscovery({ fetchIconBytes: failingFetch() }); const service = new FavoritesService(prisma as any, iconDiscovery as any); + const created = await service.create('t1', 'user-a1', { + widgetId: 'widget-a1', + title: 'Docuvita', + url: 'https://docuvita.ctl.local/server/services/web/', + iconUrl: 'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico', + } as any); + + expect(created.iconUrl).toBe( + 'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico', + ); + expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled(); + expect(iconDiscovery.discoverFavoriteIconUrl).not.toHaveBeenCalled(); + expect(prisma.__favorites.size).toBe(1); + }); + + it.each([ + ['kein http/https', 'ftp://x.invalid/icon.png'], + ['javascript-Schema', 'javascript:alert(1)'], + ['keine Adresse', 'kein url'], + ['laenger als 2048 Zeichen', `https://x.invalid/${'a'.repeat(2050)}`], + ])('create mit ungueltiger iconUrl (%s) -> BadRequestException, nichts geschrieben', async (_label, iconUrl) => { + const prisma = makeFakePrisma([], widgets); + const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); + await expect( service.create('t1', 'user-a1', { widgetId: 'widget-a1', title: 'X', url: 'https://x.invalid', - iconUrl: 'https://x.invalid/logo.png', + iconUrl, } as any), - ).rejects.toThrow(UnprocessableEntityException); - expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1); - expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://x.invalid/logo.png'); + ).rejects.toThrow(BadRequestException); expect(prisma.__favorites.size).toBe(0); }); @@ -956,24 +979,31 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => { iconVersion: 0, }; - it('update mit neuer, abweichender iconUrl: fetchIconBytes genau einmal mit dieser Adresse; wirft -> UnprocessableEntityException, favoriteLink.update NICHT aufgerufen', async () => { + it('update mit neuer iconUrl, die der Server nicht abrufen kann: gespeichert, iconVersion +1, KEIN Abruf', async () => { const prisma = makeFakePrisma([baseRow]); - const iconDiscovery = makeIconDiscovery({ - fetchIconBytes: vi.fn(async () => { - throw new Error('blocked'); - }), - }); + const iconDiscovery = makeIconDiscovery({ fetchIconBytes: failingFetch() }); 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(updated.iconUrl).toBe('https://neu.invalid/icon.png'); + expect(updated.iconVersion).toBe(1); + expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled(); + }); + + it('update mit ungueltiger neuer iconUrl -> BadRequestException, Zeile unveraendert', async () => { + const prisma = makeFakePrisma([baseRow]); + const service = new FavoritesService(prisma as any, makeIconDiscovery() 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'); + service.update('t1', 'f1', 'user-a1', { iconUrl: 'file:///etc/passwd' } as any), + ).rejects.toThrow(BadRequestException); expect(prisma.__favorites.get('f1').iconUrl).toBe(baseRow.iconUrl); }); - it('update mit UNVERAENDERTER iconUrl: keine Probe, keine Erhoehung', async () => { + it('update mit UNVERAENDERTER iconUrl: keine Pruefung, keine Erhoehung', async () => { const prisma = makeFakePrisma([baseRow]); const iconDiscovery = makeIconDiscovery(); const service = new FavoritesService(prisma as any, iconDiscovery as any); @@ -992,18 +1022,5 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => { 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); - }); }); }); diff --git a/apps/api/src/favorites/favorites.service.ts b/apps/api/src/favorites/favorites.service.ts index 50882ca..e1e4001 100644 --- a/apps/api/src/favorites/favorites.service.ts +++ b/apps/api/src/favorites/favorites.service.ts @@ -9,7 +9,6 @@ import { Logger, NotFoundException, PayloadTooLargeException, - UnprocessableEntityException, } from '@nestjs/common'; import type { UploadedFileLike } from '../auth/types/auth-user'; import { PrismaService } from '../prisma/prisma.service'; @@ -77,12 +76,16 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service'; * 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. + * - 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); @@ -107,19 +110,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. + * 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 async assertIconUrlLoadable(iconUrl: string): Promise { + private assertIconUrlWellFormed(iconUrl: string): void { + let parsed: URL | null = null; try { - await this.iconDiscovery.fetchIconBytes(iconUrl); + parsed = new URL(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.', + 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).', ); } } @@ -129,8 +144,8 @@ export class FavoritesService { * 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. + * 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); @@ -154,8 +169,8 @@ export class FavoritesService { let iconUrl = dto.iconUrl ?? null; if (iconUrl) { - // 260923-lrr: explizit uebergebene Adresse wird einmal probiert. - await this.assertIconUrlLoadable(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); @@ -179,10 +194,11 @@ export class FavoritesService { * 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`. + * 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); @@ -205,8 +221,8 @@ 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); + // 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; diff --git a/apps/api/src/favorites/icon-discovery.service.spec.ts b/apps/api/src/favorites/icon-discovery.service.spec.ts index 0693da0..005e052 100644 --- a/apps/api/src/favorites/icon-discovery.service.spec.ts +++ b/apps/api/src/favorites/icon-discovery.service.spec.ts @@ -133,6 +133,54 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl', () => { }); }); +describe('IconDiscoveryService.discoverFavoriteIconUrl — Seite mit Fehlerstatus (260929-lh3)', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + function htmlResponse(status: number, html: string) { + return { + ok: status >= 200 && status < 300, + status, + headers: { + get: (n: string) => + n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null, + }, + text: async () => html, + }; + } + + it('Seite antwortet 400, traegt aber (docuvita) -> dieser Verweis wird genutzt', async () => { + const html = + ''; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(htmlResponse(400, html))); + + const icon = await new IconDiscoveryService().discoverFavoriteIconUrl( + 'http://8.8.8.8/server/services/web/', + ); + + expect(icon).toBe('http://8.8.8.8/webclient/docuvita/resources/brandimage/favicon.ico'); + }); + + it('Fehlerseite ohne Symbol-Verweis, nur og:image -> Rueckfall /favicon.ico (og:image einer Fehlerseite zaehlt nicht)', async () => { + const html = ''; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(htmlResponse(404, html))); + + const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x'); + + expect(icon).toBe('http://8.8.8.8/favicon.ico'); + }); + + it('fetchIconBytes bleibt streng: Fehlerstatus -> wirft (kein allowErrorStatus fuer Bilder)', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ...htmlResponse(404, ''), headers: { get: () => 'text/html' } })); + + await expect( + new IconDiscoveryService().fetchIconBytes('http://8.8.8.8/favicon.ico'), + ).rejects.toThrow(); + }); +}); + describe('IconDiscoveryService.fetchIconBytes', () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/apps/api/src/favorites/icon-discovery.service.ts b/apps/api/src/favorites/icon-discovery.service.ts index 91643c4..c499991 100644 --- a/apps/api/src/favorites/icon-discovery.service.ts +++ b/apps/api/src/favorites/icon-discovery.service.ts @@ -49,6 +49,8 @@ const LENIENT_TLS_AGENT = new Agent({ connect: { rejectUnauthorized: false } }); type FetchHtmlResult = { html: string; finalUrl: string; + /** false = die Seite antwortete mit einem Fehlerstatus (z. B. 400/404), lieferte aber HTML (260929-lh3). */ + ok: boolean; }; function isPrivateIpv4(address: string): boolean { @@ -202,7 +204,11 @@ function toAbsoluteUrl(value: string | undefined, base: string): string | null { } } -function extractIconFromHtml(html: string, baseUrl: string): string | null { +function extractIconFromHtml( + html: string, + baseUrl: string, + linkTagsOnly = false, +): string | null { const linkTags = html.match(/]*>/gi) ?? []; const metaTags = html.match(/]*>/gi) ?? []; @@ -238,6 +244,10 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null { if (imageSrc) return imageSrc; + // 260929-lh3: eine Fehlerseite (Status != 2xx) traegt kein Vorschaubild der + // Seite — nur die ausdruecklichen Symbol-Verweise () zaehlen. + if (linkTagsOnly) return null; + const metaImage = metaTags .map((tag) => parseAttributes(tag)) .map((a) => ({ @@ -266,7 +276,13 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null { */ async function fetchWithRedirectGuard( pageUrl: URL, - options: { accept: string; timeoutMs: number; userAgent?: string }, + options: { + accept: string; + timeoutMs: number; + userAgent?: string; + /** 260929-lh3: auch eine 4xx/5xx-Antwort zurueckgeben (nur fuer die HTML-Suche). */ + allowErrorStatus?: boolean; + }, ): Promise<{ response: UndiciResponse; finalUrl: URL } | null> { let currentUrl = pageUrl; @@ -298,7 +314,7 @@ async function fetchWithRedirectGuard( continue; } - if (!response.ok) return null; + if (!response.ok && !options.allowErrorStatus) return null; return { response, finalUrl: currentUrl }; } catch { @@ -315,6 +331,9 @@ async function fetchHtml(pageUrl: URL): Promise { const result = await fetchWithRedirectGuard(pageUrl, { accept: 'text/html,application/xhtml+xml,*/*', timeoutMs: HTML_FETCH_TIMEOUT_MS, + // 260929-lh3: Server wie docuvita antworten dem Server mit 400, tragen im + // HTML aber trotzdem den — den Verweis wollen wir haben. + allowErrorStatus: true, }); if (!result) return null; @@ -328,6 +347,7 @@ async function fetchHtml(pageUrl: URL): Promise { return { html: html.slice(0, MAX_HTML_CHARS), // T-08-09: HTML cap finalUrl: result.finalUrl.toString(), + ok: result.response.ok, }; } @@ -350,7 +370,10 @@ export class IconDiscoveryService { if (!htmlResult) return fallback; - return extractIconFromHtml(htmlResult.html, htmlResult.finalUrl) ?? fallback; + return ( + extractIconFromHtml(htmlResult.html, htmlResult.finalUrl, !htmlResult.ok) ?? + fallback + ); } catch { return fallback; } diff --git a/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx b/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx index 66c3f7a..6985879 100644 --- a/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx +++ b/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx @@ -490,6 +490,75 @@ describe('FavoritesWidget', () => { expect(screen.getByTestId('letter-fallback-fav-id-1')).toHaveTextContent('G'); }); + it('ausdrueckliche Symbol-Adresse (260929-lh3): Proxy -> Browser laedt iconUrl direkt (no-referrer) -> Origin-Favicon -> Buchstabe', async () => { + mockFetch.mockResolvedValue([ + { + id: 'fav-id-9', + widgetId: 'fav-1', + title: 'Docuvita', + url: 'https://docuvita.ctl.local/server/services/web/', + iconUrl: 'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico', + position: 0, + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Docuvita')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.error(screen.getByTestId('icon-proxy-fav-id-9')); + }); + + const direct1 = screen.getByTestId('icon-direct-fav-id-9') as HTMLImageElement; + expect(direct1.src).toBe( + 'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico', + ); + expect(direct1.getAttribute('referrerpolicy')).toBe('no-referrer'); + + act(() => { + fireEvent.error(direct1); + }); + + const direct2 = screen.getByTestId('icon-direct-fav-id-9') as HTMLImageElement; + expect(direct2.src).toBe('https://docuvita.ctl.local/favicon.ico'); + + act(() => { + fireEvent.error(direct2); + }); + + expect(screen.queryByTestId('icon-direct-fav-id-9')).not.toBeInTheDocument(); + expect(screen.getByTestId('letter-fallback-fav-id-9')).toHaveTextContent('D'); + }); + + it('iconUrl mit Nicht-http-Schema wird NIE direkt geladen (javascript:/data:)', async () => { + mockFetch.mockResolvedValue([ + { + id: 'fav-id-8', + widgetId: 'fav-1', + title: 'Boese', + url: 'https://boese.example', + iconUrl: 'javascript:alert(1)', + position: 0, + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Boese')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.error(screen.getByTestId('icon-proxy-fav-id-8')); + }); + + const direct = screen.getByTestId('icon-direct-fav-id-8') as HTMLImageElement; + expect(direct.src).toBe('https://boese.example/favicon.ico'); + }); + it('kein Direktbild bei Nicht-http-URL', async () => { mockFetch.mockResolvedValue([ { id: 'fav-id-3', widgetId: 'fav-1', title: 'Ablage', url: 'ftp://files.example', iconUrl: null, position: 0 }, @@ -730,8 +799,8 @@ describe('FavoritesWidget', () => { }); }); - it('updateFavorite wirft FavoriteRequestError(iconUrlUnreachable) -> Meldung im Formular (role alert), Formular bleibt offen, uploadFavoriteIcon NICHT aufgerufen', async () => { - mockUpdate.mockRejectedValue(new FavoriteRequestError('iconUrlUnreachable')); + it('updateFavorite wirft (z. B. 400 ungueltige Adresse) -> allgemeine Meldung favorites.error im Formular (role alert), Formular bleibt offen, uploadFavoriteIcon NICHT aufgerufen', async () => { + mockUpdate.mockRejectedValue(new Error('Failed to update favorite')); render(); @@ -753,7 +822,7 @@ describe('FavoritesWidget', () => { }); await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent('favorites.iconUrlUnreachable'); + expect(screen.getByRole('alert')).toHaveTextContent('favorites.error'); }); expect(mockUploadIcon).not.toHaveBeenCalled(); expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument(); diff --git a/apps/web/src/components/dashboard/widgets/favorites-widget.tsx b/apps/web/src/components/dashboard/widgets/favorites-widget.tsx index bba9e43..4f4826f 100644 --- a/apps/web/src/components/dashboard/widgets/favorites-widget.tsx +++ b/apps/web/src/components/dashboard/widgets/favorites-widget.tsx @@ -23,7 +23,7 @@ const TITLE_DEBOUNCE_MS = 1500; /** * Bildet einen Fehler aus dem Favoriten-Klienten auf einen Uebersetzungs- * schluessel ab (260923-lrr): `FavoriteRequestError` traegt den passenden - * Grund (`iconUrlUnreachable`/`iconTooLarge`/`iconInvalidType`/ + * Grund (`iconTooLarge`/`iconInvalidType`/ * `iconUploadFailed`) bereits als `reason`, jeder andere Fehler faellt auf * die bisherige allgemeine Meldung zurueck. */ @@ -527,13 +527,37 @@ function getDirectFaviconSrc(url: string): string | null { } /** - * FavoriteIcon — dreistufiger Symbol-Ersatzweg (260917-jdd): + * Direkt-ladbare Adressen fuer Stufe 2 des Ersatzwegs (260929-lh3): erst die + * gespeicherte `iconUrl`, dann das Origin-Favicon der Favoriten-URL; nur + * http:/https: (kein javascript:/data:, T-JDD-04), ohne Doppelte. + */ +function getDirectCandidates(iconUrl: string | null, url: string): string[] { + const candidates: string[] = []; + if (iconUrl) { + try { + const u = new URL(iconUrl); + if (u.protocol === 'http:' || u.protocol === 'https:') candidates.push(u.toString()); + } catch { + // ungueltige Adresse: uebersprungen + } + } + const origin = getDirectFaviconSrc(url); + if (origin && !candidates.includes(origin)) candidates.push(origin); + return candidates; +} + +/** + * FavoriteIcon — Symbol-Ersatzweg (260917-jdd, erweitert 260929-lh3): * * 1. `proxy` — Server-Proxy (GET /favorites/:id/icon), der seit diesem Plan * auch bei Zertifikatsfehlern des Zielhosts liefert (undici-Dispatcher). - * 2. `direct` — Direktbild aus dem Browser des Nutzers - * (`referrerPolicy="no-referrer"`, Origin nur aus http/https); erreicht - * interne Hosts, die der SSRF-Schutz des Servers absichtlich ablehnt. + * 2. `direct` — Direktbilder aus dem Browser des Nutzers + * (`referrerPolicy="no-referrer"`, nur http/https), nacheinander: + * zuerst die gespeicherte `iconUrl` (ausdrueckliche Symbol-Adresse oder + * erkannter Verweis — Server wie docuvita geben dem Server 404/HTML, dem + * Browser aber das Bild), dann `{origin}/favicon.ico` (entfaellt, wenn + * identisch). Erreicht auch interne Hosts, die der SSRF-Schutz des + * Servers absichtlich ablehnt. * 3. `none` — der Buchstaben-Platzhalter liegt IMMER darunter. * * Bewusst KEIN Drittanbieter-Favicon-Dienst: der wuerde Hostnamen nach @@ -575,10 +599,12 @@ function FavoriteIcon({ const proxySrc = hasServerIcon ? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon?v=${fav.iconVersion ?? 0}` : null; - const directSrc = getDirectFaviconSrc(fav.url); - const [stage, setStage] = useState<'proxy' | 'direct' | 'none'>( - proxySrc ? 'proxy' : 'direct', - ); + // 260929-lh3: Direkt-Kandidaten in Reihenfolge — gespeicherte Adresse zuerst, + // dann das Origin-Favicon; nur http/https, ohne Doppelte. + const directSrcs = getDirectCandidates(fav.iconUrl, fav.url); + // 'proxy' | Index in directSrcs | Ende der Kette (Buchstabe). + const [stage, setStage] = useState<'proxy' | number>(proxySrc ? 'proxy' : 0); + const directSrc = typeof stage === 'number' ? (directSrcs[stage] ?? null) : null; return (
@@ -598,10 +624,10 @@ function FavoriteIcon({ height={px} loading="lazy" className={`relative rounded-sm ${img}`} - onError={() => setStage('direct')} + onError={() => setStage(0)} /> )} - {stage === 'direct' && directSrc && ( + {directSrc && ( setStage('none')} + onError={() => setStage((prev) => (typeof prev === 'number' ? prev + 1 : 0))} /> )}
diff --git a/apps/web/src/lib/favorites-api.test.ts b/apps/web/src/lib/favorites-api.test.ts index d7045a3..ddb16b2 100644 --- a/apps/web/src/lib/favorites-api.test.ts +++ b/apps/web/src/lib/favorites-api.test.ts @@ -14,9 +14,9 @@ import { * Die vier neuen/geaenderten Aufrufe: `uploadFavoriteIcon` (FormData-Feld * `icon`, Groessenpruefung vor dem Netzwerkaufruf, 413/400/sonst -> * `FavoriteRequestError`), `removeFavoriteIcon` (DELETE), sowie - * `createFavorite`/`updateFavorite` (422 -> `FavoriteRequestError` mit - * `reason: 'iconUrlUnreachable'`, jeder andere Fehler bleibt eine generische - * `Error` wie bisher). Muster `dashboard-images-api.test.ts`. + * `createFavorite`/`updateFavorite` (jeder Fehler bleibt eine generische + * `Error`; die 422-Abrufprobe ist seit 260929-lh3 entfallen). Muster + * `dashboard-images-api.test.ts`. */ const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() })); @@ -112,9 +112,9 @@ describe('favorites-api (quick-260923-lrr)', () => { expect(init.credentials).toBe('include'); }); - describe('createFavorite / updateFavorite — Abrufprobe (260923-lrr)', () => { - it('createFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable', async () => { - mockFetch.mockResolvedValue(new Response('{}', { status: 422 })); + describe('createFavorite / updateFavorite — Fehler bleiben generisch (260929-lh3)', () => { + it('createFavorite: 400 (ungueltige Logo-Adresse) -> generische Error, KEIN FavoriteRequestError', async () => { + mockFetch.mockResolvedValue(new Response('{}', { status: 400 })); const err = await createFavorite({ widgetId: 'w1', @@ -123,8 +123,8 @@ describe('favorites-api (quick-260923-lrr)', () => { iconUrl: 'https://x.invalid/logo.png', }).catch((e) => e); - expect(err).toBeInstanceOf(FavoriteRequestError); - expect((err as FavoriteRequestError).reason).toBe('iconUrlUnreachable'); + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(FavoriteRequestError); }); it('createFavorite: anderer Fehler -> wie bisher eine generische Error', async () => { @@ -138,15 +138,13 @@ describe('favorites-api (quick-260923-lrr)', () => { expect(err).not.toBeInstanceOf(FavoriteRequestError); }); - it('updateFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable', async () => { - mockFetch.mockResolvedValue(new Response('{}', { status: 422 })); + it('updateFavorite: 400 (ungueltige Logo-Adresse) -> generische Error, KEIN FavoriteRequestError', async () => { + mockFetch.mockResolvedValue(new Response('{}', { status: 400 })); - const err = await updateFavorite('fav-1', { iconUrl: 'https://x.invalid/logo.png' }).catch( - (e) => e, - ); + const err = await updateFavorite('fav-1', { iconUrl: 'https://x.invalid/logo.png' }).catch((e) => e); - expect(err).toBeInstanceOf(FavoriteRequestError); - expect((err as FavoriteRequestError).reason).toBe('iconUrlUnreachable'); + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(FavoriteRequestError); }); it('updateFavorite: anderer Fehler -> wie bisher eine generische Error', async () => { diff --git a/apps/web/src/lib/favorites-api.ts b/apps/web/src/lib/favorites-api.ts index b0568a3..0909d5c 100644 --- a/apps/web/src/lib/favorites-api.ts +++ b/apps/web/src/lib/favorites-api.ts @@ -7,10 +7,9 @@ * 260923-lrr — eigenes Symbol hochladen, Zwischenspeicher nach Aenderung * erneuern: `FavoriteLink` traegt jetzt `uploadedIconMime`/`iconVersion` * (optional, weil Testdaten und aeltere Antworten sie nicht tragen). - * `createFavorite`/`updateFavorite` uebersetzen ein 422 (Abrufprobe der API - * fuer eine nicht abrufbare Logo-Adresse) in `FavoriteRequestError`, damit - * das Widget eine sprachrichtige Meldung zeigen kann, statt der bisherigen - * generischen `Error`. `uploadFavoriteIcon`/`removeFavoriteIcon` sind neu, + * `FavoriteRequestError` traegt den Grund einer abgewiesenen Symbol-Datei + * (Widget bildet ihn auf eine sprachrichtige Meldung ab). 260929-lh3: die + * 422-Abrufprobe fuer Logo-Adressen ist entfallen. `uploadFavoriteIcon`/`removeFavoriteIcon` sind neu, * Muster `uploadDashboardImage`/`deleteDashboardImage` (dashboard-images-api.ts). */ @@ -34,7 +33,6 @@ export interface FavoriteLink { /** Grund einer abgewiesenen Favoriten-Anfrage (260923-lrr). */ export type FavoriteErrorReason = - | 'iconUrlUnreachable' | 'iconTooLarge' | 'iconInvalidType' | 'iconUploadFailed'; @@ -72,8 +70,9 @@ export async function fetchFavorites(widgetId: string): Promise /** * Create a new favorite link. * Server-side icon discovery runs automatically if iconUrl is not provided. - * 260923-lrr: eine explizite `iconUrl`, die sich serverseitig nicht laden - * laesst, ergibt 422 -> `FavoriteRequestError('iconUrlUnreachable')`. + * 260929-lh3: eine ausdrueckliche `iconUrl` wird auch gespeichert, wenn der + * Server sie nicht abrufen kann (die Kachel laedt sie dann im Browser); die + * frueheren 422-Antworten gibt es nicht mehr. */ export async function createFavorite(payload: { widgetId: string; @@ -87,7 +86,6 @@ export async function createFavorite(payload: { credentials: 'include', body: JSON.stringify(payload), }); - if (res.status === 422) throw new FavoriteRequestError('iconUrlUnreachable'); if (!res.ok) throw new Error('Failed to create favorite'); return res.json(); @@ -96,8 +94,7 @@ export async function createFavorite(payload: { /** * Update an existing favorite link. * Pass iconUrl: null to clear a stored icon. - * 260923-lrr: eine neue, nicht abrufbare `iconUrl` ergibt ebenso 422 -> - * `FavoriteRequestError('iconUrlUnreachable')`. + * 260929-lh3: wie `createFavorite` — keine 422-Abweisung wegen Abrufbarkeit mehr. */ export async function updateFavorite( id: string, @@ -109,7 +106,6 @@ export async function updateFavorite( credentials: 'include', body: JSON.stringify(payload), }); - if (res.status === 422) throw new FavoriteRequestError('iconUrlUnreachable'); if (!res.ok) throw new Error('Failed to update favorite'); return res.json(); diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 0aeed8c..66624fd 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -380,10 +380,9 @@ "viewModeLabel": "Ansicht wechseln", "iconUrlPlaceholder": "Logo-Adresse (optional)", "iconUploadLabel": "Eigenes Symbol hochladen", - "iconUploadHint": "PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB. Ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse.", + "iconUploadHint": "PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB. Ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse; eine neu eingetragene Logo-Adresse ersetzt es aber.", "iconUploadedHint": "Für diesen Favoriten ist ein eigenes Symbol hochgeladen.", "iconRemoveButton": "Hochgeladenes Symbol entfernen", - "iconUrlUnreachable": "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.", "iconTooLarge": "Die Datei ist zu groß – erlaubt sind höchstens 512 KB.", "iconInvalidType": "Nur Bilder im Format PNG, JPEG, GIF, WebP, ICO oder SVG sind erlaubt.", "iconUploadFailed": "Das Symbol konnte nicht hochgeladen werden." diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 2fbbd9b..8c421d5 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -380,10 +380,9 @@ "viewModeLabel": "Switch view", "iconUrlPlaceholder": "Logo URL (optional)", "iconUploadLabel": "Upload custom icon", - "iconUploadHint": "PNG, JPEG, GIF, WebP, ICO or SVG, at most 512 KB. An uploaded icon takes precedence over the logo URL.", + "iconUploadHint": "PNG, JPEG, GIF, WebP, ICO or SVG, at most 512 KB. An uploaded icon takes precedence over the logo URL; a newly entered logo URL replaces it, though.", "iconUploadedHint": "A custom icon has been uploaded for this favorite.", "iconRemoveButton": "Remove uploaded icon", - "iconUrlUnreachable": "The image at this address could not be loaded. The site likely blocks automated requests (for example via a Cloudflare check) or is unreachable. Please upload the icon instead.", "iconTooLarge": "The file is too large – at most 512 KB is allowed.", "iconInvalidType": "Only PNG, JPEG, GIF, WebP, ICO or SVG images are allowed.", "iconUploadFailed": "The icon could not be uploaded."