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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user