Skip to content

Commit e9fcb60

Browse files
authored
Add /logo/{domain} proxy backed by context.dev Logo Link (#7)
* Add /logo/{domain} proxy backed by context.dev Logo Link Proxies logos.context.dev (when CONTEXT_DEV_LOGO_CLIENT_ID is set) with a fallback to Google's favicon service, cached at the edge for 24h to match the upstream's own Cache-Control. Executor clients will point here for all integration favicons. * Downscale Logo Link originals via Cloudflare image resizing
1 parent cf8371e commit e9fcb60

2 files changed

Lines changed: 73 additions & 1 deletion

File tree

‎src/lib/favicon.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,15 @@ import { parse } from "tldts";
1010
* trigger the browser's Local Network Access permission prompt.
1111
*/
1212
export function faviconUrl(domain: string | null | undefined): string | null {
13+
const registrable = registrableDomain(domain);
14+
return registrable ? `https://${registrable}/favicon.ico` : null;
15+
}
16+
17+
/** The registrable domain behind `faviconUrl`'s validation, for callers that
18+
* need the domain itself (the /logo proxy route) rather than a favicon URL. */
19+
export function registrableDomain(domain: string | null | undefined): string | null {
1320
if (!domain) return null;
1421
const info = parse(domain, { allowPrivateDomains: true });
1522
if (info.isIp || !info.domain || !(info.isIcann || info.isPrivate)) return null;
16-
return `https://${info.domain}/favicon.ico`;
23+
return info.domain;
1724
}

‎worker/index.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { apiHandler } from "./api.ts";
22
import { setChat, setWebBackend, discoverWithProgress } from "./operations.ts";
33
import { contextWeb, naiveWeb } from "../src/lib/contextdev.ts";
4+
import { registrableDomain } from "../src/lib/favicon.ts";
45
import { renderSurfacePage, slugifySurface, type Surface } from "./surface-page.ts";
56

67
export { McpDurableObject } from "./mcp-do.ts";
@@ -36,6 +37,11 @@ export interface Env {
3637
/** OpenAI API key (secret). Powers the discover extraction model, routed
3738
* through Cloudflare AI Gateway so spend limits + logging apply. */
3839
OPENAI_API_KEY?: string;
40+
/** context.dev Logo Link public client id (brandLL_…) for the /logo proxy.
41+
* Frontend-safe by design (access is referrer-restricted upstream), but kept
42+
* in env so rotating it is a secret update, not a deploy. Optional: when
43+
* missing, /logo serves the Google favicon fallback only. */
44+
CONTEXT_DEV_LOGO_CLIENT_ID?: string;
3945
}
4046

4147
// Bump when detect/discover output shape or logic changes, so the edge Cache API
@@ -197,6 +203,65 @@ export default {
197203
);
198204
}
199205

206+
// Logo proxy — the single logo source for executor clients (and anything
207+
// else): /logo/{domain}?theme=light|dark&sz=64. Proxies context.dev Logo
208+
// Link, falling back to Google's favicon service when the client id is
209+
// missing or the upstream errors. Logo Link's terms forbid persisting
210+
// images in our own storage but explicitly allow header-driven browser/CDN
211+
// caching, so this uses the edge Cache API with a TTL matching the
212+
// upstream's own 24h Cache-Control — no KV/R2.
213+
const logoMatch = /^\/logo\/([^/]+)\/?$/.exec(url.pathname);
214+
if (logoMatch) {
215+
const domain = registrableDomain(decodeURIComponent(logoMatch[1]).trim().toLowerCase());
216+
if (!domain) return json({ error: "not a public registrable domain" }, 400);
217+
const theme = url.searchParams.get("theme");
218+
const size = Math.min(Math.max(Number(url.searchParams.get("sz")) || 64, 16), 256);
219+
220+
// Normalized cache key: validated domain + the params that affect bytes.
221+
const cache = (caches as unknown as { default: Cache }).default;
222+
const keyUrl = new URL(`${url.origin}/logo/${domain}`);
223+
if (theme === "light" || theme === "dark") keyUrl.searchParams.set("theme", theme);
224+
keyUrl.searchParams.set("sz", String(size));
225+
keyUrl.searchParams.set("__cv", CACHE_VERSION);
226+
const cacheKey = new Request(keyUrl.toString(), { method: "GET" });
227+
const cached = await cache.match(cacheKey);
228+
if (cached) return cached;
229+
230+
const isImage = (r: Response) =>
231+
r.ok && (r.headers.get("content-type") ?? "").toLowerCase().startsWith("image/");
232+
let upstream: Response | null = null;
233+
if (env.CONTEXT_DEV_LOGO_CLIENT_ID) {
234+
const logoLink = new URL("https://logos.context.dev/");
235+
logoLink.searchParams.set("publicClientId", env.CONTEXT_DEV_LOGO_CLIENT_ID);
236+
logoLink.searchParams.set("domain", domain);
237+
if (theme === "light" || theme === "dark") logoLink.searchParams.set("theme", theme);
238+
upstream = await fetch(logoLink, {
239+
// Access to the client id is referrer-restricted; identify as the site.
240+
headers: { referer: "https://integrations.sh/" },
241+
// Logo Link serves originals (2048px PNGs for some brands) — have
242+
// Cloudflare downscale to the requested size. Ignored (originals pass
243+
// through) when the zone doesn't have Image Resizing.
244+
cf: { image: { width: size, height: size, fit: "scale-down" } },
245+
} as RequestInit).catch(() => null);
246+
}
247+
if (!upstream || !isImage(upstream)) {
248+
upstream = await fetch(
249+
`https://www.google.com/s2/favicons?domain=${domain}&sz=${size}`,
250+
).catch(() => null);
251+
}
252+
if (!upstream || !isImage(upstream)) return json({ error: "no logo found" }, 404);
253+
254+
const res = new Response(upstream.body, {
255+
headers: {
256+
"content-type": upstream.headers.get("content-type") ?? "image/png",
257+
"access-control-allow-origin": "*",
258+
"cache-control": "public, max-age=86400",
259+
},
260+
});
261+
ctx.waitUntil(cache.put(cacheKey, res.clone()));
262+
return res;
263+
}
264+
200265
// Stored discovery — the durable KV result for a domain, written on the
201266
// last completion. The page reads this at render and merges it with the
202267
// static catalog. 404 when nothing has been discovered yet.

0 commit comments

Comments
 (0)