authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-04 22:37:36-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 22:37:36-03:00
logf4153a75a4b04e14bd45d1b578af4d1d63cc84e5
tree5b3ca8dc48803e251dadffa761b800ebe9f756a0
parentbc2748241776066c532f3312a26f1756d9740f9c
signature Signed by PGP key B5690EEEBB952194

fix(server): expand home-relative media paths (#15618)


2 files changed, 57 insertions(+), 1 deletions(-)

apps/server/src/assets/AssetAccess.test.ts+55
...@@ -2,6 +2,7 @@...@@ -2,6 +2,7 @@
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";3import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
4import * as NodeFSP from "node:fs/promises";4import * as NodeFSP from "node:fs/promises";
5import * as NodeOS from "node:os";
5import { AssetAccessError, AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts";6import { AssetAccessError, AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts";
6import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";7import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
7import { describe, expect, it } from "@effect/vitest";8import { describe, expect, it } from "@effect/vitest";
...@@ -35,6 +36,11 @@ vi.mock("node:fs/promises", async (importOriginal) => {...@@ -35,6 +36,11 @@ vi.mock("node:fs/promises", async (importOriginal) => {
35 return { ...actual, open: vi.fn(actual.open), realpath: vi.fn(actual.realpath) };36 return { ...actual, open: vi.fn(actual.open), realpath: vi.fn(actual.realpath) };
36});37});
3738
39vi.mock("node:os", async (importOriginal) => {
40 const actual = await importOriginal<typeof NodeOS>();
41 return { ...actual, homedir: vi.fn(actual.homedir) };
42});
43
38const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {44const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
39 prefix: "t3-asset-access-test-",45 prefix: "t3-asset-access-test-",
40});46});
...@@ -183,6 +189,55 @@ describe("AssetAccess", () => {...@@ -183,6 +189,55 @@ describe("AssetAccess", () => {
183 }).pipe(Effect.provide(testLayer)),189 }).pipe(Effect.provide(testLayer)),
184 );190 );
185191
192 it.effect("resolves home-relative media paths independently of the workspace", () =>
193 Effect.gen(function* () {
194 const fs = yield* FileSystem.FileSystem;
195 const path = yield* Path.Path;
196 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-home-" });
197 const home = path.join(directory, "var", "home", "alice");
198 const filePath = path.join(home, "Downloads", "repro.mp4");
199 yield* fs.makeDirectory(path.dirname(filePath), { recursive: true });
200 yield* fs.writeFileString(filePath, "recording bytes");
201 const canonicalFile = yield* fs.realPath(filePath);
202 const homeSpy = vi.mocked(NodeOS.homedir).mockReturnValue(home);
203 try {
204 for (const workspaceRoot of [
205 path.join(home, "project"),
206 path.join(directory, "srv", "project"),
207 undefined,
208 ]) {
209 if (workspaceRoot) yield* fs.makeDirectory(workspaceRoot, { recursive: true });
210 for (const requestedPath of ["~/Downloads/repro.mp4", "~\\Downloads/repro.mp4"]) {
211 const result = yield* issueAssetUrl({
212 resource: {
213 _tag: "media-file",
214 threadId: ThreadId.make("thread-1"),
215 path: requestedPath,
216 },
217 ...(workspaceRoot ? { workspaceRoot } : {}),
218 });
219 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
220 const separator = suffix.indexOf("/");
221 const asset = yield* resolveAsset(
222 suffix.slice(0, separator),
223 suffix.slice(separator + 1),
224 );
225 expect(asset).toMatchObject({
226 kind: "file",
227 path: canonicalFile,
228 mimeType: "video/mp4",
229 });
230 if (asset?.kind !== "file") throw new Error("Expected a resolved home media file");
231 const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset));
232 expect(yield* Effect.promise(() => response.text())).toBe("recording bytes");
233 }
234 }
235 } finally {
236 homeSpy.mockRestore();
237 }
238 }).pipe(Effect.provide(testLayer)),
239 );
240
186 it.effect.skipIf(!symlinksSupported)(241 it.effect.skipIf(!symlinksSupported)(
187 "rejects non-previewable files, disguised targets, and directories",242 "rejects non-previewable files, disguised targets, and directories",
188 () =>243 () =>
apps/server/src/assets/AssetAccess.ts+2-1
...@@ -49,6 +49,7 @@ import {...@@ -49,6 +49,7 @@ import {
49import * as ServerSecretStore from "../auth/ServerSecretStore.ts";49import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
50import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts";50import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts";
51import * as ServerConfig from "../config.ts";51import * as ServerConfig from "../config.ts";
52import { expandHomePathWith } from "../pathExpansion.ts";
52import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";53import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
53import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";54import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
54import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";55import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
...@@ -433,7 +434,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -433,7 +434,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
433434
434 switch (input.resource._tag) {435 switch (input.resource._tag) {
435 case "media-file": {436 case "media-file": {
436 let requestedPath = input.resource.path;437 let requestedPath = expandHomePathWith(input.resource.path, path);
437 if (!path.isAbsolute(requestedPath)) {438 if (!path.isAbsolute(requestedPath)) {
438 if (!input.workspaceRoot) {439 if (!input.workspaceRoot) {
439 return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource });440 return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource });