fix(favorites): Logo auch fuer Seiten, die es per JavaScript setzen
hosteurope.de liefert im HTML nur einen leeren data:-Platzhalter, das echte Symbol setzt erst JavaScript; /favicon.ico antwortet mit HTML. Die Kachel zeigte deshalb nur den Buchstaben. Scheitert das gespeicherte Symbol, fragt getIconBytes jetzt einmal den DuckDuckGo-Symboldienst - nur fuer oeffentlich erreichbare Seiten, interne Hostnamen verlassen das Haus nicht; kennt der Dienst nichts (404), bleibt es beim Buchstaben. Wirkt auch fuer bestehende Favoriten. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -220,7 +220,11 @@ function expectBoundCall(
|
||||
}
|
||||
|
||||
function makeIconDiscovery(
|
||||
overrides: Partial<{ discoverFavoriteIconUrl: any; fetchIconBytes: any }> = {},
|
||||
overrides: Partial<{
|
||||
discoverFavoriteIconUrl: any;
|
||||
fetchIconBytes: any;
|
||||
fetchPublicServiceIconBytes: any;
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
discoverFavoriteIconUrl:
|
||||
@@ -229,6 +233,11 @@ function makeIconDiscovery(
|
||||
fetchIconBytes:
|
||||
overrides.fetchIconBytes ??
|
||||
vi.fn(async () => ({ contentType: 'image/png', body: Buffer.from('png') })),
|
||||
fetchPublicServiceIconBytes:
|
||||
overrides.fetchPublicServiceIconBytes ??
|
||||
vi.fn(async () => {
|
||||
throw new Error('icon service: unknown');
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -511,6 +520,35 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
||||
await expect(service.getIconBytes('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('quick-261001-hbi: gespeichertes Symbol scheitert -> Symbol-Dienst mit der Seiten-URL', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery({
|
||||
fetchIconBytes: vi.fn(async () => {
|
||||
throw new Error('not an image');
|
||||
}),
|
||||
fetchPublicServiceIconBytes: vi.fn(async () => ({
|
||||
contentType: 'image/png',
|
||||
body: Buffer.from('ddg'),
|
||||
})),
|
||||
});
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
const result = await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||
|
||||
expect(iconDiscovery.fetchPublicServiceIconBytes).toHaveBeenCalledWith(baseRow.url);
|
||||
expect(result.body).toEqual(Buffer.from('ddg'));
|
||||
});
|
||||
|
||||
it('quick-261001-hbi: gespeichertes Symbol klappt -> Symbol-Dienst wird nicht gefragt', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||
|
||||
expect(iconDiscovery.fetchPublicServiceIconBytes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetchIconBytes wirft -> HttpException mit Status 502', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery({
|
||||
|
||||
@@ -497,7 +497,9 @@ export class FavoritesService {
|
||||
* Throws NotFoundException (404) if the row doesn't exist, isn't owned
|
||||
* by the caller, or has neither an uploaded icon nor a stored iconUrl.
|
||||
* Throws a 502 HttpException if the upstream fetch fails (unreachable,
|
||||
* timeout, non-image, or SSRF-blocked) -- never returns a placeholder image.
|
||||
* timeout, non-image, or SSRF-blocked) AND the public icon service fallback
|
||||
* (quick-261001-hbi, public pages only) has no icon either -- never returns
|
||||
* a placeholder image.
|
||||
*/
|
||||
async getIconBytes(
|
||||
tenantId: string,
|
||||
@@ -536,7 +538,14 @@ export class FavoritesService {
|
||||
try {
|
||||
return await this.iconDiscovery.fetchIconBytes(link.iconUrl);
|
||||
} catch {
|
||||
throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY);
|
||||
// quick-261001-hbi: Seite liefert kein abrufbares Symbol (z. B. per
|
||||
// JavaScript gesetzt) -- einmal beim oeffentlichen Symbol-Dienst fragen,
|
||||
// nur fuer oeffentlich erreichbare Seiten.
|
||||
try {
|
||||
return await this.iconDiscovery.fetchPublicServiceIconBytes(link.url);
|
||||
} catch {
|
||||
throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +238,57 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('IconDiscoveryService.fetchPublicServiceIconBytes (quick-261001-hbi)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('fragt fuer eine oeffentliche Seite den Symbol-Dienst mit dem Hostnamen', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockResponse({ contentType: 'image/png' }));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const service = new IconDiscoveryService();
|
||||
const result = await service.fetchPublicServiceIconBytes('http://8.8.8.8/start');
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe('https://icons.duckduckgo.com/ip3/8.8.8.8.ico');
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('fragt fuer eine interne Seite NICHT (Hostname verlaesst das Haus nicht)', async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const service = new IconDiscoveryService();
|
||||
|
||||
await expect(
|
||||
service.fetchPublicServiceIconBytes('https://docuvita.ctl.local/x'),
|
||||
).rejects.toThrow(/not public/);
|
||||
await expect(service.fetchPublicServiceIconBytes('http://192.168.1.5/')).rejects.toThrow(
|
||||
/not public/,
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Dienst kennt kein Symbol (404) -> wirft', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
...mockResponse({ contentType: 'image/png' }),
|
||||
ok: false,
|
||||
status: 404,
|
||||
}),
|
||||
);
|
||||
|
||||
const service = new IconDiscoveryService();
|
||||
|
||||
await expect(service.fetchPublicServiceIconBytes('http://8.8.8.8/')).rejects.toThrow(
|
||||
/blocked or failed/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
|
||||
@@ -25,6 +25,18 @@ const MAX_REDIRECTS = 2;
|
||||
const MAX_HTML_CHARS = 200000;
|
||||
const MAX_ICON_BYTES = 1_000_000;
|
||||
|
||||
/**
|
||||
* quick-261001-hbi — oeffentlicher Symbol-Dienst als letzter Rueckfall. Manche
|
||||
* Seiten setzen ihr Symbol erst per JavaScript (hosteurope.de: im HTML nur
|
||||
* `<link rel="icon" href="data:;base64,=">`, `/favicon.ico` liefert eine
|
||||
* HTML-Seite) — ohne Browser findet die Suche dort nichts. DuckDuckGo kennt
|
||||
* das gerenderte Symbol und antwortet fuer Unbekanntes mit 404 (dann bleibt
|
||||
* der Buchstabe). Gefragt wird NUR fuer oeffentlich erreichbare Adressen,
|
||||
* damit interne Hostnamen (docuvita.ctl.local, private IPs) das Haus nie
|
||||
* verlassen; der Dienst erfaehrt nur den Hostnamen.
|
||||
*/
|
||||
const PUBLIC_ICON_SERVICE = 'https://icons.duckduckgo.com/ip3/';
|
||||
|
||||
/**
|
||||
* 260917-jdd — Ziel ist ein Bildchen, kein Geheimnis: selbstsignierte,
|
||||
* abgelaufene oder falsch benannte Zertifikate sollen das Symbol eines
|
||||
@@ -426,6 +438,22 @@ export class IconDiscoveryService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* quick-261001-hbi: Symbol fuer die Seite `pageUrl` beim oeffentlichen
|
||||
* Symbol-Dienst holen (siehe PUBLIC_ICON_SERVICE). Wirft, wenn die Seite
|
||||
* nicht oeffentlich erreichbar ist (dann wird der Dienst NICHT gefragt) oder
|
||||
* der Dienst kein Symbol kennt (404) — wie `fetchIconBytes`.
|
||||
*/
|
||||
async fetchPublicServiceIconBytes(
|
||||
pageUrl: string,
|
||||
): Promise<{ contentType: string; body: Buffer }> {
|
||||
const page = new URL(normalizeUrl(pageUrl));
|
||||
if (!(await isPublicHttpUrl(page))) {
|
||||
throw new Error('Page is not public, icon service not asked');
|
||||
}
|
||||
return this.fetchIconBytes(`${PUBLIC_ICON_SERVICE}${page.hostname}.ico`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the raw bytes of a stored icon URL, SSRF-guarded, for streaming
|
||||
* back to the browser from Tessera's own origin (avoids Cross-Origin-
|
||||
|
||||
Reference in New Issue
Block a user