diff --git a/apps/server/src/projectFaviconRoute.test.ts b/apps/server/src/projectFaviconRoute.test.ts index a7418c02a..cbc0227b5 100644 --- a/apps/server/src/projectFaviconRoute.test.ts +++ b/apps/server/src/projectFaviconRoute.test.ts @@ -9,6 +9,7 @@ import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; interface HttpResponse { statusCode: number; contentType: string | null; + location: string | null; body: string; } @@ -61,11 +62,16 @@ async function withRouteServer(run: (baseUrl: string) => Promise): Promise } } -async function request(baseUrl: string, pathname: string): Promise { - const response = await fetch(`${baseUrl}${pathname}`); +async function request( + baseUrl: string, + pathname: string, + init?: Pick, +): Promise { + const response = await fetch(`${baseUrl}${pathname}`, init); return { statusCode: response.status, contentType: response.headers.get("content-type"), + location: response.headers.get("location"), body: await response.text(), }; } @@ -113,6 +119,19 @@ describe("tryHandleProjectFaviconRequest", () => { }); }); + it("redirects to an explicit absolute icon override when provided", async () => { + const projectDir = makeTempDir("okcode-favicon-route-absolute-override-"); + const remoteIconUrl = "https://cdn.example.com/assets/project-icon.gif?size=64"; + + await withRouteServer(async (baseUrl) => { + const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}&icon=${encodeURIComponent(remoteIconUrl)}`; + const response = await request(baseUrl, pathname, { redirect: "manual" }); + expect(response.statusCode).toBe(302); + expect(response.location).toBe(remoteIconUrl); + expect(response.body).toBe(""); + }); + }); + it("resolves icon href from source files when no well-known favicon exists", async () => { const projectDir = makeTempDir("okcode-favicon-route-source-"); const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); @@ -132,6 +151,42 @@ describe("tryHandleProjectFaviconRequest", () => { }); }); + it("redirects to an absolute icon href discovered from source files", async () => { + const projectDir = makeTempDir("okcode-favicon-route-absolute-source-"); + const remoteIconUrl = "https://cdn.example.com/brand/logo.jpeg?version=42"; + fs.writeFileSync( + path.join(projectDir, "index.html"), + ``, + ); + + await withRouteServer(async (baseUrl) => { + const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; + const response = await request(baseUrl, pathname, { redirect: "manual" }); + expect(response.statusCode).toBe(302); + expect(response.location).toBe(remoteIconUrl); + expect(response.body).toBe(""); + }); + }); + + it("resolves a local icon href discovered from source files with query params", async () => { + const projectDir = makeTempDir("okcode-favicon-route-source-query-"); + const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); + fs.mkdirSync(path.dirname(iconPath), { recursive: true }); + fs.writeFileSync( + path.join(projectDir, "index.html"), + '', + ); + fs.writeFileSync(iconPath, "brand-query", "utf8"); + + await withRouteServer(async (baseUrl) => { + const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; + const response = await request(baseUrl, pathname); + expect(response.statusCode).toBe(200); + expect(response.contentType).toContain("image/svg+xml"); + expect(response.body).toBe("brand-query"); + }); + }); + it("resolves icon link when href appears before rel in HTML", async () => { const projectDir = makeTempDir("okcode-favicon-route-html-order-"); const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); @@ -172,6 +227,19 @@ describe("tryHandleProjectFaviconRequest", () => { }); }); + it("serves common image types such as jpeg with the correct content type", async () => { + const projectDir = makeTempDir("okcode-favicon-route-jpeg-"); + fs.writeFileSync(path.join(projectDir, "favicon.jpeg"), "jpeg-bits", "utf8"); + + await withRouteServer(async (baseUrl) => { + const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; + const response = await request(baseUrl, pathname); + expect(response.statusCode).toBe(200); + expect(response.contentType).toContain("image/jpeg"); + expect(response.body).toBe("jpeg-bits"); + }); + }); + it("serves a fallback favicon when no icon exists", async () => { const projectDir = makeTempDir("okcode-favicon-route-fallback-"); diff --git a/apps/server/src/projectFaviconRoute.ts b/apps/server/src/projectFaviconRoute.ts index b017cd519..887fe351e 100644 --- a/apps/server/src/projectFaviconRoute.ts +++ b/apps/server/src/projectFaviconRoute.ts @@ -4,9 +4,14 @@ import path from "node:path"; import { PROJECT_ICON_FALLBACK_CANDIDATES } from "@okcode/shared/projectIcons"; const FAVICON_MIME_TYPES: Record = { + ".avif": "image/avif", + ".bmp": "image/bmp", + ".gif": "image/gif", + ".jpeg": "image/jpeg", ".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml", + ".webp": "image/webp", ".ico": "image/x-icon", }; @@ -25,9 +30,10 @@ const ICON_SOURCE_FILES = [ // Matches tags or object-like icon metadata where rel/href can appear in any order. const LINK_ICON_HTML_RE = - /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; + /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"']+))[^>]*>/i; const LINK_ICON_OBJ_RE = - /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; + /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"']+))[^}]*/i; +const ICON_URL_SCHEME_RE = /^[a-z][a-z\d+.-]*:/i; function extractIconHref(source: string): string | null { const htmlMatch = source.match(LINK_ICON_HTML_RE); @@ -37,8 +43,36 @@ function extractIconHref(source: string): string | null { return null; } -function resolveIconHref(projectCwd: string, href: string): string[] { - const clean = href.replace(/^\//, ""); +function resolveExternalIconUrl(href: string): string | null { + const trimmed = href.trim(); + if (trimmed.length === 0) return null; + if (trimmed.startsWith("//")) return trimmed; + try { + const url = new URL(trimmed); + if (url.protocol === "http:" || url.protocol === "https:") { + return url.toString(); + } + } catch { + return null; + } + return null; +} + +function hasUnsupportedIconScheme(href: string): boolean { + const trimmed = href.trim(); + return trimmed.length > 0 && !trimmed.startsWith("//") && ICON_URL_SCHEME_RE.test(trimmed); +} + +function stripHrefSearchAndHash(href: string): string { + const queryIndex = href.indexOf("?"); + const hashIndex = href.indexOf("#"); + const cutIndex = + queryIndex === -1 ? hashIndex : hashIndex === -1 ? queryIndex : Math.min(queryIndex, hashIndex); + return (cutIndex === -1 ? href : href.slice(0, cutIndex)).trim(); +} + +function resolveLocalIconHref(projectCwd: string, href: string): string[] { + const clean = stripHrefSearchAndHash(href).replace(/^\//, ""); return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; } @@ -72,6 +106,14 @@ function serveFallbackFavicon(res: http.ServerResponse): void { res.end(FALLBACK_FAVICON_SVG); } +function redirectToFaviconUrl(iconUrl: string, res: http.ServerResponse): void { + res.writeHead(302, { + Location: iconUrl, + "Cache-Control": "public, max-age=3600", + }); + res.end(); +} + export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerResponse): boolean { if (url.pathname !== "/api/project-favicon") { return false; @@ -86,7 +128,16 @@ export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerRespons const overrideIconPath = url.searchParams.get("icon"); if (overrideIconPath) { - const candidates = resolveIconHref(projectCwd, overrideIconPath); + const externalIconUrl = resolveExternalIconUrl(overrideIconPath); + if (externalIconUrl) { + redirectToFaviconUrl(externalIconUrl, res); + return true; + } + if (hasUnsupportedIconScheme(overrideIconPath)) { + serveFallbackFavicon(res); + return true; + } + const candidates = resolveLocalIconHref(projectCwd, overrideIconPath); const serveOverrideOrFallback = (index: number): void => { if (index >= candidates.length) { serveFallbackFavicon(res); @@ -144,7 +195,16 @@ export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerRespons trySourceFiles(index + 1); return; } - const candidates = resolveIconHref(projectCwd, href); + const externalIconUrl = resolveExternalIconUrl(href); + if (externalIconUrl) { + redirectToFaviconUrl(externalIconUrl, res); + return; + } + if (hasUnsupportedIconScheme(href)) { + trySourceFiles(index + 1); + return; + } + const candidates = resolveLocalIconHref(projectCwd, href); tryResolvedPaths(candidates, 0, () => trySourceFiles(index + 1)); }); }; diff --git a/apps/web/src/components/ProjectIconEditorDialog.tsx b/apps/web/src/components/ProjectIconEditorDialog.tsx index 4132ea106..e243e37ca 100644 --- a/apps/web/src/components/ProjectIconEditorDialog.tsx +++ b/apps/web/src/components/ProjectIconEditorDialog.tsx @@ -104,8 +104,8 @@ export function ProjectIconEditorDialog({ Project icon - Set a path relative to the project root. Leave it blank to fall back to the detected - favicon or icon file. + Set a path relative to the project root or an absolute image URL. Leave it blank to fall + back to the detected favicon or icon file. @@ -142,7 +142,9 @@ export function ProjectIconEditorDialog({ draftWasTouchedRef.current = true; setDraft(event.target.value); }} - placeholder={suggestedIconPath ?? "public/favicon.svg"} + placeholder={ + suggestedIconPath ?? "public/favicon.svg or https://example.com/icon.png" + } autoComplete="off" spellCheck={false} /> diff --git a/apps/web/src/lib/projectIcons.test.ts b/apps/web/src/lib/projectIcons.test.ts index a16cd2768..27a8ebd1c 100644 --- a/apps/web/src/lib/projectIcons.test.ts +++ b/apps/web/src/lib/projectIcons.test.ts @@ -6,6 +6,9 @@ import { normalizeProjectIconPath, resolveSuggestedProjectIconPath } from "./pro describe("project icon helpers", () => { it("normalizes icon paths by trimming and treating blanks as null", () => { expect(normalizeProjectIconPath(" public/icon.svg ")).toBe("public/icon.svg"); + expect(normalizeProjectIconPath(" https://cdn.example.com/icon.gif ")).toBe( + "https://cdn.example.com/icon.gif", + ); expect(normalizeProjectIconPath(" ")).toBeNull(); expect(normalizeProjectIconPath(null)).toBeNull(); }); diff --git a/apps/web/src/routes/_chat.settings.index.tsx b/apps/web/src/routes/_chat.settings.index.tsx index d778a26a1..326af9299 100644 --- a/apps/web/src/routes/_chat.settings.index.tsx +++ b/apps/web/src/routes/_chat.settings.index.tsx @@ -1896,7 +1896,7 @@ function SettingsRouteView() { @@ -1930,7 +1930,7 @@ function SettingsRouteView() { setProjectIconDraft(selectedProject?.iconPath ?? ""); } }} - placeholder="public/icon.svg" + placeholder="public/icon.svg or https://example.com/icon.png" className="w-full sm:w-64" aria-label="Project icon path" disabled={!selectedProject} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 5cc880145..50266e6a1 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -3499,7 +3499,7 @@ function SettingsRouteView() { @@ -3533,7 +3533,7 @@ function SettingsRouteView() { setProjectIconDraft(selectedProject?.iconPath ?? ""); } }} - placeholder="public/icon.svg" + placeholder="public/icon.svg or https://example.com/icon.png" className="w-full sm:w-64" aria-label="Project icon path" disabled={!selectedProject} diff --git a/packages/shared/src/projectIcons.ts b/packages/shared/src/projectIcons.ts index 8f8219a66..ffe504e75 100644 --- a/packages/shared/src/projectIcons.ts +++ b/packages/shared/src/projectIcons.ts @@ -2,23 +2,61 @@ export const PROJECT_ICON_FALLBACK_CANDIDATES = [ "favicon.svg", "favicon.ico", "favicon.png", + "favicon.jpg", + "favicon.jpeg", + "favicon.gif", + "favicon.webp", "public/favicon.svg", "public/favicon.ico", "public/favicon.png", + "public/favicon.jpg", + "public/favicon.jpeg", + "public/favicon.gif", + "public/favicon.webp", "app/favicon.ico", "app/favicon.png", + "app/favicon.jpg", + "app/favicon.jpeg", + "app/favicon.gif", + "app/favicon.webp", "app/icon.svg", "app/icon.png", + "app/icon.jpg", + "app/icon.jpeg", + "app/icon.gif", + "app/icon.webp", "app/icon.ico", "src/favicon.ico", "src/favicon.svg", + "src/favicon.png", + "src/favicon.jpg", + "src/favicon.jpeg", + "src/favicon.gif", + "src/favicon.webp", "src/app/favicon.ico", + "src/app/favicon.png", + "src/app/favicon.jpg", + "src/app/favicon.jpeg", + "src/app/favicon.gif", + "src/app/favicon.webp", "src/app/icon.svg", "src/app/icon.png", + "src/app/icon.jpg", + "src/app/icon.jpeg", + "src/app/icon.gif", + "src/app/icon.webp", "assets/icon.svg", "assets/icon.png", + "assets/icon.jpg", + "assets/icon.jpeg", + "assets/icon.gif", + "assets/icon.webp", "assets/logo.svg", "assets/logo.png", + "assets/logo.jpg", + "assets/logo.jpeg", + "assets/logo.gif", + "assets/logo.webp", ] as const; export const PROJECT_ICON_DISCOVERY_QUERIES = ["favicon", "icon", "logo"] as const;