From 21ae96cb3a099619bee64ae08a5097d2b189a0c2 Mon Sep 17 00:00:00 2001 From: Juliet Date: Thu, 12 Mar 2026 23:53:31 +0100 Subject: [PATCH] favicon fetching through worker --- .gitignore | 1 + src/components/favicon.tsx | 17 +++++-- src/components/navbar.tsx | 19 ++++--- src/worker.js | 100 +++++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index c048587..c83402e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist public/oauth-client-metadata.json public/opensearch.xml public/_worker.js +.wrangler diff --git a/src/components/favicon.tsx b/src/components/favicon.tsx index 634f17a..39dc2e9 100644 --- a/src/components/favicon.tsx +++ b/src/components/favicon.tsx @@ -6,26 +6,33 @@ export const Favicon = (props: { wrapper?: (children: JSX.Element) => JSX.Element; }) => { const [loaded, setLoaded] = createSignal(false); + const [src, setSrc] = createSignal(""); const domain = () => (props.reverse ? props.domain.split(".").reverse().join(".") : props.domain); + const workerUrl = () => `/favicon?domain=${encodeURIComponent(domain())}`; + const directUrl = () => `https://${domain()}/favicon.ico`; + const content = ( - - - setLoaded(true)} - onError={() => setLoaded(false)} + onError={() => { + if (!src()) { + setSrc(directUrl()); + } else { + setLoaded(false); + } + }} /> diff --git a/src/components/navbar.tsx b/src/components/navbar.tsx index 85920d7..4229cf1 100644 --- a/src/components/navbar.tsx +++ b/src/components/navbar.tsx @@ -33,17 +33,22 @@ const CopyButton = (props: { content: string; label: string }) => { const HoverFavicon = (props: { domain: string; hovered: boolean; children: JSX.Element }) => { const [hasHovered, setHasHovered] = createSignal(false); const [loaded, setLoaded] = createSignal(false); + const [src, setSrc] = createSignal(""); createEffect(() => { props.domain; setHasHovered(false); setLoaded(false); + setSrc(""); }); createEffect(() => { if (props.hovered) setHasHovered(true); }); + const workerUrl = () => `/favicon?domain=${encodeURIComponent(props.domain)}`; + const directUrl = () => `https://${props.domain}/favicon.ico`; + return (
{props.children} @@ -58,15 +63,17 @@ const HoverFavicon = (props: { domain: string; hovered: boolean; children: JSX.E setLoaded(true)} - onError={() => setLoaded(false)} + onError={() => { + if (!src()) { + setSrc(directUrl()); + } else { + setLoaded(false); + } + }} /> diff --git a/src/worker.js b/src/worker.js index ea6f72f..c12c34a 100644 --- a/src/worker.js +++ b/src/worker.js @@ -486,6 +486,100 @@ class HeadEndRewriter { } } +const MAX_FAVICON_SIZE = 100 * 1024; // 100KB + +async function handleFavicon(searchParams) { + const domain = searchParams.get("domain"); + if (!domain) { + return new Response("Missing domain param", { status: 400 }); + } + + let faviconUrl = null; + try { + const pageRes = await fetch(`https://${domain}/`, { + signal: AbortSignal.timeout(5000), + headers: { "User-Agent": "PDSls-Favicon/1.0" }, + redirect: "follow", + }); + + if (pageRes.ok && (pageRes.headers.get("content-type") ?? "").includes("text/html")) { + let bestHref = null; + let bestPriority = -1; + + const rewriter = new HTMLRewriter().on("link", { + element(el) { + const rel = (el.getAttribute("rel") ?? "").toLowerCase(); + if (!rel.includes("icon")) return; + const href = el.getAttribute("href"); + if (!href) return; + + // Prefer apple-touch-icon > icon with sizes > icon > shortcut icon + let priority = 0; + if (rel === "apple-touch-icon") priority = 3; + else if (rel === "icon" && el.getAttribute("sizes")) priority = 2; + else if (rel === "icon") priority = 1; + + if (priority > bestPriority) { + bestPriority = priority; + bestHref = href; + } + }, + }); + + const transformed = rewriter.transform(pageRes); + await transformed.text(); + + if (bestHref) { + try { + faviconUrl = new URL(bestHref, `https://${domain}/`).href; + } catch { + faviconUrl = null; + } + } + } + } catch {} + + if (!faviconUrl) { + faviconUrl = `https://${domain}/favicon.ico`; + } + + try { + const iconRes = await fetch(faviconUrl, { + signal: AbortSignal.timeout(5000), + redirect: "follow", + }); + + if (!iconRes.ok) { + return new Response("Favicon not found", { status: 404 }); + } + + const contentType = iconRes.headers.get("content-type") ?? ""; + if (!contentType.includes("image") && !contentType.includes("icon")) { + return new Response("Not an image", { status: 404 }); + } + + const contentLength = parseInt(iconRes.headers.get("content-length") ?? "0", 10); + if (contentLength > MAX_FAVICON_SIZE) { + return new Response("Favicon too large", { status: 413 }); + } + + const body = await iconRes.arrayBuffer(); + if (body.byteLength > MAX_FAVICON_SIZE) { + return new Response("Favicon too large", { status: 413 }); + } + + return new Response(body, { + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=86400", + "Access-Control-Allow-Origin": "*", + }, + }); + } catch { + return new Response("Failed to fetch favicon", { status: 502 }); + } +} + export default { async fetch(request, env) { const url = new URL(request.url); @@ -496,6 +590,12 @@ export default { ); } + if (url.pathname === "/favicon") { + return handleFavicon(url.searchParams).catch( + (err) => new Response(`Failed to fetch favicon: ${err?.message ?? err}`, { status: 500 }), + ); + } + const ua = request.headers.get("user-agent") ?? ""; if (!isBot(ua)) { -- 2.51.2