/** * Favicon-resolution helpers shared between mobile + extension. * * Items in the vault (and analogues in other Entropy apps) store an * `icon_url` reference that may be: * * * a `custom:` marker for a server-side upload — resolved * at render time through the auth-aware transport; * * an arbitrary public URL (logo CDN, etc.) — used verbatim; * * `null` / empty — the renderer derives a favicon URL from the * item's site URL via the server's favicon-proxy endpoint. */ import { getBaseUrl } from "./api-config"; const CUSTOM_ICON_PREFIX = "custom:"; /** * Build the favicon-proxy URL for a site, or return `null` when no * proxy URL can be built. The caller falls back to its default * placeholder when null is returned. */ export function buildFaviconUrl( siteUrl: string | null | undefined, faviconPath = "/favicon" ): string | null { if (!siteUrl) { return null; } const trimmed = siteUrl.trim(); if (!trimmed) { return null; } let normalized = trimmed; // Reject non-http(s) schemes up front. If we skipped this and just // prepended `https://`, an `ftp:` input would parse as // `https://ftp://…` — `URL` accepts it, the protocol comes out as // `https:`, and a non-web origin leaks into the favicon proxy query. const explicitScheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(normalized)?.[1]; if (explicitScheme && !/^https?$/i.test(explicitScheme)) { return null; } if (!/^https?:\/\//i.test(normalized)) { normalized = `https://${normalized}`; } try { const parsed = new URL(normalized); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return null; } return `${getBaseUrl()}${faviconPath}?url=${encodeURIComponent(parsed.toString())}`; } catch { return null; } } /** Wrap a raw upload id as a `custom:` reference for storage. */ export function customIconRef(uploadId: string): string { return `${CUSTOM_ICON_PREFIX}${uploadId}`; } /** True if the stored icon reference points at a custom upload. */ export function isCustomIconRef(iconRef: string | null | undefined): boolean { return !!iconRef && iconRef.startsWith(CUSTOM_ICON_PREFIX); } /** Extract the upload id from a `custom:` reference. */ export function customIconId(iconRef: string | null | undefined): string | null { if (!iconRef || !iconRef.startsWith(CUSTOM_ICON_PREFIX)) { return null; } const id = iconRef.slice(CUSTOM_ICON_PREFIX.length); return id || null; } /** Tagged result describing how the renderer should source an item's icon. */ export type ResolvedIcon = | { kind: "upload"; uploadId: string } | { kind: "url"; uri: string } | { kind: "none" }; /** * Pick the right source for an item's icon. Returning a tagged variant * (rather than always coercing to a URL string) lets the renderer * handle the auth-required upload case differently from public-favicon * URLs without sniffing path patterns at the call site. */ export function resolveIcon(opts: { iconUrl?: string | null; siteUrl?: string | null; faviconPath?: string; }): ResolvedIcon { if (opts.iconUrl) { const uploadId = customIconId(opts.iconUrl); if (uploadId) { return { kind: "upload", uploadId }; } return { kind: "url", uri: opts.iconUrl }; } const fav = buildFaviconUrl(opts.siteUrl, opts.faviconPath); return fav ? { kind: "url", uri: fav } : { kind: "none" }; }