fix(favorites): normalize scheme-less URLs so favicons resolve
Tessera CI/CD / Lint & Type Check (push) Successful in 38s
Tessera CI/CD / Tests (push) Successful in 40s
Tessera CI/CD / Build & Publish Images (push) Successful in 22s

A favorite entered as a bare host ("ctl.de") passed @IsUrl() but had no
scheme, so `new URL()` threw inside icon discovery and it silently fell
back to a relative "/favicon.ico" — which 502'd through the icon proxy
and left the widget showing the first-letter placeholder ("C").

- add normalizeUrl() (prepend https:// when no scheme present)
- apply it in discoverFavoriteIconUrl and when storing the favorite url,
  so both the link and discovery use the normalized value
- on update, re-run discovery when the icon field is cleared, so editing
  a previously-broken favorite repairs its icon
- tests: normalizeUrl cases + end-to-end discovery (apple-touch extraction,
  scheme-less fallback stays absolute)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-14 13:58:44 +02:00
parent 9553e5304d
commit 9b65ac63c3
3 changed files with 112 additions and 8 deletions
@@ -124,6 +124,21 @@ export async function isPublicHttpUrl(url: URL): Promise<boolean> {
}
}
/**
* 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<string> {
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;