fix: serve projects and assets whose names contain @ % # & or ? (#4415)

* fix(studio-server): serve projects whose names contain @ % # & or ?

Five routes cut the decoded project id out of Hono's c.req.path, which leaves %40 %25 %23 %26 %3F
encoded, so the cut missed and files, preview assets, sub-compositions, thumbnails and waveforms
404ed or 403ed for those names. One helper now takes the sub-path by segment from the raw URL.

* fix(studio-server): thumbnails and waveforms stay inside the project for any name

The thumbnail route built its preview URL from the raw project id and path, so # ? and % broke it,
and neither route checked that the decoded path stays inside the project. Thumbnails now use the
composition guard and an encoded URL; waveforms use the read-only asset guard.

* fix(cli): play and present serve assets whose names contain @ % # & or ?

Both /composition/* routes cut a prefix out of Hono's c.req.path with no decode. They now share
studio-server's requestSubPath (renamed from projectSubPath and generalised to any route) with the
five studio-server routes, so one helper owns how a request path becomes a file path.

* fix(studio-server): a thumbnail request for the project folder answers 404

An empty thumbnail path resolved to the project directory and crashed reading it (500).

* fix(studio-server): read the thumbnail's composition with one open

Checking the path with stat and then reading it let the file change in between (CodeQL
js/file-system-race). One open now serves the type check, the mtime and the read; a missing file
still thumbnails from the preview as before, anything that is not a file answers 404.
This commit is contained in:
Miguel Ángel
2026-09-24 10:38:56 -04:00
committed by GitHub
parent 1926903b6a
commit fed45bac7c
10 changed files with 262 additions and 72 deletions
@@ -0,0 +1,53 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Hono } from "hono";
import type { ProjectDir } from "../utils/project.js";
import { registerCompositionRoute } from "./play.js";
import { registerPresentCompositionRoute } from "./present.js";
// Hono's c.req.path leaves %40 %25 %23 %26 %3F encoded; Windows file names cannot hold "?".
const NAMES = [
"A @HyperFrames launch",
"50% off",
"#2 take",
"Tom & Jerry",
...(process.platform === "win32" ? [] : ["why?"]),
"two spaces",
"café crème",
"🎬 film",
];
const ROUTES = [
["play", (app: Hono, project: ProjectDir) => registerCompositionRoute(app, project, false)],
["present", registerPresentCompositionRoute],
] as const;
let dir: string | undefined;
afterEach(() => {
if (dir) rmSync(dir, { recursive: true, force: true });
dir = undefined;
});
async function requestAsset(
register: (app: Hono, project: ProjectDir) => Promise<void>,
name: string,
) {
dir = mkdtempSync(join(tmpdir(), "hf-composition-route-"));
mkdirSync(join(dir, "scenes"));
writeFileSync(join(dir, "scenes", `${name}.txt`), `ASSET ${name}`);
const app = new Hono();
await register(app, { dir, name: "test-project", indexPath: join(dir, "index.html") });
const response = await app.request(`/composition/scenes/${encodeURIComponent(`${name}.txt`)}`);
return { status: response.status, text: await response.text() };
}
describe.each(ROUTES)("%s /composition/*", (_route, register) => {
it.each(NAMES)("serves an asset named %j", async (name) => {
const result = await requestAsset(register, name);
expect(result.status).toBe(200);
expect(result.text).toBe(`ASSET ${name}`);
});
});
+2 -1
View File
@@ -20,6 +20,7 @@ export const examples: Example[] = [
];
import { resolve } from "node:path";
import type { Hono } from "hono";
import { requestSubPath } from "@hyperframes/studio-server";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { resolveProject, type ProjectDir } from "../utils/project.js";
@@ -213,7 +214,7 @@ export async function registerCompositionRoute(
// fallow-ignore-next-line complexity
app.get("/composition/*", async (ctx) => {
const reqPath = ctx.req.path.replace("/composition/", "");
const reqPath = requestSubPath(ctx.req.url, "composition");
const filePath = resolve(project.dir, reqPath);
// Security: don't allow path traversal outside project dir. isSafePath
+21 -12
View File
@@ -13,7 +13,9 @@ export const examples: Example[] = [
import { resolve } from "node:path";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { resolveProject } from "../utils/project.js";
import { resolveProject, type ProjectDir } from "../utils/project.js";
import type { Hono } from "hono";
import { requestSubPath } from "@hyperframes/studio-server";
import {
openBrowser,
parseRemoteDebuggingPort,
@@ -113,7 +115,6 @@ export default defineCommand({
const { Hono } = await import("hono");
const { createAdaptorServer } = await import("@hono/node-server");
const { isSafePath } = await import("@hyperframes/core/studio-api");
const app = new Hono();
@@ -133,16 +134,7 @@ export default defineCommand({
// timelines (no engine runtime injected) — the same model demo.html / the
// standalone harness use; injecting a runtime would leave the composition
// engine-paused and blank.
app.get("/composition/*", (ctx) => {
const reqPath = ctx.req.path.replace("/composition/", "");
const filePath = resolve(project.dir, reqPath);
// Security: canonicalizes symlinks + guards the trailing separator so neither
// an in-project symlink nor a sibling dir sharing the prefix can escape.
if (!isSafePath(project.dir, filePath)) return ctx.text("Forbidden", 403);
if (!existsSync(filePath)) return ctx.text("Not found", 404);
if (filePath.endsWith(".html")) return ctx.html(readFileSync(filePath, "utf-8"));
return ctx.body(readFileSync(filePath), 200, { "Content-Type": assetContentType(filePath) });
});
await registerPresentCompositionRoute(app, project);
// Both the presenter window and the audience window (opened by present() with
// ?mode=audience) load this same page; the component reads the mode from the URL.
@@ -309,3 +301,20 @@ function escHtml(value: string): string {
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
export async function registerPresentCompositionRoute(
app: Hono,
project: ProjectDir,
): Promise<void> {
const { isSafePath } = await import("@hyperframes/core/studio-api");
app.get("/composition/*", (ctx) => {
const reqPath = requestSubPath(ctx.req.url, "composition");
const filePath = resolve(project.dir, reqPath);
// Security: canonicalizes symlinks + guards the trailing separator so neither
// an in-project symlink nor a sibling dir sharing the prefix can escape.
if (!isSafePath(project.dir, filePath)) return ctx.text("Forbidden", 403);
if (!existsSync(filePath)) return ctx.text("Not found", 404);
if (filePath.endsWith(".html")) return ctx.html(readFileSync(filePath, "utf-8"));
return ctx.body(readFileSync(filePath), 200, { "Content-Type": assetContentType(filePath) });
});
}
@@ -0,0 +1,8 @@
// The decoded path after `route` ("projects/:id/preview", "composition"), cut by segment from the raw URL:
// Hono's c.req.path leaves %40 %25 %23 %26 %3F encoded, so cutting a decoded prefix out of it misses.
export function requestSubPath(url: string, route: string): string {
const routeSegments = route.split("/");
const segments = new URL(url).pathname.split("/");
const start = segments.indexOf(routeSegments[0] ?? "");
return decodeURIComponent(segments.slice(start + routeSegments.length).join("/"));
}
+1
View File
@@ -31,6 +31,7 @@ export {
export type { PreviewApiAdapter } from "./helpers/mediaProxyPreview.js";
export { PREVIEW_BUNDLE_OPTIONS } from "./routes/preview.js";
export { getMimeType, MIME_TYPES } from "./helpers/mime.js";
export { requestSubPath } from "./helpers/requestSubPath.js";
export {
consumeFileWriteReceipt,
identifyFileWrite,
+10 -19
View File
@@ -89,6 +89,7 @@ import {
insertCompositionIntoSource,
} from "../helpers/compositionInsertion.js";
import { resolveGsapWriter } from "./gsapMutationCapabilities.js";
import { requestSubPath } from "../helpers/requestSubPath.js";
// ── Server cutover flag ─────────────────────────────────────────────────────
@@ -114,7 +115,7 @@ async function loadGsapParser() {
interface RouteContext {
req: {
param: (name: string) => string;
path: string;
url: string;
query: (name: string) => string | undefined;
header: (name: string) => string | undefined;
};
@@ -172,7 +173,7 @@ function isDanglingSymlinkInProject(projectDir: string, lexicalPath: string): bo
async function resolveProjectPath(
c: RouteContext,
adapter: StudioApiAdapter,
pathPrefix: (projectId: string) => string,
route: string,
opts?: { mustExist?: boolean },
) {
const id = c.req.param("id");
@@ -195,7 +196,7 @@ async function resolveProjectPath(
} as const;
}
const filePath = decodeURIComponent(c.req.path.replace(pathPrefix(project.id), ""));
const filePath = requestSubPath(c.req.url, `projects/:id/${route}`);
if (filePath.includes("\0")) {
return { error: c.json({ error: "forbidden", why: "nul" }, 403) } as const;
}
@@ -220,11 +221,11 @@ function resolveProjectFile(
adapter: StudioApiAdapter,
opts?: { mustExist?: boolean },
) {
return resolveProjectPath(c, adapter, (id) => `/projects/${id}/files/`, opts);
return resolveProjectPath(c, adapter, "files", opts);
}
function resolveFileMutationContext(c: RouteContext, adapter: StudioApiAdapter, operation: string) {
return resolveProjectPath(c, adapter, (id) => `/projects/${id}/file-mutations/${operation}/`);
return resolveProjectPath(c, adapter, `file-mutations/${operation}`);
}
type MutationTarget = {
@@ -3222,7 +3223,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
// ── GSAP Animations (parse) ──
api.get("/projects/:id/gsap-animations/*", async (c) => {
const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-animations/`, {
const res = await resolveProjectPath(c, adapter, "gsap-animations", {
mustExist: true,
});
if ("error" in res) return res.error;
@@ -3251,7 +3252,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
});
api.post("/projects/:id/gsap-mutations/*", async (c) => {
const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-mutations/`, {
const res = await resolveProjectPath(c, adapter, "gsap-mutations", {
mustExist: true,
});
if ("error" in res) return res.error;
@@ -3264,12 +3265,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
});
api.post("/projects/:id/gsap-mutations-batch/*", async (c) => {
const res = await resolveProjectPath(
c,
adapter,
(id) => `/projects/${id}/gsap-mutations-batch/`,
{ mustExist: true },
);
const res = await resolveProjectPath(c, adapter, "gsap-mutations-batch", { mustExist: true });
if ("error" in res) return res.error;
const body = (await c.req.json().catch(() => null)) as {
@@ -3289,12 +3285,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
// mutation wrote. Keep compare + write in this synchronous server section so
// another request cannot land between a client-side check and the restore.
api.post("/projects/:id/gsap-mutation-rollback/*", async (c) => {
const res = await resolveProjectPath(
c,
adapter,
(id) => `/projects/${id}/gsap-mutation-rollback/`,
{ mustExist: true },
);
const res = await resolveProjectPath(c, adapter, "gsap-mutation-rollback", { mustExist: true });
if ("error" in res) return res.error;
const body = (await c.req.json().catch(() => null)) as {
+3 -6
View File
@@ -47,6 +47,7 @@ import {
resolvePreviewMediaCodecProbeCache,
type PreviewApiAdapter,
} from "../helpers/mediaProxyPreview.js";
import { requestSubPath } from "../helpers/requestSubPath.js";
const PROJECT_SIGNATURE_META = "hyperframes-project-signature";
const GSAP_CDN_VERSION = "3.15.0";
@@ -471,9 +472,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
const vars = previewVariablesFromRequest(c.req.query("variables"));
if (vars.error !== undefined) return c.json({ error: vars.error }, 400);
const previewVariables = vars.values;
const compPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/preview/comp/`, "").split("?")[0] ?? "",
);
const compPath = requestSubPath(c.req.url, "projects/:id/preview/comp");
const compFile = resolveWithinProject(project.dir, compPath);
if (!compFile || !existsSync(compFile) || !statSync(compFile).isFile()) {
return c.text("not found", 404);
@@ -516,9 +515,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
api.get("/projects/:id/preview/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const subPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/preview/`, "").split("?")[0] ?? "",
);
const subPath = requestSubPath(c.req.url, "projects/:id/preview");
// Assets are read-only and should mirror the renderer: permit a path that
// is lexically inside the project even if an explicit project symlink
// targets a shared directory outside it. Composition source files still
@@ -1,30 +1,16 @@
import { describe, expect, it } from "vitest";
import { Hono } from "hono";
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "node:fs";
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerFileRoutes } from "./files.js";
import { registerPreviewRoutes } from "./preview.js";
import { registerThumbnailRoutes } from "./thumbnail.js";
import { registerWaveformRoutes } from "./waveform.js";
import { buildWaveformCacheKey } from "../helpers/waveform.js";
import type { StudioApiAdapter } from "../types.js";
/**
* Pins the path-resolution contract of the project-file read that Studio's SDK
* session depends on. If this route fails, `openComposition` never runs and
* every edit in that project silently falls back to the server path.
*
* The shapes here are the ones that looked most likely to break it, because
* `resolveProjectPath` strips a prefix built from the *decoded* `project.id`
* out of `c.req.path`, and the CLI derives that id straight from the folder
* name (`projectId = projectName || basename(projectDir)`). A folder named with
* a space or non-ASCII character therefore produces an id that percent-encodes,
* and sub-compositions — the house pattern, one per scene — put a separator in
* the file path that encodes as %2F.
*
* All of them pass today: Hono hands `c.req.path` over already decoded. These
* are regression tests, not a reproduction — they were written while hunting a
* production failure (`stage: read`) that turned out to be something else, and
* they are kept because the decoded/encoded seam is a real latent hazard that a
* refactor of `resolveProjectPath` could reopen silently.
*/
// Project ids come from folder names, so any character a folder allows must survive the URL.
function createAdapter(projectDir: string): StudioApiAdapter {
return {
listProjects: () => [],
@@ -92,3 +78,118 @@ describe("project ids that percent-encode in a URL", () => {
expect(result.content).toContain("SCENE ONE");
});
});
// A Home sentence with an @ mention names the project; Hono leaves %40 %25 %23 %26 %3F encoded in c.req.path.
const RESERVED_NAMES = [
"A @HyperFrames launch",
"50% off",
"#2 take",
"Tom & Jerry",
"why?",
"two spaces",
"café crème",
"🎬 film",
];
type ThumbnailCall = { compPath: string; previewUrl: string };
async function requestProject(projectId: string, route: string, encodedSubPath: string) {
const { dir, cleanup } = projectWithComposition();
writeFileSync(join(dir, "scenes", "voice.wav"), "RIFF");
// The waveform route's own cache hit, so no audio decoder runs.
const voice = statSync(join(dir, "scenes", "voice.wav"));
mkdirSync(join(dir, ".waveform-cache"));
writeFileSync(
join(dir, ".waveform-cache", buildWaveformCacheKey("scenes/voice.wav", voice)),
"[0.5]",
);
const thumbnails: ThumbnailCall[] = [];
const adapter = {
...createAdapter(dir),
generateThumbnail: async (opts: ThumbnailCall) => {
thumbnails.push({ compPath: opts.compPath, previewUrl: opts.previewUrl });
return Buffer.from("jpeg");
},
} as StudioApiAdapter;
try {
const app = new Hono();
registerFileRoutes(app, adapter);
registerPreviewRoutes(app, adapter);
registerThumbnailRoutes(app, adapter);
registerWaveformRoutes(app, adapter);
const response = await app.request(
`http://localhost/projects/${encodeURIComponent(projectId)}/${route}/${encodedSubPath}`,
);
return { status: response.status, text: await response.text(), thumbnails };
} finally {
cleanup();
}
}
describe.each(RESERVED_NAMES)("project id %j", (projectId) => {
it("reads a project file", async () => {
const result = await readComposition(projectId, "scenes/scene-1.html");
expect(result.status).toBe(200);
expect(result.content).toContain("SCENE ONE");
});
it("serves a preview asset", async () => {
const result = await requestProject(projectId, "preview", "scenes/scene-1.html");
expect(result.status).toBe(200);
expect(result.text).toContain("SCENE ONE");
});
it("serves a preview sub-composition", async () => {
const result = await requestProject(projectId, "preview/comp", "scenes/scene-1.html");
expect(result.status).toBe(200);
expect(result.text).toContain("SCENE ONE");
});
it("thumbnails a sub-composition through a preview URL that parses back to it", async () => {
const result = await requestProject(projectId, "thumbnail", "scenes/scene-1.html");
expect(result.status).toBe(200);
expect(result.thumbnails).toHaveLength(1);
expect(result.thumbnails[0]?.compPath).toBe("scenes/scene-1.html");
const url = new URL(result.thumbnails[0]?.previewUrl ?? "");
expect(url.search + url.hash).toBe("");
const segments = url.pathname.split("/").map(decodeURIComponent);
expect(segments).toEqual([
"",
"api",
"projects",
projectId,
"preview",
"comp",
"scenes",
"scene-1.html",
]);
});
it("serves an audio waveform", async () => {
const result = await requestProject(projectId, "waveform", "scenes/voice.wav");
expect(result.status).toBe(200);
expect(JSON.parse(result.text)).toEqual({ peaks: [0.5] });
});
});
// Encoded as one segment: a literal ../ is normalised away before routing.
const OUTSIDE_PROJECT = encodeURIComponent(`${"../".repeat(24)}etc/hosts`);
describe("a sub-path that decodes to a parent directory", () => {
it("is not thumbnailed", async () => {
const result = await requestProject("demo-project", "thumbnail", OUTSIDE_PROJECT);
expect(result.status).toBe(404);
expect(result.thumbnails).toHaveLength(0);
});
it("thumbnails nothing for the project folder itself", async () => {
const result = await requestProject("demo-project", "thumbnail", "");
expect(result.status).toBe(404);
expect(result.thumbnails).toHaveLength(0);
});
it("is not read for a waveform", async () => {
const result = await requestProject("demo-project", "waveform", OUTSIDE_PROJECT);
expect(result.status).toBe(404);
});
});
+35 -9
View File
@@ -1,6 +1,9 @@
import type { Hono } from "hono";
import {
closeSync,
existsSync,
fstatSync,
openSync,
mkdirSync,
readFileSync,
readdirSync,
@@ -18,6 +21,8 @@ import { compositionInputSignature } from "../helpers/compositionInputs.js";
import { createProjectSignature, resolveProjectAndSignature } from "../helpers/projectSignature.js";
import { STUDIO_MOTION_PATH } from "../helpers/studioMotionRenderScript.js";
import { thumbnailGenerationCoordinator } from "./thumbnailGenerationCoordinator.js";
import { requestSubPath } from "../helpers/requestSubPath.js";
import { resolveWithinProject } from "../helpers/safePath.js";
const THUMBNAIL_CACHE_VERSION = "v4";
const THUMBNAIL_MAX_OUTPUT_WIDTH = 240;
@@ -74,6 +79,26 @@ function writeThumbnailAtomically(path: string, buffer: Buffer): void {
}
}
type CompositionSource = { html: string; mtimeMs: number } | "missing" | "not-a-file";
// One open for the stat and the read, so the file cannot change between the check and the use.
function readCompositionSource(file: string): CompositionSource {
let fd: number;
try {
fd = openSync(file, "r");
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return "missing";
throw err;
}
try {
const stat = fstatSync(fd);
if (!stat.isFile()) return "not-a-file";
return { html: readFileSync(fd, "utf-8"), mtimeMs: stat.mtimeMs };
} finally {
closeSync(fd);
}
}
export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): void {
api.get("/projects/:id/thumbnail/*", async (c) => {
if (!adapter.generateThumbnail) {
@@ -83,10 +108,11 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
if (!resolved) return c.json({ error: "not found" }, 404);
const { project, signature: projectSignature } = resolved;
let compPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/thumbnail/`, "").split("?")[0] ?? "",
);
let compPath = requestSubPath(c.req.url, "projects/:id/thumbnail");
if (compPath && !compPath.includes(".")) compPath += ".html";
const htmlFile = resolveWithinProject(project.dir, compPath);
const source = htmlFile ? readCompositionSource(htmlFile) : "not-a-file";
if (source === "not-a-file") return c.json({ error: "not found" }, 404);
// Keyed on what this composition renders from, so editing one scene leaves the others cached.
const inputSignature = compositionInputSignature(project.dir, compPath, projectSignature);
@@ -122,11 +148,10 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
// edit, even on a hard reload. Keyed on content (like manualEdits/motion), not
// just mtime, so a restore/copy with a preserved mtime can't serve stale.
let sourceKey = "";
const htmlFile = join(project.dir, compPath);
if (existsSync(htmlFile)) {
const html = readFileSync(htmlFile, "utf-8");
if (source !== "missing") {
const { html } = source;
sourceKey = `_${createHash("sha1").update(html).digest("hex").slice(0, 16)}`;
sourceMtime = Math.round(statSync(htmlFile).mtimeMs);
sourceMtime = Math.round(source.mtimeMs);
if (!vpWidth) {
const wMatch = html.match(/data-width=["'](\d+)["']/);
const hMatch = html.match(/data-height=["'](\d+)["']/);
@@ -149,10 +174,11 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
sourceMtime = Math.max(sourceMtime, Math.round(statSync(motionFile).mtimeMs));
}
const projectUrl = `http://${c.req.header("host")}/api/projects/${encodeURIComponent(project.id)}`;
const previewUrl =
compPath === "index.html"
? `http://${c.req.header("host")}/api/projects/${project.id}/preview`
: `http://${c.req.header("host")}/api/projects/${project.id}/preview/comp/${compPath}`;
? `${projectUrl}/preview`
: `${projectUrl}/preview/comp/${compPath.split("/").map(encodeURIComponent).join("/")}`;
// Cache
const cacheDir = join(project.dir, ".thumbnails");
@@ -1,5 +1,6 @@
import { existsSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { join, resolve } from "node:path";
import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
import type { Hono } from "hono";
import type { StudioApiAdapter } from "../types.js";
import {
@@ -8,16 +9,18 @@ import {
writeWaveformCache,
isWaveformCacheDirectory,
} from "../helpers/waveform.js";
import { requestSubPath } from "../helpers/requestSubPath.js";
export function registerWaveformRoutes(api: Hono, adapter: StudioApiAdapter): void {
api.get("/projects/:id/waveform/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const assetPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/waveform/`, "").split("?")[0] ?? "",
);
const audioPath = join(project.dir, assetPath);
const assetPath = requestSubPath(c.req.url, "projects/:id/waveform");
const audioPath = resolve(project.dir, assetPath);
if (!isWithinProjectRoot(project.dir, audioPath)) {
return c.json({ error: "file not found" }, 404);
}
const stats = statSync(audioPath, { throwIfNoEntry: false });
if (!stats) return c.json({ error: "file not found" }, 404);