diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 7c29eeaa24d..772d5e8cc14 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -3,6 +3,7 @@ import { Image } from "expo-image"; import { useState } from "react"; import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; +import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; import { useThemeColor } from "../lib/useThemeColor"; import { useAssetUrl } from "../state/assets"; @@ -24,11 +25,12 @@ export function ProjectFavicon(props: { ? null : { _tag: "project-favicon", cwd: props.workspaceRoot }, ); + const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; return ( { const fallbackResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, }); + expect(fallbackResult.relativeUrl.endsWith(`/${PROJECT_FAVICON_FALLBACK_MARKER}`)).toBe(true); const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); const fallbackSeparatorIndex = fallbackSuffix.indexOf("/"); expect( @@ -235,7 +237,7 @@ describe("AssetAccess", () => { fallbackSuffix.slice(0, fallbackSeparatorIndex), fallbackSuffix.slice(fallbackSeparatorIndex + 1), ), - ).toEqual({ kind: "project-favicon-fallback" }); + ).toBeNull(); }).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 8d8ecbc2af3..b469e0e315b 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -19,6 +19,7 @@ import { WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, } from "@t3tools/shared/filePreview"; +import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -40,7 +41,6 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; export const ASSET_ROUTE_PREFIX = "/api/assets"; -export const FALLBACK_PROJECT_FAVICON_SVG = ``; const SIGNING_SECRET_NAME = "asset-access-signing-key"; const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; @@ -91,9 +91,7 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); -export type ResolvedAsset = - | { readonly kind: "file"; readonly path: string } - | { readonly kind: "project-favicon-fallback" }; +export type ResolvedAsset = { readonly kind: "file"; readonly path: string }; function decodeClaims(encodedPayload: string): AssetClaims | null { try { @@ -326,7 +324,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i relativePath, expiresAt, }; - fileName = relativePath ? path.basename(relativePath) : "favicon.svg"; + fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER; break; } } @@ -391,9 +389,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( } if (claims.kind === "project-favicon") { - if (claims.relativePath === null) { - return { kind: "project-favicon-fallback" } satisfies ResolvedAsset; - } + if (claims.relativePath === null) return null; const faviconPath = yield* resolveCanonicalWorkspaceFileForRequest({ workspaceRoot: claims.workspaceRoot, relativePath: claims.relativePath, diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index fc7a9ef13a2..b9bb40f372d 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -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"; @@ -196,17 +192,6 @@ export const assetRouteLayer = HttpRouter.add( if (!asset) { return HttpServerResponse.text("Not Found", { status: 404 }); } - if (asset.kind === "project-favicon-fallback") { - return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, { - status: 200, - contentType: "image/svg+xml", - headers: { - "Cache-Control": "private, max-age=3600", - "X-Content-Type-Options": "nosniff", - }, - }); - } - return yield* HttpServerResponse.file(asset.path, { status: 200, headers: { diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 481ddab05ab..f7603a36e9a 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,4 +1,5 @@ import type { EnvironmentId } from "@t3tools/contracts"; +import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; import { FolderIcon } from "lucide-react"; import { useState } from "react"; import { useAssetUrl } from "../assets/assetUrls"; @@ -15,7 +16,7 @@ export function ProjectFavicon(input: { cwd: input.cwd, }); - if (!src) { + if (!src || isProjectFaviconFallbackUrl(src)) { return ; } diff --git a/packages/shared/package.json b/packages/shared/package.json index e08844cbfae..bf68ce766ef 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./projectFavicon": { + "types": "./src/projectFavicon.ts", + "import": "./src/projectFavicon.ts" + }, "./model": { "types": "./src/model.ts", "import": "./src/model.ts" diff --git a/packages/shared/src/projectFavicon.test.ts b/packages/shared/src/projectFavicon.test.ts new file mode 100644 index 00000000000..0011b2fc7c9 --- /dev/null +++ b/packages/shared/src/projectFavicon.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isProjectFaviconFallbackUrl, PROJECT_FAVICON_FALLBACK_MARKER } from "./projectFavicon.ts"; + +describe("project favicon", () => { + it("identifies fallback asset URLs by their dedicated filename", () => { + expect( + isProjectFaviconFallbackUrl( + `https://environment.example/api/assets/signed-token/${PROJECT_FAVICON_FALLBACK_MARKER}`, + ), + ).toBe(true); + expect( + isProjectFaviconFallbackUrl(`/api/assets/signed-token/${PROJECT_FAVICON_FALLBACK_MARKER}`), + ).toBe(true); + }); + + it("does not mistake real favicons or query parameters for fallbacks", () => { + expect( + isProjectFaviconFallbackUrl("https://environment.example/api/assets/token/favicon.svg"), + ).toBe(false); + expect( + isProjectFaviconFallbackUrl( + `https://environment.example/api/assets/token/favicon.svg?name=${PROJECT_FAVICON_FALLBACK_MARKER}`, + ), + ).toBe(false); + expect(isProjectFaviconFallbackUrl(null)).toBe(false); + }); +}); diff --git a/packages/shared/src/projectFavicon.ts b/packages/shared/src/projectFavicon.ts new file mode 100644 index 00000000000..2e46429b6c1 --- /dev/null +++ b/packages/shared/src/projectFavicon.ts @@ -0,0 +1,12 @@ +export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing"; + +export function isProjectFaviconFallbackUrl(url: string | null | undefined): boolean { + if (!url) return false; + + try { + const pathname = new URL(url, "https://t3.invalid").pathname; + return pathname.slice(pathname.lastIndexOf("/") + 1) === PROJECT_FAVICON_FALLBACK_MARKER; + } catch { + return false; + } +}