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)) {