the code used at ico.madebydanny.uk
worker.js edited
171 lines 6.5 kB view raw
1const DEFAULT_FAVICON_URL = 2 "https://cdn.madebydanny.uk/user-content/2026-03-05/843fc448-e9fb-40dd-bbb7-17db9f6471b5.png"; 3 4const CACHE_TTL = 86400; 5 6export default { 7 async fetch(request, env, ctx) { 8 const url = new URL(request.url); 9 let domain = url.pathname.slice(1); 10 11 if (!domain) return new Response("Usage: /{domain}", { status: 400 }); 12 13 domain = domain.replace(/^https?:\/\//, "").replace(/\/.*$/, "").toLowerCase().trim(); 14 15 if (!isValidDomain(domain)) return new Response("Invalid domain", { status: 400 }); 16 17 const larger = url.searchParams.get("larger") === "true"; 18 const defaultAvatar = url.searchParams.get("default-avatar"); 19 const throwOn404 = url.searchParams.get("throw-error-on-404") === "true"; 20 21 const cacheKey = new Request(`https://favicon-cache/${domain}?larger=${larger}`, request); 22 const cache = caches.default; 23 const cached = await cache.match(cacheKey); 24 if (cached) return cached; 25 26 const result = await fetchFavicon(domain, larger); 27 28 if (!result) { 29 if (throwOn404) return new Response("No favicon found", { status: 404 }); 30 if (defaultAvatar) return Response.redirect(defaultAvatar, 302); 31 return Response.redirect(DEFAULT_FAVICON_URL, 302); 32 } 33 34 const response = new Response(result.body, { 35 headers: { 36 "Content-Type": result.contentType, 37 "Cache-Control": `public, max-age=${CACHE_TTL}`, 38 "Access-Control-Allow-Origin": "*", 39 "X-Favicon-Source": result.source, 40 }, 41 }); 42 43 ctx.waitUntil(cache.put(cacheKey, response.clone())); 44 return response; 45 }, 46}; 47 48async function fetchFavicon(domain, larger) { 49 if (larger) { 50 const r = await tryFetch(`https://www.google.com/s2/favicons?domain=${domain}&sz=128`); 51 if (r) return { ...r, source: "google-s2-128" }; 52 } 53 54 const htmlResult = await fetchFromHTML(domain, larger); 55 if (htmlResult) return htmlResult; 56 57 const ico = await tryFetch(`https://${domain}/favicon.ico`); 58 if (ico) return { ...ico, source: "favicon.ico" }; 59 60 const small = await tryFetch(`https://www.google.com/s2/favicons?domain=${domain}&sz=32`); 61 if (small) return { ...small, source: "google-s2-32" }; 62 63 return null; 64} 65 66async function fetchFromHTML(domain, larger) { 67 let html; 68 try { 69 const res = await fetchWithTimeout(`https://${domain}`, 5000); 70 if (!res?.ok) { 71 const www = await fetchWithTimeout(`https://www.${domain}`, 5000); 72 if (!www?.ok) return null; 73 html = await www.text(); 74 } else { 75 html = await res.text(); 76 } 77 } catch { return null; } 78 79 const icons = extractIcons(html, domain); 80 const best = pickBestIcon(icons, larger); 81 if (!best) return null; 82 83 const result = await tryFetch(best); 84 return result ? { ...result, source: best } : null; 85} 86 87function extractIcons(html, domain) { 88 const icons = []; 89 const patterns = [ 90 /<link[^>]+rel=["'][^"']*icon[^"']*["'][^>]*href=["']([^"']+)["'][^>]*>/gi, 91 /<link[^>]+href=["']([^"']+)["'][^>]*rel=["'][^"']*icon[^"']*["'][^>]*>/gi, 92 /<link[^>]+rel=["'][^"']*apple-touch-icon[^"']*["'][^>]*href=["']([^"']+)["'][^>]*>/gi, 93 ]; 94 for (const re of patterns) { 95 let m; 96 while ((m = re.exec(html)) !== null) { 97 const href = m[1].trim(); 98 if (href && !href.startsWith("data:")) icons.push(resolveUrl(href, domain)); 99 } 100 } 101 return [...new Set(icons)]; 102} 103 104function pickBestIcon(icons, larger) { 105 if (!icons.length) return null; 106 if (larger) { 107 const svg = icons.find(u => u.match(/\.svg(\?|$)/i)); 108 if (svg) return svg; 109 const big = icons.find(u => u.match(/\b(512|256|192|180|152|144|128|96)\b/)); 110 if (big) return big; 111 } 112 return icons.find(u => u.match(/\.ico(\?|$)/i)) 113 || icons.find(u => u.match(/\.png(\?|$)/i)) 114 || icons[0]; 115} 116 117function resolveUrl(href, domain) { 118 if (href.startsWith("http://") || href.startsWith("https://")) return href; 119 if (href.startsWith("//")) return `https:${href}`; 120 if (href.startsWith("/")) return `https://${domain}${href}`; 121 return `https://${domain}/${href}`; 122} 123 124async function tryFetch(url) { 125 try { 126 const res = await fetchWithTimeout(url, 5000); 127 if (!res?.ok) return null; 128 const ct = res.headers.get("content-type") || ""; 129 if (!ct.includes("image") && !ct.includes("octet-stream") && !ct.includes("svg") && !ct.includes("icon")) return null; 130 const body = await res.arrayBuffer(); 131 if (!body.byteLength) return null; 132 return { body, contentType: sanitizeContentType(ct, url) }; 133 } catch { return null; } 134} 135 136async function fetchWithTimeout(url, ms) { 137 const controller = new AbortController(); 138 const id = setTimeout(() => controller.abort(), ms); 139 try { 140 return await fetch(url, { 141 signal: controller.signal, 142 headers: { 143 "User-Agent": "Mozilla/5.0 (compatible; FaviconBot/1.0; +https://favicon.blueat.net)", 144 Accept: "image/*,*/*;q=0.8", 145 }, 146 redirect: "follow", 147 cf: { cacheTtl: CACHE_TTL, cacheEverything: true }, 148 }); 149 } finally { clearTimeout(id); } 150} 151 152function sanitizeContentType(ct, url) { 153 if (ct.includes("svg")) return "image/svg+xml"; 154 if (ct.includes("png")) return "image/png"; 155 if (ct.includes("jpeg") || ct.includes("jpg")) return "image/jpeg"; 156 if (ct.includes("gif")) return "image/gif"; 157 if (ct.includes("webp")) return "image/webp"; 158 if (ct.includes("icon") || ct.includes("ico") || url?.endsWith(".ico")) return "image/x-icon"; 159 return "image/x-icon"; 160} 161 162function isValidDomain(domain) { 163 if (!domain.includes(".")) return false; 164 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"]; 165 if (BLOCKED_EXT.some(ext => domain.endsWith(ext))) return false; 166 const BLOCKED_NAMES = ["phpinfo","adminer","phpmyadmin","swagger","docker-compose","sitemap","robots","wp-login","xmlrpc","config","setup","install","readme","license","changelog"]; 167 if (BLOCKED_NAMES.some(n => domain === n || domain.startsWith(n + "."))) return false; 168 const tld = domain.split(".").pop(); 169 if (!/^[a-zA-Z]{2,63}$/.test(tld)) return false; 170 return /^[a-zA-Z0-9][a-zA-Z0-9\-\.]{0,251}[a-zA-Z0-9]$/.test(domain); 171}