authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 13:33:23-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 20:33:23+00:00
log5f4c7161fa7782ea93b63fe934d43229742e7ec8
tree6b389bdb1e94c2ed890c4594f12b7d7bb474f809
parent912276bcd9ca258ad9309727df8e279e5ab698f8
signature Signed by PGP key B5690EEEBB952194

fix(server): canonicalise media paths the same way their callers do (#9575)

Co-authored-by: Claude Code <noreply@anthropic.com>

3 files changed, 22 insertions(+), 11 deletions(-)

apps/server/src/assets/AssetAccess.test.ts+2-2
...@@ -754,7 +754,7 @@ describe("AssetAccess", () => {...@@ -754,7 +754,7 @@ describe("AssetAccess", () => {
754 projectFaviconPath: "brand/custom.svg",754 projectFaviconPath: "brand/custom.svg",
755 });755 });
756756
757 expect(result.sourcePath).toBe("brand/custom.svg");757 expect(result.sourcePath).toBe(path.join("brand", "custom.svg"));
758 expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-custom\.svg$/);758 expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-custom\.svg$/);
759 }).pipe(Effect.provide(testLayer)),759 }).pipe(Effect.provide(testLayer)),
760 );760 );
...@@ -813,7 +813,7 @@ describe("AssetAccess", () => {...@@ -813,7 +813,7 @@ describe("AssetAccess", () => {
813 projectFaviconPath: "brand/saved.svg",813 projectFaviconPath: "brand/saved.svg",
814 });814 });
815815
816 expect(result.sourcePath).toBe("brand/saved.svg");816 expect(result.sourcePath).toBe(path.join("brand", "saved.svg"));
817 expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-saved\.svg$/);817 expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-saved\.svg$/);
818 }).pipe(Effect.provide(testLayer)),818 }).pipe(Effect.provide(testLayer)),
819 );819 );
apps/server/src/assets/MediaFile.ts+10-1
...@@ -37,6 +37,11 @@ export interface OpenMediaFile {...@@ -37,6 +37,11 @@ export interface OpenMediaFile {
37 readonly info: NodeFS.BigIntStats;37 readonly info: NodeFS.BigIntStats;
38}38}
3939
40const realpathLikeFileSystem = (filePath: string) =>
41 new Promise<string>((resolve, reject) => {
42 NodeFS.realpath(filePath, (error, resolved) => (error ? reject(error) : resolve(resolved)));
43 });
44
40/** Opens a canonical media path once. Replacements cannot change the response's source. */45/** Opens a canonical media path once. Replacements cannot change the response's source. */
41export const openMediaFile = Effect.fn("openMediaFile")(function* (46export const openMediaFile = Effect.fn("openMediaFile")(function* (
42 filePath: string,47 filePath: string,
...@@ -71,7 +76,11 @@ export const openMediaFile = Effect.fn("openMediaFile")(function* (...@@ -71,7 +76,11 @@ export const openMediaFile = Effect.fn("openMediaFile")(function* (
71 ) {76 ) {
72 return null;77 return null;
73 }78 }
74 if ((await NodeFSP.realpath(filePath)) !== filePath) return null;79 // Callers canonicalise with Effect's FileSystem.realPath, which is
80 // Node's JS realpath. fs/promises.realpath is the native binding and
81 // on Windows also expands 8.3 short names, so a path that is already
82 // canonical by the caller's rules would still look swapped here.
83 if ((await realpathLikeFileSystem(filePath)) !== filePath) return null;
75 const after = await NodeFSP.lstat(filePath, { bigint: true });84 const after = await NodeFSP.lstat(filePath, { bigint: true });
76 if (!after.isFile() || info.dev !== after.dev || info.ino !== after.ino) return null;85 if (!after.isFile() || info.dev !== after.dev || info.ino !== after.ino) return null;
77 accepted = true;86 accepted = true;
apps/server/src/project/ProjectFaviconResolver.test.ts+10-8
...@@ -126,6 +126,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {...@@ -126,6 +126,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
126 it.effect("prefers a t3.json iconPath over well-known files", () =>126 it.effect("prefers a t3.json iconPath over well-known files", () =>
127 Effect.gen(function* () {127 Effect.gen(function* () {
128 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;128 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
129 const path = yield* Path.Path;
129 const cwd = yield* makeTempDir;130 const cwd = yield* makeTempDir;
130 yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "brand/mark.svg" }');131 yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "brand/mark.svg" }');
131 yield* writeTextFile(cwd, "brand/mark.svg", "<svg>mark</svg>");132 yield* writeTextFile(cwd, "brand/mark.svg", "<svg>mark</svg>");
...@@ -134,13 +135,14 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {...@@ -134,13 +135,14 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
134 const resolved = yield* resolver.resolvePath(cwd);135 const resolved = yield* resolver.resolvePath(cwd);
135136
136 expect(resolved).not.toBeNull();137 expect(resolved).not.toBeNull();
137 expect(resolved).toContain("brand/mark.svg");138 expect(resolved).toBe(path.join(cwd, "brand", "mark.svg"));
138 }),139 }),
139 );140 );
140141
141 it.effect("uses a saved project favicon override", () =>142 it.effect("uses a saved project favicon override", () =>
142 Effect.gen(function* () {143 Effect.gen(function* () {
143 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;144 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
145 const path = yield* Path.Path;
144 const cwd = yield* makeTempDir;146 const cwd = yield* makeTempDir;
145 yield* writeTextFile(cwd, "brand/custom.svg", "<svg>custom</svg>");147 yield* writeTextFile(cwd, "brand/custom.svg", "<svg>custom</svg>");
146 yield* writeTextFile(cwd, "favicon.svg", "<svg>automatic</svg>");148 yield* writeTextFile(cwd, "favicon.svg", "<svg>automatic</svg>");
...@@ -148,7 +150,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {...@@ -148,7 +150,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
148 const resolved = yield* resolver.resolvePath(cwd, "brand/custom.svg");150 const resolved = yield* resolver.resolvePath(cwd, "brand/custom.svg");
149151
150 expect(resolved).not.toBeNull();152 expect(resolved).not.toBeNull();
151 expect(resolved).toContain("brand/custom.svg");153 expect(resolved).toBe(path.join(cwd, "brand", "custom.svg"));
152 }),154 }),
153 );155 );
154156
...@@ -232,7 +234,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {...@@ -232,7 +234,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
232 const resolved = yield* resolver.resolvePath(cwd);234 const resolved = yield* resolver.resolvePath(cwd);
233235
234 expect(resolved).not.toBeNull();236 expect(resolved).not.toBeNull();
235 expect(resolved).toContain("public/brand/logo.svg");237 expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg"));
236 }),238 }),
237 );239 );
238240
...@@ -257,7 +259,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {...@@ -257,7 +259,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
257 const resolved = yield* resolver.resolvePath(cwd);259 const resolved = yield* resolver.resolvePath(cwd);
258260
259 expect(resolved).not.toBeNull();261 expect(resolved).not.toBeNull();
260 expect(resolved).toContain("public/brand/logo.svg");262 expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg"));
261 }),263 }),
262 );264 );
263265
...@@ -275,7 +277,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {...@@ -275,7 +277,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
275 const resolved = yield* resolver.resolvePath(cwd);277 const resolved = yield* resolver.resolvePath(cwd);
276278
277 expect(resolved).not.toBeNull();279 expect(resolved).not.toBeNull();
278 expect(resolved).toContain("public/brand/logo.svg");280 expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg"));
279 }),281 }),
280 );282 );
281283
...@@ -293,7 +295,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {...@@ -293,7 +295,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
293 const resolved = yield* resolver.resolvePath(cwd);295 const resolved = yield* resolver.resolvePath(cwd);
294296
295 expect(resolved).not.toBeNull();297 expect(resolved).not.toBeNull();
296 expect(resolved).toContain("public/brand/logo.svg");298 expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg"));
297 }),299 }),
298 );300 );
299301
...@@ -311,7 +313,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {...@@ -311,7 +313,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
311 const resolved = yield* resolver.resolvePath(cwd);313 const resolved = yield* resolver.resolvePath(cwd);
312314
313 expect(resolved).not.toBeNull();315 expect(resolved).not.toBeNull();
314 expect(resolved).toContain("public/brand/logo.svg");316 expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg"));
315 }),317 }),
316 );318 );
317319
...@@ -459,7 +461,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {...@@ -459,7 +461,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
459 const resolved = yield* resolver.resolvePath(cwd);461 const resolved = yield* resolver.resolvePath(cwd);
460462
461 expect(resolved).not.toBeNull();463 expect(resolved).not.toBeNull();
462 expect(resolved).toContain("public/brand/logo.svg");464 expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg"));
463 }),465 }),
464 );466 );
465 });467 });