const DEFAULT_FAVICON_URL =
"https://cdn.madebydanny.uk/user-content/2026-04-20/cb70f504-07fa-4f02-942f-45f1ce80255a.svg";
const CACHE_TTL = 86400;
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
let domain = url.pathname.slice(1);
if (!domain) return new Response("Usage: /{domain}", { status: 400 });
domain = domain.replace(/^https?:\/\//, "").replace(/\/.*$/, "").toLowerCase().trim();
if (!isValidDomain(domain)) return new Response("Invalid domain", { status: 400 });
const larger = url.searchParams.get("larger") === "true";
const defaultAvatar = url.searchParams.get("default-avatar");
const throwOn404 = url.searchParams.get("throw-error-on-404") === "true";
const cacheKey = new Request(`https://favicon-cache/${domain}?larger=${larger}`, request);
const cache = caches.default;
const cached = await cache.match(cacheKey);
if (cached) return cached;
const result = await fetchFavicon(domain, larger);
if (!result) {
if (throwOn404) return new Response("No favicon found", { status: 404 });
if (defaultAvatar) return Response.redirect(defaultAvatar, 302);
return Response.redirect(DEFAULT_FAVICON_URL, 302);
}
const response = new Response(result.body, {
headers: {
"Content-Type": result.contentType,
"Cache-Control": `public, max-age=${CACHE_TTL}`,
"Access-Control-Allow-Origin": "*",
"X-Favicon-Source": result.source,
},
});
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
},
};
async function fetchFavicon(domain, larger) {
if (larger) {
const r = await tryFetch(`https://www.google.com/s2/favicons?domain=${domain}&sz=128`);
if (r) return { ...r, source: "google-s2-128" };
}
const htmlResult = await fetchFromHTML(domain, larger);
if (htmlResult) return htmlResult;
const ico = await tryFetch(`https://${domain}/favicon.ico`);
if (ico) return { ...ico, source: "favicon.ico" };
const small = await tryFetch(`https://www.google.com/s2/favicons?domain=${domain}&sz=32`);
if (small) return { ...small, source: "google-s2-32" };
return null;
}
async function fetchFromHTML(domain, larger) {
let html;
try {
const res = await fetchWithTimeout(`https://${domain}`, 5000);
if (!res?.ok) {
const www = await fetchWithTimeout(`https://www.${domain}`, 5000);
if (!www?.ok) return null;
html = await www.text();
} else {
html = await res.text();
}
} catch { return null; }
const icons = extractIcons(html, domain);
const best = pickBestIcon(icons, larger);
if (!best) return null;
const result = await tryFetch(best);
return result ? { ...result, source: best } : null;
}
function extractIcons(html, domain) {
const icons = [];
const patterns = [
/]+rel=["'][^"']*icon[^"']*["'][^>]*href=["']([^"']+)["'][^>]*>/gi,
/]+href=["']([^"']+)["'][^>]*rel=["'][^"']*icon[^"']*["'][^>]*>/gi,
/]+rel=["'][^"']*apple-touch-icon[^"']*["'][^>]*href=["']([^"']+)["'][^>]*>/gi,
];
for (const re of patterns) {
let m;
while ((m = re.exec(html)) !== null) {
const href = m[1].trim();
if (href && !href.startsWith("data:")) icons.push(resolveUrl(href, domain));
}
}
return [...new Set(icons)];
}
function pickBestIcon(icons, larger) {
if (!icons.length) return null;
if (larger) {
const svg = icons.find(u => u.match(/\.svg(\?|$)/i));
if (svg) return svg;
const big = icons.find(u => u.match(/\b(512|256|192|180|152|144|128|96)\b/));
if (big) return big;
}
return icons.find(u => u.match(/\.ico(\?|$)/i))
|| icons.find(u => u.match(/\.png(\?|$)/i))
|| icons[0];
}
function resolveUrl(href, domain) {
if (href.startsWith("http://") || href.startsWith("https://")) return href;
if (href.startsWith("//")) return `https:${href}`;
if (href.startsWith("/")) return `https://${domain}${href}`;
return `https://${domain}/${href}`;
}
async function tryFetch(url) {
try {
const res = await fetchWithTimeout(url, 5000);
if (!res?.ok) return null;
const ct = res.headers.get("content-type") || "";
if (!ct.includes("image") && !ct.includes("octet-stream") && !ct.includes("svg") && !ct.includes("icon")) return null;
const body = await res.arrayBuffer();
if (!body.byteLength) return null;
return { body, contentType: sanitizeContentType(ct, url) };
} catch { return null; }
}
async function fetchWithTimeout(url, ms) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), ms);
try {
return await fetch(url, {
signal: controller.signal,
headers: {
"User-Agent": "Mozilla/5.0 (compatible; FaviconBot/1.0; +https://favicon.blueat.net)",
Accept: "image/*,*/*;q=0.8",
},
redirect: "follow",
cf: { cacheTtl: CACHE_TTL, cacheEverything: true },
});
} finally { clearTimeout(id); }
}
function sanitizeContentType(ct, url) {
if (ct.includes("svg")) return "image/svg+xml";
if (ct.includes("png")) return "image/png";
if (ct.includes("jpeg") || ct.includes("jpg")) return "image/jpeg";
if (ct.includes("gif")) return "image/gif";
if (ct.includes("webp")) return "image/webp";
if (ct.includes("icon") || ct.includes("ico") || url?.endsWith(".ico")) return "image/x-icon";
return "image/x-icon";
}
function isValidDomain(domain) {
if (!domain.includes(".")) return false;
const BLOCKED_EXT = [".php",".xml",".json",".yml",".yaml",".txt",".html",".htm",".asp",".aspx",".env",".ini",".cfg",".conf",".bak",".sql",".sh",".py",".rb",".pl",".cgi",".exe",".dll",".log"];
if (BLOCKED_EXT.some(ext => domain.endsWith(ext))) return false;
const BLOCKED_NAMES = ["phpinfo","adminer","phpmyadmin","swagger","docker-compose","sitemap","robots","wp-login","xmlrpc","config","setup","install","readme","license","changelog"];
if (BLOCKED_NAMES.some(n => domain === n || domain.startsWith(n + "."))) return false;
const tld = domain.split(".").pop();
if (!/^[a-zA-Z]{2,63}$/.test(tld)) return false;
return /^[a-zA-Z0-9][a-zA-Z0-9\-\.]{0,251}[a-zA-Z0-9]$/.test(domain);
}