Skip to content
Open
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
122 changes: 120 additions & 2 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,24 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ServerConfig from "../config.ts";
import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";
import {
ASSET_ROUTE_PREFIX,
FALLBACK_PROJECT_FAVICON_SVG,
issueAssetUrl,
resolveAsset,
} from "./AssetAccess.ts";

type FallbackTag =
| "project-favicon"
| "project-favicon-react"
| "project-favicon-nextjs"
| "project-favicon-vue"
| "project-favicon-svelte"
| "project-favicon-angular"
| "project-favicon-android"
| "project-favicon-youtube"
| "project-favicon-tiktok"
| "project-favicon-instagram";

const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
prefix: "t3-asset-access-test-",
Expand Down Expand Up @@ -235,7 +252,108 @@ describe("AssetAccess", () => {
fallbackSuffix.slice(0, fallbackSeparatorIndex),
fallbackSuffix.slice(fallbackSeparatorIndex + 1),
),
).toEqual({ kind: "project-favicon-fallback" });
).toEqual({ kind: "project-favicon-fallback", svg: FALLBACK_PROJECT_FAVICON_SVG });
}).pipe(Effect.provide(testLayer)),
);

it.effect("serves stack-specific fallback icons for projects without a favicon", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;

const resolveFallbackTag = Effect.fnUntraced(function* (root: string) {
const result = yield* issueAssetUrl({
resource: { _tag: "project-favicon", cwd: root },
});
const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const separatorIndex = suffix.indexOf("/");
const resolved = yield* resolveAsset(
suffix.slice(0, separatorIndex),
suffix.slice(separatorIndex + 1),
);
expect(resolved?.kind).toBe("project-favicon-fallback");
if (resolved?.kind !== "project-favicon-fallback") return "project-favicon" as FallbackTag;
const match = /data-fallback="([^"]+)"/.exec(resolved.svg);
return (match?.[1] ?? "project-favicon") as FallbackTag;
});

const makePkgRoot = (prefix: string, deps: Record<string, string>) =>
Effect.gen(function* () {
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix });
const entries = Object.entries(deps)
.map(([k, v]) => `"${k}":"${v}"`)
.join(",");
yield* fileSystem.writeFileString(
path.join(root, "package.json"),
`{"dependencies":{${entries}}}`,
);
return root;
});

// Next.js takes priority over plain React
expect(
yield* resolveFallbackTag(
yield* makePkgRoot("t3-asset-favicon-next-", { next: "^15.0.0", react: "^19.0.0" }),
),
).toBe("project-favicon-nextjs");

// Plain React
expect(
yield* resolveFallbackTag(
yield* makePkgRoot("t3-asset-favicon-react-", { react: "^19.0.0" }),
),
).toBe("project-favicon-react");

// Angular
expect(
yield* resolveFallbackTag(
yield* makePkgRoot("t3-asset-favicon-ng-", { "@angular/core": "^18.0.0" }),
),
).toBe("project-favicon-angular");

// Svelte
expect(
yield* resolveFallbackTag(
yield* makePkgRoot("t3-asset-favicon-svelte-", { svelte: "^5.0.0" }),
),
).toBe("project-favicon-svelte");

// Vue
expect(
yield* resolveFallbackTag(yield* makePkgRoot("t3-asset-favicon-vue-", { vue: "^3.0.0" })),
).toBe("project-favicon-vue");

// Android (Gradle marker file)
const androidRoot = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-favicon-android-",
});
yield* fileSystem.writeFileString(path.join(androidRoot, "build.gradle"), "");
expect(yield* resolveFallbackTag(androidRoot)).toBe("project-favicon-android");

// YouTube (project name matching)
const ytRoot = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-favicon-youtube-companion-",
});
expect(yield* resolveFallbackTag(ytRoot)).toBe("project-favicon-youtube");

// TikTok (project name matching)
const tiktokRoot = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-favicon-tiktok-bot-",
});
expect(yield* resolveFallbackTag(tiktokRoot)).toBe("project-favicon-tiktok");

// Instagram (project name matching)
const igRoot = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-favicon-insta-poster-",
});
expect(yield* resolveFallbackTag(igRoot)).toBe("project-favicon-instagram");

// Generic fallback for unrecognized stacks
expect(
yield* resolveFallbackTag(
yield* makePkgRoot("t3-asset-favicon-plain-", { express: "^5.0.0" }),
),
).toBe("project-favicon");
}).pipe(Effect.provide(testLayer)),
);

Expand Down
82 changes: 80 additions & 2 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,26 @@ import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";

export const ASSET_ROUTE_PREFIX = "/api/assets";
export const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`;
const NEXTJS_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" data-fallback="project-favicon-nextjs"><circle cx="64" cy="64" r="64" fill="#000"/><path d="M106.5 110.6 49.2 38H38v52h9.5V49.9l52.8 66.2c2.2-1.7 4.2-3.5 6.2-5.5Z" fill="#fff"/><path d="M81 38h9v52h-9z" fill="url(#ng)"/><defs><linearGradient id="ng" x1="85.5" y1="38" x2="85.5" y2="90" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs></svg>`;
const REACT_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-11.5 -10.23174 23 20.46348" data-fallback="project-favicon-react"><circle cx="0" cy="0" r="2.05" fill="#61dafb"/><g fill="none" stroke="#61dafb" stroke-width="1"><ellipse rx="11" ry="4.2"/><ellipse rx="11" ry="4.2" transform="rotate(60)"/><ellipse rx="11" ry="4.2" transform="rotate(120)"/></g></svg>`;
const VUE_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" data-fallback="project-favicon-vue"><path fill="#41b883" d="M78.8 10 64 35.4 49.2 10H0l64 108L128 10z"/><path fill="#35495e" d="M78.8 10 64 35.4 49.2 10H25.6L64 76l38.4-66z"/></svg>`;
const SVELTE_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" data-fallback="project-favicon-svelte"><path d="M110.3 26.3C99 10.1 77 5.5 60.9 15.6L27.5 36.4C19.8 41 14.4 48.5 12.8 57.1c-1.4 7.2-.2 14.7 3.4 21.1a31.5 31.5 0 0 0-4.7 11.9c-1.6 8.9.6 18 6.1 25.1C28.9 131.4 51 136 67.1 125.9l33.4-20.8c7.7-4.6 13.1-12.1 14.7-20.7 1.4-7.2.2-14.7-3.4-21.1a31.5 31.5 0 0 0 4.7-11.9c1.6-8.9-.6-18-6.2-25.1z" fill="#ff3e00"/><path d="M59.4 109.8c-9.6 2.6-19.9-1.3-25.2-9.6a23.5 23.5 0 0 1-3.3-14.2c.2-1.3.5-2.5.9-3.7l.7-2.1 1.8 1.4a47 47 0 0 0 14.3 7.2l1.4.4-.1 1.4a7.2 7.2 0 0 0 1.3 4.6c1.8 2.6 5 3.8 8 3a7.4 7.4 0 0 0 2-.9L93.8 75c1.9-1.1 3.2-3 3.4-5.1.3-2.2-.6-4.4-2.3-5.8a7.2 7.2 0 0 0-7.9-.8l-.1.1-13.4 8.3a24.6 24.6 0 0 1-6.7 3 23.8 23.8 0 0 1-25.2-9.6 23.5 23.5 0 0 1-3.3-14.2 22 22 0 0 1 9.9-16l33.4-20.8a24.6 24.6 0 0 1 6.7-3c9.6-2.6 19.9 1.3 25.2 9.6a23.5 23.5 0 0 1 3.3 14.2c-.2 1.3-.5 2.5-.9 3.7l-.7 2.1-1.8-1.4a47 47 0 0 0-14.3-7.2l-1.4-.4.1-1.4a7.2 7.2 0 0 0-1.3-4.6c-1.8-2.6-5-3.8-8-3a7.4 7.4 0 0 0-2 .9L34.2 53c-1.9 1.1-3.2 3-3.4 5.1-.3 2.2.6 4.4 2.3 5.8a7.2 7.2 0 0 0 7.9.8l.1-.1 13.4-8.3a24.6 24.6 0 0 1 6.7-3 23.8 23.8 0 0 1 25.2 9.6 23.5 23.5 0 0 1 3.3 14.2 22 22 0 0 1-9.9 16L46.4 113.9a24.6 24.6 0 0 1-7 3z" fill="#fff"/></svg>`;
const ANGULAR_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" data-fallback="project-favicon-angular"><path fill="#dd0031" d="M64 4 8.6 23.8l8.3 69.9L64 124l47.1-30.3 8.3-69.9z"/><path fill="#c3002f" d="M64 4v120l47.1-30.3 8.3-69.9z"/><path fill="#fff" d="m64 21.1-28 63.9h10.4l5.6-14.2h24l5.6 14.2H92L64 21.1zm0 20.1 9.4 21.6H54.6L64 41.2z"/></svg>`;
const ANDROID_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" data-fallback="project-favicon-android"><path stroke="#3ddc84" stroke-width="6" stroke-linecap="round" fill="none" d="M46 32L36 20M82 32L92 20"/><path fill="#3ddc84" d="M26 64A38 38 0 0 1 102 64L102 80Q102 86 96 86L32 86Q26 86 26 80Z"/><circle cx="50" cy="52" r="5.5" fill="#fff"/><circle cx="78" cy="52" r="5.5" fill="#fff"/></svg>`;
const YOUTUBE_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" data-fallback="project-favicon-youtube"><rect x="10" y="28" width="108" height="72" rx="18" fill="#ff0033"/><path d="M55 48v32l29-16-29-16Z" fill="white"/></svg>`;
const TIKTOK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" data-fallback="project-favicon-tiktok"><rect width="128" height="128" rx="24" fill="#010101"/><path d="M90 30c2 13 10 19 22 20v15c-8 0-15-2-22-7v32c0 19-14 34-33 34-8 0-16-3-22-8a33 33 0 0 1 22-58v16c-10 2-17 11-15 21 2 9 11 15 20 13 9-2 16-10 16-19V30h12Z" fill="#fff"/><path d="M88 28c2 13 10 19 22 20v15c-8 0-15-2-22-7v32c0 19-14 34-33 34-8 0-16-3-22-8a33 33 0 0 1 22-58v16c-10 2-17 11-15 21 2 9 11 15 20 13 9-2 16-10 16-19V28h12Z" fill="#69c9d0"/></svg>`;
const INSTAGRAM_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" data-fallback="project-favicon-instagram"><defs><radialGradient id="ig" cx="30%" cy="107%" r="150%"><stop offset="0%" stop-color="#fdf497"/><stop offset="10%" stop-color="#fdf497"/><stop offset="50%" stop-color="#fd5949"/><stop offset="68%" stop-color="#d6249f"/><stop offset="100%" stop-color="#285aeb"/></radialGradient></defs><rect width="128" height="128" rx="28" fill="url(#ig)"/><rect x="22" y="22" width="84" height="84" rx="22" fill="none" stroke="#fff" stroke-width="8"/><circle cx="64" cy="64" r="22" fill="none" stroke="#fff" stroke-width="8"/><circle cx="91" cy="37" r="6" fill="#fff"/></svg>`;

const ANDROID_PROJECT_MARKER_FILES = [
"settings.gradle",
"settings.gradle.kts",
"build.gradle",
"build.gradle.kts",
];

const YOUTUBE_PROJECT_NAME_PATTERNS = /\byt\b|youtube/i;
const TIKTOK_PROJECT_NAME_PATTERNS = /tiktok|tik[_-]?tok/i;
const INSTAGRAM_PROJECT_NAME_PATTERNS = /instagram|\binsta\b/i;

const SIGNING_SECRET_NAME = "asset-access-signing-key";
const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
Expand Down Expand Up @@ -93,7 +113,62 @@ const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson);

export type ResolvedAsset =
| { readonly kind: "file"; readonly path: string }
| { readonly kind: "project-favicon-fallback" };
| { readonly kind: "project-favicon-fallback"; readonly svg: string };

const PackageJsonDependenciesJson = Schema.fromJsonString(
Schema.Struct({
dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
devDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}),
);
const decodePackageJsonDependencies = Schema.decodeUnknownOption(PackageJsonDependenciesJson);

function parsePackageJsonDependencyNames(contents: string): ReadonlySet<string> {
const parsed = Option.getOrNull(decodePackageJsonDependencies(contents));
return new Set([
...Object.keys(parsed?.dependencies ?? {}),
...Object.keys(parsed?.devDependencies ?? {}),
]);
}

// Detection failures degrade to the generic fallback icon rather than failing
// the asset request.
const resolveProjectFaviconFallbackSvg = Effect.fn("AssetAccess.resolveProjectFaviconFallbackSvg")(
function* (workspaceRoot: string) {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;

const projectName = path.basename(workspaceRoot);

if (YOUTUBE_PROJECT_NAME_PATTERNS.test(projectName)) return YOUTUBE_PROJECT_FAVICON_SVG;
if (TIKTOK_PROJECT_NAME_PATTERNS.test(projectName)) return TIKTOK_PROJECT_FAVICON_SVG;
if (INSTAGRAM_PROJECT_NAME_PATTERNS.test(projectName)) return INSTAGRAM_PROJECT_FAVICON_SVG;

const packageJsonContents = yield* fileSystem
.readFileString(path.join(workspaceRoot, "package.json"))
.pipe(Effect.orElseSucceed(() => null));
if (packageJsonContents !== null) {
const dependencyNames = parsePackageJsonDependencyNames(packageJsonContents);
if (dependencyNames.has("next")) return NEXTJS_PROJECT_FAVICON_SVG;
if (dependencyNames.has("@angular/core")) return ANGULAR_PROJECT_FAVICON_SVG;
if (dependencyNames.has("svelte") || dependencyNames.has("@sveltejs/kit"))
return SVELTE_PROJECT_FAVICON_SVG;
if (dependencyNames.has("vue") || dependencyNames.has("nuxt")) return VUE_PROJECT_FAVICON_SVG;
if (dependencyNames.has("expo") || dependencyNames.has("react-native"))
return ANDROID_PROJECT_FAVICON_SVG;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Native shows Android icon

Medium Severity

Projects with expo or react-native dependencies are incorrectly assigned the Android favicon. The current dependency check order returns the Android icon before the react check, which was intended for React Native projects.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5e7467f. Configure here.

if (dependencyNames.has("react")) return REACT_PROJECT_FAVICON_SVG;
}

for (const markerFile of ANDROID_PROJECT_MARKER_FILES) {
const markerExists = yield* fileSystem
.exists(path.join(workspaceRoot, markerFile))
.pipe(Effect.orElseSucceed(() => false));
if (markerExists) return ANDROID_PROJECT_FAVICON_SVG;
}

return FALLBACK_PROJECT_FAVICON_SVG;
},
);

function decodeClaims(encodedPayload: string): AssetClaims | null {
try {
Expand Down Expand Up @@ -392,7 +467,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (

if (claims.kind === "project-favicon") {
if (claims.relativePath === null) {
return { kind: "project-favicon-fallback" } satisfies ResolvedAsset;
return {
kind: "project-favicon-fallback",
svg: yield* resolveProjectFaviconFallbackSvg(claims.workspaceRoot),
} satisfies ResolvedAsset;
}
const faviconPath = yield* resolveCanonicalWorkspaceFileForRequest({
workspaceRoot: claims.workspaceRoot,
Expand Down
8 changes: 2 additions & 6 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
import { OtlpTracer } from "effect/unstable/observability";

import * as ServerConfig from "./config.ts";
import {
ASSET_ROUTE_PREFIX,
FALLBACK_PROJECT_FAVICON_SVG,
resolveAsset,
} from "./assets/AssetAccess.ts";
import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts";
import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";
Expand Down Expand Up @@ -197,7 +193,7 @@ export const assetRouteLayer = HttpRouter.add(
return HttpServerResponse.text("Not Found", { status: 404 });
}
if (asset.kind === "project-favicon-fallback") {
return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cached fallback hides stack changes

Medium Severity

The fallback favicon SVG is now dynamically generated based on workspace configuration, but the Cache-Control: max-age=3600 header means browsers can serve an outdated icon for up to an hour after configuration changes (e.g., package.json, Gradle files), as the asset URL doesn't change.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5e7467f. Configure here.

return HttpServerResponse.text(asset.svg, {
status: 200,
contentType: "image/svg+xml",
headers: {
Expand Down
Loading