Skip to content

Commit 5606ced

Browse files
authored
Always return an image from the logo proxy (#72)
1 parent 6231e45 commit 5606ced

2 files changed

Lines changed: 59 additions & 4 deletions

File tree

‎src/lib/favicon.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,26 @@ export function faviconUrl(domain: string | null | undefined): string | null {
3939

4040
/** The registrable domain behind `faviconUrl`'s validation, for callers that
4141
* need the domain itself (the /logo proxy route) rather than a favicon URL. */
42+
/** The host to look a logo up by.
43+
*
44+
* `registrableDomain` answers "what domain was registered", which is not the
45+
* same question. `googleapis.com` is a public suffix in the PSL's private
46+
* section, so it HAS no registrable domain and was rejected outright — every
47+
* Google API service therefore resolved to no logo at all. A logo lookup only
48+
* needs a plausible public hostname, so fall back to the host itself.
49+
*
50+
* Still refuses what could never carry a logo: IP addresses, single-label
51+
* hosts, and anything that is not a hostname. */
52+
export function logoHost(input: string | null | undefined): string | null {
53+
const registrable = registrableDomain(input);
54+
if (registrable) return registrable;
55+
const host = normalizeHost(input);
56+
if (!host || !host.includes(".")) return null;
57+
const info = parse(host, { allowPrivateDomains: true });
58+
if (info.isIp || !(info.isIcann || info.isPrivate)) return null;
59+
return host;
60+
}
61+
4262
export function registrableDomain(domain: string | null | undefined): string | null {
4363
if (!domain) return null;
4464
const info = parse(domain, { allowPrivateDomains: true });

‎worker/entry.ts‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { apiJsonWithLiveIndex, domainsJsonWithLiveIndex, upsertLiveIndex } from
2121
import { setChat, setWebBackend, discoverWithProgress, preserveSlugs } from "./operations.ts";
2222
import { contextWeb, naiveWeb } from "../src/lib/contextdev.ts";
2323
import { DOMAIN_ALIASES, canonicalDomain } from "../src/lib/domain-aliases.ts";
24-
import { isJunkDomain, registrableDomain } from "../src/lib/favicon.ts";
24+
import { isJunkDomain, logoHost, registrableDomain } from "../src/lib/favicon.ts";
2525
import { isSdkNotCli } from "../src/lib/surface-classify.ts";
2626
import { renderOgPng, type OgFonts, type OgImageData } from "../src/lib/og.tsx";
2727
import type { Surface } from "../src/lib/surface-view.ts";
@@ -452,6 +452,29 @@ async function handleRequest(
452452
return healthz(env);
453453
}
454454

455+
// A neutral mark for a domain with no logo on file: its first letter on a
456+
// tinted square, deterministic per domain so the same service always looks
457+
// the same. Served with the same cache headers as a real logo.
458+
const letterLogo = (domain: string, size: number): Response => {
459+
const letter = (domain.replace(/^www\./, "")[0] ?? "?").toUpperCase();
460+
// A stable hue per domain — recognisable at a glance, never garish.
461+
let hash = 0;
462+
for (const char of domain) hash = (hash * 31 + char.charCodeAt(0)) % 360;
463+
const svg =
464+
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 64 64">` +
465+
`<rect width="64" height="64" rx="12" fill="hsl(${hash} 12% 88%)"/>` +
466+
`<text x="32" y="33" fill="hsl(${hash} 14% 34%)" font-family="ui-sans-serif,system-ui,sans-serif" ` +
467+
`font-size="34" font-weight="600" text-anchor="middle" dominant-baseline="central">${letter}</text>` +
468+
`</svg>`;
469+
return new Response(svg, {
470+
headers: {
471+
"content-type": "image/svg+xml; charset=utf-8",
472+
"access-control-allow-origin": "*",
473+
"cache-control": "public, max-age=86400",
474+
},
475+
});
476+
};
477+
455478
// Logo proxy — the single logo source for executor clients (and anything
456479
// else): /logo/{domain}?theme=light|dark&sz=64. Proxies context.dev Logo
457480
// Link, falling back to Google's favicon service when the client id is
@@ -461,8 +484,11 @@ async function handleRequest(
461484
// upstream's own 24h Cache-Control — no KV/R2.
462485
const logoMatch = /^\/logo\/([^/]+)\/?$/.exec(url.pathname);
463486
if (logoMatch) {
464-
const domain = registrableDomain(decodeURIComponent(logoMatch[1]).trim().toLowerCase());
465-
if (!domain) return json({ error: "not a public registrable domain" }, 400);
487+
const domain = logoHost(decodeURIComponent(logoMatch[1]).trim().toLowerCase());
488+
// Only a host that could never carry a logo is refused. Everything else
489+
// gets an image — see letterLogo below for why an error is the wrong
490+
// answer here.
491+
if (!domain) return json({ error: "not a usable logo host" }, 400);
466492
const theme = url.searchParams.get("theme");
467493
const size = Math.min(Math.max(Number(url.searchParams.get("sz")) || 64, 16), 256);
468494

@@ -498,7 +524,16 @@ async function handleRequest(
498524
`https://www.google.com/s2/favicons?domain=${domain}&sz=${size}`,
499525
).catch(() => null);
500526
}
501-
if (!upstream || !isImage(upstream)) return json({ error: "no logo found" }, 404);
527+
// A LOGO ENDPOINT MUST RETURN A LOGO. Clients put this URL in an <img>;
528+
// a JSON 404 renders as a broken image, and since the failure is silent
529+
// to onError-less callers it reads as "the icon system is broken" rather
530+
// than "this brand has no mark on file". A letter placeholder is a
531+
// truthful answer to "show me something for this domain".
532+
if (!upstream || !isImage(upstream)) {
533+
const placeholder = letterLogo(domain, size);
534+
ctx.waitUntil(cache.put(cacheKey, placeholder.clone()));
535+
return placeholder;
536+
}
502537

503538
const res = new Response(upstream.body, {
504539
headers: {

0 commit comments

Comments
 (0)