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
@@ -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(<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 () => {
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(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
@@ -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();
@@ -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 (
<div className={`relative flex shrink-0 items-center justify-center bg-muted ${box}`}>
@@ -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 && (
<img
data-testid={`icon-direct-${fav.id}`}
src={directSrc}
@@ -612,7 +638,7 @@ function FavoriteIcon({
loading="lazy"
referrerPolicy="no-referrer"
className={`relative rounded-sm ${img}`}
onError={() => setStage('none')}
onError={() => setStage((prev) => (typeof prev === 'number' ? prev + 1 : 0))}
/>
)}
</div>
+13 -15
View File
@@ -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 () => {
+7 -11
View File
@@ -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<FavoriteLink[]>
/**
* 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();
+1 -2
View File
@@ -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."
+1 -2
View File
@@ -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."