diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index a596ca55c32a9ea40482ba85a9b41c7b7f1742e1..35844a6be11aa001d8b0bf33cb3ff307f7fc6d32 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; import { AssetAccessError, AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; @@ -35,6 +36,11 @@ vi.mock("node:fs/promises", async (importOriginal) => { return { ...actual, open: vi.fn(actual.open), realpath: vi.fn(actual.realpath) }; }); +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: vi.fn(actual.homedir) }; +}); + const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-asset-access-test-", }); @@ -183,6 +189,55 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("resolves home-relative media paths independently of the workspace", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-home-" }); + const home = path.join(directory, "var", "home", "alice"); + const filePath = path.join(home, "Downloads", "repro.mp4"); + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, "recording bytes"); + const canonicalFile = yield* fs.realPath(filePath); + const homeSpy = vi.mocked(NodeOS.homedir).mockReturnValue(home); + try { + for (const workspaceRoot of [ + path.join(home, "project"), + path.join(directory, "srv", "project"), + undefined, + ]) { + if (workspaceRoot) yield* fs.makeDirectory(workspaceRoot, { recursive: true }); + for (const requestedPath of ["~/Downloads/repro.mp4", "~\\Downloads/repro.mp4"]) { + const result = yield* issueAssetUrl({ + resource: { + _tag: "media-file", + threadId: ThreadId.make("thread-1"), + path: requestedPath, + }, + ...(workspaceRoot ? { workspaceRoot } : {}), + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const asset = yield* resolveAsset( + suffix.slice(0, separator), + suffix.slice(separator + 1), + ); + expect(asset).toMatchObject({ + kind: "file", + path: canonicalFile, + mimeType: "video/mp4", + }); + if (asset?.kind !== "file") throw new Error("Expected a resolved home media file"); + const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset)); + expect(yield* Effect.promise(() => response.text())).toBe("recording bytes"); + } + } + } finally { + homeSpy.mockRestore(); + } + }).pipe(Effect.provide(testLayer)), + ); + it.effect.skipIf(!symlinksSupported)( "rejects non-previewable files, disguised targets, and directories", () => diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 25a2748cd9e502c9df070bff5de42356f22b2710..be0d280fef7698650af0e7097df9eba74879709a 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -49,6 +49,7 @@ import { import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; +import { expandHomePathWith } from "../pathExpansion.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; @@ -433,7 +434,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i switch (input.resource._tag) { case "media-file": { - let requestedPath = input.resource.path; + let requestedPath = expandHomePathWith(input.resource.path, path); if (!path.isAbsolute(requestedPath)) { if (!input.workspaceRoot) { return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource });