| author | |
| committer | |
| log | 8de254a685f4356c719e744926397362c800f426 |
| tree | 49ef16c05d5ca7d56c6b2ead5c2a34b73fadba7d |
| parent | c1ec1915fc16f3dc1ec5d47d9a97f6210a574526 |
| signature | Signed by PGP key B5690EEEBB952194 |
8 files changed, 57 insertions(+), 27 deletions(-)
apps/mobile/src/components/ProjectFavicon.tsx+3-1| ... | ... | @@ -3,6 +3,7 @@ import { Image } from "expo-image"; |
| 3 | 3 | import { useState } from "react"; |
| 4 | 4 | import { View } from "react-native"; |
| 5 | 5 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 6 | import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; | |
| 6 | 7 | import { useThemeColor } from "../lib/useThemeColor"; |
| 7 | 8 | import { useAssetUrl } from "../state/assets"; |
| 8 | 9 | |
| ... | ... | @@ -24,11 +25,12 @@ export function ProjectFavicon(props: { |
| 24 | 25 | ? null |
| 25 | 26 | : { _tag: "project-favicon", cwd: props.workspaceRoot }, |
| 26 | 27 | ); |
| 28 | const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; | |
| 27 | 29 | |
| 28 | 30 | return ( |
| 29 | 31 | <ProjectFaviconImage |
| 30 | 32 | key={faviconUrl} |
| 31 | faviconUrl={faviconUrl} | |
| 33 | faviconUrl={renderableFaviconUrl} | |
| 32 | 34 | open={props.open} |
| 33 | 35 | projectTitle={props.projectTitle} |
| 34 | 36 | size={size} |
apps/server/src/assets/AssetAccess.test.ts+3-1| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { ThreadId } from "@t3tools/contracts"; |
| 3 | import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; | |
| 3 | 4 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | 5 | import * as Effect from "effect/Effect"; |
| 5 | 6 | import * as FileSystem from "effect/FileSystem"; |
| ... | ... | @@ -228,6 +229,7 @@ describe("AssetAccess", () => { |
| 228 | 229 | const fallbackResult = yield* issueAssetUrl({ |
| 229 | 230 | resource: { _tag: "project-favicon", cwd: root }, |
| 230 | 231 | }); |
| 232 | expect(fallbackResult.relativeUrl.endsWith(`/${PROJECT_FAVICON_FALLBACK_MARKER}`)).toBe(true); | |
| 231 | 233 | const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); |
| 232 | 234 | const fallbackSeparatorIndex = fallbackSuffix.indexOf("/"); |
| 233 | 235 | expect( |
| ... | ... | @@ -235,7 +237,7 @@ describe("AssetAccess", () => { |
| 235 | 237 | fallbackSuffix.slice(0, fallbackSeparatorIndex), |
| 236 | 238 | fallbackSuffix.slice(fallbackSeparatorIndex + 1), |
| 237 | 239 | ), |
| 238 | ).toEqual({ kind: "project-favicon-fallback" }); | |
| 240 | ).toBeNull(); | |
| 239 | 241 | }).pipe(Effect.provide(testLayer)), |
| 240 | 242 | ); |
| 241 | 243 |
apps/server/src/assets/AssetAccess.ts+4-8| ... | ... | @@ -19,6 +19,7 @@ import { |
| 19 | 19 | WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, |
| 20 | 20 | WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, |
| 21 | 21 | } from "@t3tools/shared/filePreview"; |
| 22 | import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; | |
| 22 | 23 | import * as Clock from "effect/Clock"; |
| 23 | 24 | import * as Effect from "effect/Effect"; |
| 24 | 25 | import * as FileSystem from "effect/FileSystem"; |
| ... | ... | @@ -40,7 +41,6 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; |
| 40 | 41 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 41 | 42 | |
| 42 | 43 | export const ASSET_ROUTE_PREFIX = "/api/assets"; |
| 43 | 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>`; | |
| 44 | 44 | |
| 45 | 45 | const SIGNING_SECRET_NAME = "asset-access-signing-key"; |
| 46 | 46 | const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; |
| ... | ... | @@ -91,9 +91,7 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); |
| 91 | 91 | const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); |
| 92 | 92 | const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); |
| 93 | 93 | |
| 94 | export type ResolvedAsset = | |
| 95 | | { readonly kind: "file"; readonly path: string } | |
| 96 | | { readonly kind: "project-favicon-fallback" }; | |
| 94 | export type ResolvedAsset = { readonly kind: "file"; readonly path: string }; | |
| 97 | 95 | |
| 98 | 96 | function decodeClaims(encodedPayload: string): AssetClaims | null { |
| 99 | 97 | try { |
| ... | ... | @@ -326,7 +324,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 326 | 324 | relativePath, |
| 327 | 325 | expiresAt, |
| 328 | 326 | }; |
| 329 | fileName = relativePath ? path.basename(relativePath) : "favicon.svg"; | |
| 327 | fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER; | |
| 330 | 328 | break; |
| 331 | 329 | } |
| 332 | 330 | } |
| ... | ... | @@ -391,9 +389,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( |
| 391 | 389 | } |
| 392 | 390 | |
| 393 | 391 | if (claims.kind === "project-favicon") { |
| 394 | if (claims.relativePath === null) { | |
| 395 | return { kind: "project-favicon-fallback" } satisfies ResolvedAsset; | |
| 396 | } | |
| 392 | if (claims.relativePath === null) return null; | |
| 397 | 393 | const faviconPath = yield* resolveCanonicalWorkspaceFileForRequest({ |
| 398 | 394 | workspaceRoot: claims.workspaceRoot, |
| 399 | 395 | relativePath: claims.relativePath, |
apps/server/src/http.ts+1-16| ... | ... | @@ -25,11 +25,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; |
| 25 | 25 | import { OtlpTracer } from "effect/unstable/observability"; |
| 26 | 26 | |
| 27 | 27 | import * as ServerConfig from "./config.ts"; |
| 28 | import { | |
| 29 | ASSET_ROUTE_PREFIX, | |
| 30 | FALLBACK_PROJECT_FAVICON_SVG, | |
| 31 | resolveAsset, | |
| 32 | } from "./assets/AssetAccess.ts"; | |
| 28 | import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; | |
| 33 | 29 | import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; |
| 34 | 30 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 35 | 31 | import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; |
| ... | ... | @@ -196,17 +192,6 @@ export const assetRouteLayer = HttpRouter.add( |
| 196 | 192 | if (!asset) { |
| 197 | 193 | return HttpServerResponse.text("Not Found", { status: 404 }); |
| 198 | 194 | } |
| 199 | if (asset.kind === "project-favicon-fallback") { | |
| 200 | return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, { | |
| 201 | status: 200, | |
| 202 | contentType: "image/svg+xml", | |
| 203 | headers: { | |
| 204 | "Cache-Control": "private, max-age=3600", | |
| 205 | "X-Content-Type-Options": "nosniff", | |
| 206 | }, | |
| 207 | }); | |
| 208 | } | |
| 209 | ||
| 210 | 195 | return yield* HttpServerResponse.file(asset.path, { |
| 211 | 196 | status: 200, |
| 212 | 197 | headers: { |
apps/web/src/components/ProjectFavicon.tsx+2-1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; | |
| 2 | 3 | import { FolderIcon } from "lucide-react"; |
| 3 | 4 | import { useState } from "react"; |
| 4 | 5 | import { useAssetUrl } from "../assets/assetUrls"; |
| ... | ... | @@ -15,7 +16,7 @@ export function ProjectFavicon(input: { |
| 15 | 16 | cwd: input.cwd, |
| 16 | 17 | }); |
| 17 | 18 | |
| 18 | if (!src) { | |
| 19 | if (!src || isProjectFaviconFallbackUrl(src)) { | |
| 19 | 20 | return <ProjectFaviconFallback className={input.className} />; |
| 20 | 21 | } |
| 21 | 22 |
packages/shared/package.json+4| ... | ... | @@ -3,6 +3,10 @@ |
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 5 | "exports": { |
| 6 | "./projectFavicon": { | |
| 7 | "types": "./src/projectFavicon.ts", | |
| 8 | "import": "./src/projectFavicon.ts" | |
| 9 | }, | |
| 6 | 10 | "./model": { |
| 7 | 11 | "types": "./src/model.ts", |
| 8 | 12 | "import": "./src/model.ts" |
packages/shared/src/projectFavicon.test.ts created+28| ... | ... | @@ -0,0 +1,28 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { isProjectFaviconFallbackUrl, PROJECT_FAVICON_FALLBACK_MARKER } from "./projectFavicon.ts"; | |
| 4 | ||
| 5 | describe("project favicon", () => { | |
| 6 | it("identifies fallback asset URLs by their dedicated filename", () => { | |
| 7 | expect( | |
| 8 | isProjectFaviconFallbackUrl( | |
| 9 | `https://environment.example/api/assets/signed-token/${PROJECT_FAVICON_FALLBACK_MARKER}`, | |
| 10 | ), | |
| 11 | ).toBe(true); | |
| 12 | expect( | |
| 13 | isProjectFaviconFallbackUrl(`/api/assets/signed-token/${PROJECT_FAVICON_FALLBACK_MARKER}`), | |
| 14 | ).toBe(true); | |
| 15 | }); | |
| 16 | ||
| 17 | it("does not mistake real favicons or query parameters for fallbacks", () => { | |
| 18 | expect( | |
| 19 | isProjectFaviconFallbackUrl("https://environment.example/api/assets/token/favicon.svg"), | |
| 20 | ).toBe(false); | |
| 21 | expect( | |
| 22 | isProjectFaviconFallbackUrl( | |
| 23 | `https://environment.example/api/assets/token/favicon.svg?name=${PROJECT_FAVICON_FALLBACK_MARKER}`, | |
| 24 | ), | |
| 25 | ).toBe(false); | |
| 26 | expect(isProjectFaviconFallbackUrl(null)).toBe(false); | |
| 27 | }); | |
| 28 | }); |
packages/shared/src/projectFavicon.ts created+12| ... | ... | @@ -0,0 +1,12 @@ |
| 1 | export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing"; | |
| 2 | ||
| 3 | export function isProjectFaviconFallbackUrl(url: string | null | undefined): boolean { | |
| 4 | if (!url) return false; | |
| 5 | ||
| 6 | try { | |
| 7 | const pathname = new URL(url, "https://t3.invalid").pathname; | |
| 8 | return pathname.slice(pathname.lastIndexOf("/") + 1) === PROJECT_FAVICON_FALLBACK_MARKER; | |
| 9 | } catch { | |
| 10 | return false; | |
| 11 | } | |
| 12 | } |