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:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
|
||||
@@ -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', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
|
||||
@@ -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(/<link\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;
|
||||
|
||||
// 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
|
||||
.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<FetchHtmlResult | null> {
|
||||
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 <link rel=icon> — den Verweis wollen wir haben.
|
||||
allowErrorStatus: true,
|
||||
});
|
||||
|
||||
if (!result) return null;
|
||||
@@ -328,6 +347,7 @@ async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user