Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions apps/server/src/projectFaviconRoute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
interface HttpResponse {
statusCode: number;
contentType: string | null;
location: string | null;
body: string;
}

Expand Down Expand Up @@ -61,11 +62,16 @@ async function withRouteServer(run: (baseUrl: string) => Promise<void>): Promise
}
}

async function request(baseUrl: string, pathname: string): Promise<HttpResponse> {
const response = await fetch(`${baseUrl}${pathname}`);
async function request(
baseUrl: string,
pathname: string,
init?: Pick<RequestInit, "redirect">,
): Promise<HttpResponse> {
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(),
};
}
Expand Down Expand Up @@ -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");
Expand All @@ -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"),
`<link rel="icon" href="${remoteIconUrl}">`,
);

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"),
'<link rel="icon" href="/brand/logo.svg?v=123#cache-bust">',
);
fs.writeFileSync(iconPath, "<svg>brand-query</svg>", "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("<svg>brand-query</svg>");
});
});

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");
Expand Down Expand Up @@ -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-");

Expand Down
72 changes: 66 additions & 6 deletions apps/server/src/projectFaviconRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@ import path from "node:path";
import { PROJECT_ICON_FALLBACK_CANDIDATES } from "@okcode/shared/projectIcons";

const FAVICON_MIME_TYPES: Record<string, string> = {
".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",
};

Expand All @@ -25,9 +30,10 @@ const ICON_SOURCE_FILES = [

// Matches <link ...> tags or object-like icon metadata where rel/href can appear in any order.
const LINK_ICON_HTML_RE =
/<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i;
/<link\b(?=[^>]*\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);
Expand All @@ -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)];
}

Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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));
});
};
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/components/ProjectIconEditorDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@ export function ProjectIconEditorDialog({
<DialogHeader>
<DialogTitle>Project icon</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>

Expand Down Expand Up @@ -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}
/>
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/lib/projectIcons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/routes/_chat.settings.index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1896,7 +1896,7 @@ function SettingsRouteView() {

<SettingsRow
title="Project icon"
description="Override the icon shown next to this project in the sidebar. Use a path relative to the project root, such as `public/icon.svg`."
description="Override the icon shown next to this project in the sidebar. Use a path relative to the project root or an absolute image URL, such as `public/icon.svg` or `https://example.com/icon.png`."
status={
selectedProject ? (
<span className="inline-flex items-center gap-2 text-[11px] text-muted-foreground">
Expand Down Expand Up @@ -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}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/routes/_chat.settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3499,7 +3499,7 @@ function SettingsRouteView() {

<SettingsRow
title="Project icon"
description="Override the icon shown next to this project in the sidebar. Use a path relative to the project root, such as `public/icon.svg`."
description="Override the icon shown next to this project in the sidebar. Use a path relative to the project root or an absolute image URL, such as `public/icon.svg` or `https://example.com/icon.png`."
status={
selectedProject ? (
<span className="inline-flex items-center gap-2 text-[11px] text-muted-foreground">
Expand Down Expand Up @@ -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}
Expand Down
38 changes: 38 additions & 0 deletions packages/shared/src/projectIcons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading