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 <link rel=icon>, 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) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 15:34:58 +02:00
parent 7188c5b958
commit b15c74632b
12 changed files with 307 additions and 112 deletions
@@ -5,6 +5,7 @@ import {
IsString, IsString,
IsUrl, IsUrl,
IsUUID, IsUUID,
MaxLength,
} from 'class-validator'; } from 'class-validator';
/** /**
@@ -24,6 +25,7 @@ export class CreateFavoriteDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(2048)
iconUrl?: string; iconUrl?: string;
@IsOptional() @IsOptional()
@@ -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. * DTO for updating an existing FavoriteLink.
@@ -19,6 +19,8 @@ export class UpdateFavoriteDto {
* No strict type validation so null passes through to Prisma. * No strict type validation so null passes through to Prisma.
*/ */
@IsOptional() @IsOptional()
@IsString()
@MaxLength(2048)
iconUrl?: string | null; iconUrl?: string | null;
@IsOptional() @IsOptional()
@@ -6,7 +6,6 @@ import {
HttpException, HttpException,
NotFoundException, NotFoundException,
PayloadTooLargeException, PayloadTooLargeException,
UnprocessableEntityException,
} from '@nestjs/common'; } from '@nestjs/common';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FavoritesService } from './favorites.service'; 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)', () => { describe('create/update — ausdrueckliche iconUrl: nur Formpruefung, kein Abruf (260929-lh3)', () => {
it('create mit expliziter iconUrl: Probe genau einmal; wirft -> UnprocessableEntityException, favoriteLink.create NICHT aufgerufen', async () => { const widgets = [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }];
const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]); const failingFetch = () =>
const iconDiscovery = makeIconDiscovery({ vi.fn(async () => {
fetchIconBytes: vi.fn(async () => { throw new Error('server bekommt 404/HTML');
throw new Error('blocked');
}),
}); });
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 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( await expect(
service.create('t1', 'user-a1', { service.create('t1', 'user-a1', {
widgetId: 'widget-a1', widgetId: 'widget-a1',
title: 'X', title: 'X',
url: 'https://x.invalid', url: 'https://x.invalid',
iconUrl: 'https://x.invalid/logo.png', iconUrl,
} as any), } as any),
).rejects.toThrow(UnprocessableEntityException); ).rejects.toThrow(BadRequestException);
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1);
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://x.invalid/logo.png');
expect(prisma.__favorites.size).toBe(0); expect(prisma.__favorites.size).toBe(0);
}); });
@@ -956,24 +979,31 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
iconVersion: 0, 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 prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery({ const iconDiscovery = makeIconDiscovery({ fetchIconBytes: failingFetch() });
fetchIconBytes: vi.fn(async () => {
throw new Error('blocked');
}),
});
const service = new FavoritesService(prisma as any, iconDiscovery as any); 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( await expect(
service.update('t1', 'f1', 'user-a1', { iconUrl: 'https://neu.invalid/icon.png' } as any), service.update('t1', 'f1', 'user-a1', { iconUrl: 'file:///etc/passwd' } as any),
).rejects.toThrow(UnprocessableEntityException); ).rejects.toThrow(BadRequestException);
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1);
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://neu.invalid/icon.png');
expect(prisma.__favorites.get('f1').iconUrl).toBe(baseRow.iconUrl); 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 prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery(); const iconDiscovery = makeIconDiscovery();
const service = new FavoritesService(prisma as any, iconDiscovery as any); 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); 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);
});
}); });
}); });
+42 -26
View File
@@ -9,7 +9,6 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
PayloadTooLargeException, PayloadTooLargeException,
UnprocessableEntityException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { UploadedFileLike } from '../auth/types/auth-user'; import type { UploadedFileLike } from '../auth/types/auth-user';
import { PrismaService } from '../prisma/prisma.service'; 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 * Datei, dann die Zeile; scheitert die Zeile, wird die neue Datei wieder
* entfernt. Entfernen/Loeschen aktualisiert zuerst die Zeile, ein * entfernt. Entfernen/Loeschen aktualisiert zuerst die Zeile, ein
* Dateifehler wird protokolliert und geschluckt. * Dateifehler wird protokolliert und geschluckt.
* - Abrufprobe: `assertIconUrlLoadable()` ruft `fetchIconBytes` einmal ab, * - 260929-lh3 (loest die Abrufprobe von 260923-lrr ab): eine ausdrueckliche
* um eine im Formular NICHT abrufbare Logo-Adresse (z. B. hinter einer * Logo-Adresse wird nur auf Form (http/https, <= 2048 Zeichen) geprueft und
* Cloudflare-Pruefung) mit `UnprocessableEntityException` (422) statt * auch gespeichert, wenn der Server sie nicht abrufen kann — der Browser der
* stiller Speicherung abzuweisen — keine Umgehung von Bot-Sperren, nur * Kachel laedt sie dann direkt. Ein hochgeladenes Symbol wird von einer
* derselbe Abruf, den `GET /favorites/:id/icon` ohnehin ausloest. * 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() @Injectable()
export class FavoritesService { export class FavoritesService {
private readonly logger = new Logger(FavoritesService.name); private readonly logger = new Logger(FavoritesService.name);
@@ -107,19 +110,31 @@ export class FavoritesService {
} }
/** /**
* Prueft, ob sich das Bild unter `iconUrl` serverseitig abrufen laesst * Prueft eine ausdruecklich eingetragene Logo-Adresse NUR auf Form (260929-lh3):
* (260923-lrr) — derselbe `fetchIconBytes`-Aufruf, den `getIconBytes` * gueltige http/https-Adresse, hoechstens 2048 Zeichen. Bewusst KEIN
* ohnehin ausloest, hier nur zur Speicherzeit als Probe. Jeder Fehler * serverseitiger Abruf mehr — Server wie docuvita liefern dem Server ein
* (SSRF-Ablehnung, Zeitgrenze, kein `image/*`, Cloudflare-Pruefung o. ae.) * 404/HTML, dem Browser aber das Bild; die fruehere Abrufprobe (422,
* wird zu derselben deutschen 422-Meldung — keine Unterscheidung, aus der * 260923-lrr) machte genau diese Adressen unspeicherbar. Entscheidung: auch
* sich etwas ueber die gepruefte Adresse ablesen liesse. * 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<void> { private assertIconUrlWellFormed(iconUrl: string): void {
let parsed: URL | null = null;
try { try {
await this.iconDiscovery.fetchIconBytes(iconUrl); parsed = new URL(iconUrl);
} catch { } catch {
throw new UnprocessableEntityException( parsed = null;
'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.', }
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 * Verifies the target widget belongs to the caller BEFORE any icon
* discovery network call (T-GWH-05). * discovery network call (T-GWH-05).
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection. * 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 * If iconUrl IS provided it is stored as given after a form check only
* create is rejected with 422 — nothing is written on a failed probe. * (260929-lh3, see assertIconUrlWellFormed) — no server-side fetch.
*/ */
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) { async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
@@ -154,8 +169,8 @@ export class FavoritesService {
let iconUrl = dto.iconUrl ?? null; let iconUrl = dto.iconUrl ?? null;
if (iconUrl) { if (iconUrl) {
// 260923-lrr: explizit uebergebene Adresse wird einmal probiert. // 260929-lh3: nur Formpruefung, kein serverseitiger Abruf.
await this.assertIconUrlLoadable(iconUrl); this.assertIconUrlWellFormed(iconUrl);
} else { } else {
// Server-side icon discovery (D-05) — only when caller did not supply an icon // Server-side icon discovery (D-05) — only when caller did not supply an icon
iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url); iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url);
@@ -179,10 +194,11 @@ export class FavoritesService {
* Verifies userId ownership before applying changes (T-08-06). * Verifies userId ownership before applying changes (T-08-06).
* Accepts null as an explicit value for iconUrl (clears stored icon). * Accepts null as an explicit value for iconUrl (clears stored icon).
* *
* 260923-lrr: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl` * 260929-lh3: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl`
* durchlaeuft die Abrufprobe (`assertIconUrlLoadable`), bevor irgendetwas * durchlaeuft nur die Formpruefung (`assertIconUrlWellFormed`), bevor
* geschrieben wird; misslingt sie, bleibt die Zeile unveraendert. Jede * irgendetwas geschrieben wird; sie wird auch gespeichert, wenn der Server
* tatsaechliche Aenderung der Symbolquelle erhoeht `iconVersion`. * sie nicht abrufen kann. Jede tatsaechliche Aenderung der Symbolquelle
* erhoeht `iconVersion`.
*/ */
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) { async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
@@ -205,8 +221,8 @@ export class FavoritesService {
if ('iconUrl' in dto) { if ('iconUrl' in dto) {
if (dto.iconUrl) { if (dto.iconUrl) {
if (dto.iconUrl !== link.iconUrl) { if (dto.iconUrl !== link.iconUrl) {
// 260923-lrr: nur eine NEUE, abweichende Adresse wird probiert. // 260929-lh3: nur eine NEUE, abweichende Adresse wird geprueft (Form).
await this.assertIconUrlLoadable(dto.iconUrl); this.assertIconUrlWellFormed(dto.iconUrl);
} }
// Explicit icon URL supplied — respect it as-is. // Explicit icon URL supplied — respect it as-is.
data.iconUrl = dto.iconUrl; data.iconUrl = dto.iconUrl;
@@ -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 <link rel="SHORTCUT ICON"> (docuvita) -> dieser Verweis wird genutzt', async () => {
const html =
'<html><head><link rel="SHORTCUT ICON" type="image/png" href="/webclient/docuvita/resources/brandimage/favicon.ico" /></head></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 <origin>/favicon.ico (og:image einer Fehlerseite zaehlt nicht)', async () => {
const html = '<html><head><meta property="og:image" content="https://cdn.invalid/x.png"></head></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', () => { describe('IconDiscoveryService.fetchIconBytes', () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
@@ -49,6 +49,8 @@ const LENIENT_TLS_AGENT = new Agent({ connect: { rejectUnauthorized: false } });
type FetchHtmlResult = { type FetchHtmlResult = {
html: string; html: string;
finalUrl: 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 { 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(/<link\b[^>]*>/gi) ?? []; const linkTags = html.match(/<link\b[^>]*>/gi) ?? [];
const metaTags = html.match(/<meta\b[^>]*>/gi) ?? []; const metaTags = html.match(/<meta\b[^>]*>/gi) ?? [];
@@ -238,6 +244,10 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
if (imageSrc) return imageSrc; if (imageSrc) return imageSrc;
// 260929-lh3: eine Fehlerseite (Status != 2xx) traegt kein Vorschaubild der
// Seite — nur die ausdruecklichen Symbol-Verweise (<link rel=...icon>) zaehlen.
if (linkTagsOnly) return null;
const metaImage = metaTags const metaImage = metaTags
.map((tag) => parseAttributes(tag)) .map((tag) => parseAttributes(tag))
.map((a) => ({ .map((a) => ({
@@ -266,7 +276,13 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
*/ */
async function fetchWithRedirectGuard( async function fetchWithRedirectGuard(
pageUrl: URL, 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> { ): Promise<{ response: UndiciResponse; finalUrl: URL } | null> {
let currentUrl = pageUrl; let currentUrl = pageUrl;
@@ -298,7 +314,7 @@ async function fetchWithRedirectGuard(
continue; continue;
} }
if (!response.ok) return null; if (!response.ok && !options.allowErrorStatus) return null;
return { response, finalUrl: currentUrl }; return { response, finalUrl: currentUrl };
} catch { } catch {
@@ -315,6 +331,9 @@ async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
const result = await fetchWithRedirectGuard(pageUrl, { const result = await fetchWithRedirectGuard(pageUrl, {
accept: 'text/html,application/xhtml+xml,*/*', accept: 'text/html,application/xhtml+xml,*/*',
timeoutMs: HTML_FETCH_TIMEOUT_MS, timeoutMs: HTML_FETCH_TIMEOUT_MS,
// 260929-lh3: Server wie docuvita antworten dem Server mit 400, tragen im
// HTML aber trotzdem den <link rel=icon> — den Verweis wollen wir haben.
allowErrorStatus: true,
}); });
if (!result) return null; if (!result) return null;
@@ -328,6 +347,7 @@ async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
return { return {
html: html.slice(0, MAX_HTML_CHARS), // T-08-09: HTML cap html: html.slice(0, MAX_HTML_CHARS), // T-08-09: HTML cap
finalUrl: result.finalUrl.toString(), finalUrl: result.finalUrl.toString(),
ok: result.response.ok,
}; };
} }
@@ -350,7 +370,10 @@ export class IconDiscoveryService {
if (!htmlResult) return fallback; if (!htmlResult) return fallback;
return extractIconFromHtml(htmlResult.html, htmlResult.finalUrl) ?? fallback; return (
extractIconFromHtml(htmlResult.html, htmlResult.finalUrl, !htmlResult.ok) ??
fallback
);
} catch { } catch {
return fallback; return fallback;
} }
@@ -490,6 +490,75 @@ describe('FavoritesWidget', () => {
expect(screen.getByTestId('letter-fallback-fav-id-1')).toHaveTextContent('G'); 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(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
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(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
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 () => { it('kein Direktbild bei Nicht-http-URL', async () => {
mockFetch.mockResolvedValue([ mockFetch.mockResolvedValue([
{ id: 'fav-id-3', widgetId: 'fav-1', title: 'Ablage', url: 'ftp://files.example', iconUrl: null, position: 0 }, { 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 () => { 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 FavoriteRequestError('iconUrlUnreachable')); mockUpdate.mockRejectedValue(new Error('Failed to update favorite'));
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />); render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
@@ -753,7 +822,7 @@ describe('FavoritesWidget', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('favorites.iconUrlUnreachable'); expect(screen.getByRole('alert')).toHaveTextContent('favorites.error');
}); });
expect(mockUploadIcon).not.toHaveBeenCalled(); expect(mockUploadIcon).not.toHaveBeenCalled();
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument(); expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
@@ -23,7 +23,7 @@ const TITLE_DEBOUNCE_MS = 1500;
/** /**
* Bildet einen Fehler aus dem Favoriten-Klienten auf einen Uebersetzungs- * Bildet einen Fehler aus dem Favoriten-Klienten auf einen Uebersetzungs-
* schluessel ab (260923-lrr): `FavoriteRequestError` traegt den passenden * schluessel ab (260923-lrr): `FavoriteRequestError` traegt den passenden
* Grund (`iconUrlUnreachable`/`iconTooLarge`/`iconInvalidType`/ * Grund (`iconTooLarge`/`iconInvalidType`/
* `iconUploadFailed`) bereits als `reason`, jeder andere Fehler faellt auf * `iconUploadFailed`) bereits als `reason`, jeder andere Fehler faellt auf
* die bisherige allgemeine Meldung zurueck. * 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 * 1. `proxy` — Server-Proxy (GET /favorites/:id/icon), der seit diesem Plan
* auch bei Zertifikatsfehlern des Zielhosts liefert (undici-Dispatcher). * auch bei Zertifikatsfehlern des Zielhosts liefert (undici-Dispatcher).
* 2. `direct` — Direktbild aus dem Browser des Nutzers * 2. `direct` — Direktbilder aus dem Browser des Nutzers
* (`referrerPolicy="no-referrer"`, Origin nur aus http/https); erreicht * (`referrerPolicy="no-referrer"`, nur http/https), nacheinander:
* interne Hosts, die der SSRF-Schutz des Servers absichtlich ablehnt. * 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. * 3. `none` — der Buchstaben-Platzhalter liegt IMMER darunter.
* *
* Bewusst KEIN Drittanbieter-Favicon-Dienst: der wuerde Hostnamen nach * Bewusst KEIN Drittanbieter-Favicon-Dienst: der wuerde Hostnamen nach
@@ -575,10 +599,12 @@ function FavoriteIcon({
const proxySrc = hasServerIcon const proxySrc = hasServerIcon
? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon?v=${fav.iconVersion ?? 0}` ? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon?v=${fav.iconVersion ?? 0}`
: null; : null;
const directSrc = getDirectFaviconSrc(fav.url); // 260929-lh3: Direkt-Kandidaten in Reihenfolge — gespeicherte Adresse zuerst,
const [stage, setStage] = useState<'proxy' | 'direct' | 'none'>( // dann das Origin-Favicon; nur http/https, ohne Doppelte.
proxySrc ? 'proxy' : 'direct', 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 ( return (
<div className={`relative flex shrink-0 items-center justify-center bg-muted ${box}`}> <div className={`relative flex shrink-0 items-center justify-center bg-muted ${box}`}>
@@ -598,10 +624,10 @@ function FavoriteIcon({
height={px} height={px}
loading="lazy" loading="lazy"
className={`relative rounded-sm ${img}`} className={`relative rounded-sm ${img}`}
onError={() => setStage('direct')} onError={() => setStage(0)}
/> />
)} )}
{stage === 'direct' && directSrc && ( {directSrc && (
<img <img
data-testid={`icon-direct-${fav.id}`} data-testid={`icon-direct-${fav.id}`}
src={directSrc} src={directSrc}
@@ -612,7 +638,7 @@ function FavoriteIcon({
loading="lazy" loading="lazy"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
className={`relative rounded-sm ${img}`} className={`relative rounded-sm ${img}`}
onError={() => setStage('none')} onError={() => setStage((prev) => (typeof prev === 'number' ? prev + 1 : 0))}
/> />
)} )}
</div> </div>
+13 -15
View File
@@ -14,9 +14,9 @@ import {
* Die vier neuen/geaenderten Aufrufe: `uploadFavoriteIcon` (FormData-Feld * Die vier neuen/geaenderten Aufrufe: `uploadFavoriteIcon` (FormData-Feld
* `icon`, Groessenpruefung vor dem Netzwerkaufruf, 413/400/sonst -> * `icon`, Groessenpruefung vor dem Netzwerkaufruf, 413/400/sonst ->
* `FavoriteRequestError`), `removeFavoriteIcon` (DELETE), sowie * `FavoriteRequestError`), `removeFavoriteIcon` (DELETE), sowie
* `createFavorite`/`updateFavorite` (422 -> `FavoriteRequestError` mit * `createFavorite`/`updateFavorite` (jeder Fehler bleibt eine generische
* `reason: 'iconUrlUnreachable'`, jeder andere Fehler bleibt eine generische * `Error`; die 422-Abrufprobe ist seit 260929-lh3 entfallen). Muster
* `Error` wie bisher). Muster `dashboard-images-api.test.ts`. * `dashboard-images-api.test.ts`.
*/ */
const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() })); const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() }));
@@ -112,9 +112,9 @@ describe('favorites-api (quick-260923-lrr)', () => {
expect(init.credentials).toBe('include'); expect(init.credentials).toBe('include');
}); });
describe('createFavorite / updateFavorite — Abrufprobe (260923-lrr)', () => { describe('createFavorite / updateFavorite — Fehler bleiben generisch (260929-lh3)', () => {
it('createFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable', async () => { it('createFavorite: 400 (ungueltige Logo-Adresse) -> generische Error, KEIN FavoriteRequestError', async () => {
mockFetch.mockResolvedValue(new Response('{}', { status: 422 })); mockFetch.mockResolvedValue(new Response('{}', { status: 400 }));
const err = await createFavorite({ const err = await createFavorite({
widgetId: 'w1', widgetId: 'w1',
@@ -123,8 +123,8 @@ describe('favorites-api (quick-260923-lrr)', () => {
iconUrl: 'https://x.invalid/logo.png', iconUrl: 'https://x.invalid/logo.png',
}).catch((e) => e); }).catch((e) => e);
expect(err).toBeInstanceOf(FavoriteRequestError); expect(err).toBeInstanceOf(Error);
expect((err as FavoriteRequestError).reason).toBe('iconUrlUnreachable'); expect(err).not.toBeInstanceOf(FavoriteRequestError);
}); });
it('createFavorite: anderer Fehler -> wie bisher eine generische Error', async () => { 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); expect(err).not.toBeInstanceOf(FavoriteRequestError);
}); });
it('updateFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable', async () => { it('updateFavorite: 400 (ungueltige Logo-Adresse) -> generische Error, KEIN FavoriteRequestError', async () => {
mockFetch.mockResolvedValue(new Response('{}', { status: 422 })); mockFetch.mockResolvedValue(new Response('{}', { status: 400 }));
const err = await updateFavorite('fav-1', { iconUrl: 'https://x.invalid/logo.png' }).catch( const err = await updateFavorite('fav-1', { iconUrl: 'https://x.invalid/logo.png' }).catch((e) => e);
(e) => e,
);
expect(err).toBeInstanceOf(FavoriteRequestError); expect(err).toBeInstanceOf(Error);
expect((err as FavoriteRequestError).reason).toBe('iconUrlUnreachable'); expect(err).not.toBeInstanceOf(FavoriteRequestError);
}); });
it('updateFavorite: anderer Fehler -> wie bisher eine generische Error', async () => { it('updateFavorite: anderer Fehler -> wie bisher eine generische Error', async () => {
+7 -11
View File
@@ -7,10 +7,9 @@
* 260923-lrr — eigenes Symbol hochladen, Zwischenspeicher nach Aenderung * 260923-lrr — eigenes Symbol hochladen, Zwischenspeicher nach Aenderung
* erneuern: `FavoriteLink` traegt jetzt `uploadedIconMime`/`iconVersion` * erneuern: `FavoriteLink` traegt jetzt `uploadedIconMime`/`iconVersion`
* (optional, weil Testdaten und aeltere Antworten sie nicht tragen). * (optional, weil Testdaten und aeltere Antworten sie nicht tragen).
* `createFavorite`/`updateFavorite` uebersetzen ein 422 (Abrufprobe der API * `FavoriteRequestError` traegt den Grund einer abgewiesenen Symbol-Datei
* fuer eine nicht abrufbare Logo-Adresse) in `FavoriteRequestError`, damit * (Widget bildet ihn auf eine sprachrichtige Meldung ab). 260929-lh3: die
* das Widget eine sprachrichtige Meldung zeigen kann, statt der bisherigen * 422-Abrufprobe fuer Logo-Adressen ist entfallen. `uploadFavoriteIcon`/`removeFavoriteIcon` sind neu,
* generischen `Error`. `uploadFavoriteIcon`/`removeFavoriteIcon` sind neu,
* Muster `uploadDashboardImage`/`deleteDashboardImage` (dashboard-images-api.ts). * Muster `uploadDashboardImage`/`deleteDashboardImage` (dashboard-images-api.ts).
*/ */
@@ -34,7 +33,6 @@ export interface FavoriteLink {
/** Grund einer abgewiesenen Favoriten-Anfrage (260923-lrr). */ /** Grund einer abgewiesenen Favoriten-Anfrage (260923-lrr). */
export type FavoriteErrorReason = export type FavoriteErrorReason =
| 'iconUrlUnreachable'
| 'iconTooLarge' | 'iconTooLarge'
| 'iconInvalidType' | 'iconInvalidType'
| 'iconUploadFailed'; | 'iconUploadFailed';
@@ -72,8 +70,9 @@ export async function fetchFavorites(widgetId: string): Promise<FavoriteLink[]>
/** /**
* Create a new favorite link. * Create a new favorite link.
* Server-side icon discovery runs automatically if iconUrl is not provided. * Server-side icon discovery runs automatically if iconUrl is not provided.
* 260923-lrr: eine explizite `iconUrl`, die sich serverseitig nicht laden * 260929-lh3: eine ausdrueckliche `iconUrl` wird auch gespeichert, wenn der
* laesst, ergibt 422 -> `FavoriteRequestError('iconUrlUnreachable')`. * 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: { export async function createFavorite(payload: {
widgetId: string; widgetId: string;
@@ -87,7 +86,6 @@ export async function createFavorite(payload: {
credentials: 'include', credentials: 'include',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (res.status === 422) throw new FavoriteRequestError('iconUrlUnreachable');
if (!res.ok) throw new Error('Failed to create favorite'); if (!res.ok) throw new Error('Failed to create favorite');
return res.json(); return res.json();
@@ -96,8 +94,7 @@ export async function createFavorite(payload: {
/** /**
* Update an existing favorite link. * Update an existing favorite link.
* Pass iconUrl: null to clear a stored icon. * Pass iconUrl: null to clear a stored icon.
* 260923-lrr: eine neue, nicht abrufbare `iconUrl` ergibt ebenso 422 -> * 260929-lh3: wie `createFavorite` — keine 422-Abweisung wegen Abrufbarkeit mehr.
* `FavoriteRequestError('iconUrlUnreachable')`.
*/ */
export async function updateFavorite( export async function updateFavorite(
id: string, id: string,
@@ -109,7 +106,6 @@ export async function updateFavorite(
credentials: 'include', credentials: 'include',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (res.status === 422) throw new FavoriteRequestError('iconUrlUnreachable');
if (!res.ok) throw new Error('Failed to update favorite'); if (!res.ok) throw new Error('Failed to update favorite');
return res.json(); return res.json();
+1 -2
View File
@@ -380,10 +380,9 @@
"viewModeLabel": "Ansicht wechseln", "viewModeLabel": "Ansicht wechseln",
"iconUrlPlaceholder": "Logo-Adresse (optional)", "iconUrlPlaceholder": "Logo-Adresse (optional)",
"iconUploadLabel": "Eigenes Symbol hochladen", "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.", "iconUploadedHint": "Für diesen Favoriten ist ein eigenes Symbol hochgeladen.",
"iconRemoveButton": "Hochgeladenes Symbol entfernen", "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.", "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.", "iconInvalidType": "Nur Bilder im Format PNG, JPEG, GIF, WebP, ICO oder SVG sind erlaubt.",
"iconUploadFailed": "Das Symbol konnte nicht hochgeladen werden." "iconUploadFailed": "Das Symbol konnte nicht hochgeladen werden."
+1 -2
View File
@@ -380,10 +380,9 @@
"viewModeLabel": "Switch view", "viewModeLabel": "Switch view",
"iconUrlPlaceholder": "Logo URL (optional)", "iconUrlPlaceholder": "Logo URL (optional)",
"iconUploadLabel": "Upload custom icon", "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.", "iconUploadedHint": "A custom icon has been uploaded for this favorite.",
"iconRemoveButton": "Remove uploaded icon", "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.", "iconTooLarge": "The file is too large – at most 512 KB is allowed.",
"iconInvalidType": "Only PNG, JPEG, GIF, WebP, ICO or SVG images are allowed.", "iconInvalidType": "Only PNG, JPEG, GIF, WebP, ICO or SVG images are allowed.",
"iconUploadFailed": "The icon could not be uploaded." "iconUploadFailed": "The icon could not be uploaded."