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 @@
22import * as NodeServices from "@effect/platform-node/NodeServices";
33import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
44import * as NodeFSP from "node:fs/promises";
5import * as NodeOS from "node:os";
56import { AssetAccessError, AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts";
67import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
78import { describe, expect, it } from "@effect/vitest";
......@@ -35,6 +36,11 @@ vi.mock("node:fs/promises", async (importOriginal) => {
3536 return { ...actual, open: vi.fn(actual.open), realpath: vi.fn(actual.realpath) };
3637});
3738
39vi.mock("node:os", async (importOriginal) => {
40 const actual = await importOriginal<typeof NodeOS>();
41 return { ...actual, homedir: vi.fn(actual.homedir) };
42});
43
3844const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
3945 prefix: "t3-asset-access-test-",
4046});
......@@ -183,6 +189,55 @@ describe("AssetAccess", () => {
183189 }).pipe(Effect.provide(testLayer)),
184190 );
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
186241 it.effect.skipIf(!symlinksSupported)(
187242 "rejects non-previewable files, disguised targets, and directories",
188243 () =>
apps/server/src/assets/AssetAccess.ts+2-1
......@@ -49,6 +49,7 @@ import {
4949import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
5050import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts";
5151import * as ServerConfig from "../config.ts";
52import { expandHomePathWith } from "../pathExpansion.ts";
5253import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
5354import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
5455import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
......@@ -433,7 +434,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
433434
434435 switch (input.resource._tag) {
435436 case "media-file": {
436 let requestedPath = input.resource.path;
437 let requestedPath = expandHomePathWith(input.resource.path, path);
437438 if (!path.isAbsolute(requestedPath)) {
438439 if (!input.workspaceRoot) {
439440 return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource });