diff --git a/apps/api/src/favorites/favorites.service.ts b/apps/api/src/favorites/favorites.service.ts index 63b85c7..3b60e99 100644 --- a/apps/api/src/favorites/favorites.service.ts +++ b/apps/api/src/favorites/favorites.service.ts @@ -8,7 +8,7 @@ import { import { PrismaService } from '../prisma/prisma.service'; import { CreateFavoriteDto } from './dto/create-favorite.dto'; import { UpdateFavoriteDto } from './dto/update-favorite.dto'; -import { IconDiscoveryService } from './icon-discovery.service'; +import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service'; /** * Service for managing per-user, per-widget favorite links. @@ -42,11 +42,14 @@ export class FavoritesService { * If iconUrl is not provided, triggers server-side icon discovery with SSRF protection. */ async create(userId: string, tenantId: string, dto: CreateFavoriteDto) { + // Normalize so a scheme-less entry like "ctl.de" is stored (and discovered) + // as "https://ctl.de" — otherwise the link and icon discovery both break. + const url = normalizeUrl(dto.url); let iconUrl = dto.iconUrl ?? null; // Server-side icon discovery (D-05) — only when caller did not supply an icon if (!iconUrl) { - iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(dto.url); + iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url); } return this.prisma.favoriteLink.create({ @@ -55,7 +58,7 @@ export class FavoritesService { tenantId, widgetId: dto.widgetId, title: dto.title, - url: dto.url, + url, iconUrl, position: dto.position ?? 0, }, @@ -77,10 +80,26 @@ export class FavoritesService { const data: Record = {}; if (dto.title !== undefined) data.title = dto.title; - if (dto.url !== undefined) data.url = dto.url; - if ('iconUrl' in dto) data.iconUrl = dto.iconUrl; // Allows explicit null + + const normalizedUrl = + dto.url !== undefined ? normalizeUrl(dto.url) : undefined; + if (normalizedUrl !== undefined) data.url = normalizedUrl; + if (dto.position !== undefined) data.position = dto.position; + if ('iconUrl' in dto) { + if (dto.iconUrl) { + // Explicit icon URL supplied — respect it as-is. + data.iconUrl = dto.iconUrl; + } else { + // Icon cleared (empty/null) — re-run discovery against the effective + // (new or existing) url so editing a broken favorite repairs its icon. + const effectiveUrl = normalizedUrl ?? link.url; + data.iconUrl = + await this.iconDiscovery.discoverFavoriteIconUrl(effectiveUrl); + } + } + return this.prisma.favoriteLink.update({ where: { id }, data, diff --git a/apps/api/src/favorites/icon-discovery.service.spec.ts b/apps/api/src/favorites/icon-discovery.service.spec.ts index c1ea368..094c99a 100644 --- a/apps/api/src/favorites/icon-discovery.service.spec.ts +++ b/apps/api/src/favorites/icon-discovery.service.spec.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { IconDiscoveryService, isPublicHttpUrl } from './icon-discovery.service'; +import { + IconDiscoveryService, + isPublicHttpUrl, + normalizeUrl, +} from './icon-discovery.service'; function mockResponse(options: { contentType?: string; @@ -46,6 +50,71 @@ describe('isPublicHttpUrl', () => { }); }); +describe('normalizeUrl', () => { + it('prepends https:// to a scheme-less host', () => { + expect(normalizeUrl('ctl.de')).toBe('https://ctl.de'); + expect(normalizeUrl('www.ctl.de/path')).toBe('https://www.ctl.de/path'); + }); + + it('leaves an existing scheme untouched', () => { + expect(normalizeUrl('http://ctl.de')).toBe('http://ctl.de'); + expect(normalizeUrl('https://ctl.de')).toBe('https://ctl.de'); + }); + + it('trims surrounding whitespace', () => { + expect(normalizeUrl(' ctl.de ')).toBe('https://ctl.de'); + }); + + it('returns empty string unchanged', () => { + expect(normalizeUrl(' ')).toBe(''); + }); +}); + +describe('IconDiscoveryService.discoverFavoriteIconUrl', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('extracts the apple-touch-icon from page HTML', async () => { + const html = ` + + + `; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: (n: string) => + n.toLowerCase() === 'content-type' + ? 'text/html; charset=utf-8' + : null, + }, + text: async () => html, + }), + ); + + const service = new IconDiscoveryService(); + // Public IP avoids a real DNS lookup in the SSRF guard. + const icon = await service.discoverFavoriteIconUrl('http://8.8.8.8'); + + expect(icon).toBe('https://ctl.de/apple-180.jpg'); + }); + + it('normalizes a scheme-less URL so the fallback is absolute, not "/favicon.ico"', async () => { + // fetch fails → discovery falls back. The fallback must be an absolute + // https origin URL, not the broken relative path that produced the bug. + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network'))); + + const service = new IconDiscoveryService(); + const icon = await service.discoverFavoriteIconUrl('ctl.de'); + + expect(icon).toBe('https://ctl.de/favicon.ico'); + }); +}); + describe('IconDiscoveryService.fetchIconBytes', () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/apps/api/src/favorites/icon-discovery.service.ts b/apps/api/src/favorites/icon-discovery.service.ts index b73f6e8..33fed34 100644 --- a/apps/api/src/favorites/icon-discovery.service.ts +++ b/apps/api/src/favorites/icon-discovery.service.ts @@ -124,6 +124,21 @@ export async function isPublicHttpUrl(url: URL): Promise { } } +/** + * Normalize a user-entered site URL by prepending https:// when no scheme is + * present, so "ctl.de" becomes "https://ctl.de". Without this, `new URL()` + * throws on a bare host and icon discovery silently falls back to a broken + * relative "/favicon.ico" (which then 502s through the icon proxy and the + * widget shows the first-letter placeholder instead of the real favicon). + */ +export function normalizeUrl(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return trimmed; + // Already has a scheme (http://, https://, ftp://, ...) — leave untouched. + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return trimmed; + return `https://${trimmed}`; +} + function getOriginFaviconUrl(pageUrl: string): string { try { const url = new URL(pageUrl); @@ -301,10 +316,11 @@ export class IconDiscoveryService { * private IP ranges, blocked hostnames, and forced-proxy vectors (T-08-05). */ async discoverFavoriteIconUrl(pageUrl: string): Promise { - const fallback = getOriginFaviconUrl(pageUrl); + const normalized = normalizeUrl(pageUrl); + const fallback = getOriginFaviconUrl(normalized); try { - const url = new URL(pageUrl); + const url = new URL(normalized); const htmlResult = await fetchHtml(url); if (!htmlResult) return fallback;