diff --git a/packages/cli/src/commands/compositionRoute.encoding.test.ts b/packages/cli/src/commands/compositionRoute.encoding.test.ts new file mode 100644 index 0000000000..3c9eb5c081 --- /dev/null +++ b/packages/cli/src/commands/compositionRoute.encoding.test.ts @@ -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, + 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}`); + }); +}); diff --git a/packages/cli/src/commands/play.ts b/packages/cli/src/commands/play.ts index 0be49f9f02..fc55a6620e 100644 --- a/packages/cli/src/commands/play.ts +++ b/packages/cli/src/commands/play.ts @@ -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 diff --git a/packages/cli/src/commands/present.ts b/packages/cli/src/commands/present.ts index 6abe35e393..ec2f4c8ce3 100644 --- a/packages/cli/src/commands/present.ts +++ b/packages/cli/src/commands/present.ts @@ -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, ">") .replace(/"/g, """); } + +export async function registerPresentCompositionRoute( + app: Hono, + project: ProjectDir, +): Promise { + 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) }); + }); +} diff --git a/packages/studio-server/src/helpers/requestSubPath.ts b/packages/studio-server/src/helpers/requestSubPath.ts new file mode 100644 index 0000000000..a9cc870c8c --- /dev/null +++ b/packages/studio-server/src/helpers/requestSubPath.ts @@ -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("/")); +} diff --git a/packages/studio-server/src/index.ts b/packages/studio-server/src/index.ts index 110126c9df..0943a871e4 100644 --- a/packages/studio-server/src/index.ts +++ b/packages/studio-server/src/index.ts @@ -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, diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index 6aaa9a2bfa..37094376c2 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -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 { diff --git a/packages/studio-server/src/routes/preview.ts b/packages/studio-server/src/routes/preview.ts index e2169a5771..8baa1666df 100644 --- a/packages/studio-server/src/routes/preview.ts +++ b/packages/studio-server/src/routes/preview.ts @@ -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 diff --git a/packages/studio-server/src/routes/projectIdEncoding.test.ts b/packages/studio-server/src/routes/projectIdEncoding.test.ts index 19db40dda2..be53efc951 100644 --- a/packages/studio-server/src/routes/projectIdEncoding.test.ts +++ b/packages/studio-server/src/routes/projectIdEncoding.test.ts @@ -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); + }); +}); diff --git a/packages/studio-server/src/routes/thumbnail.ts b/packages/studio-server/src/routes/thumbnail.ts index 68787f0803..b6c693bf20 100644 --- a/packages/studio-server/src/routes/thumbnail.ts +++ b/packages/studio-server/src/routes/thumbnail.ts @@ -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"); diff --git a/packages/studio-server/src/routes/waveform.ts b/packages/studio-server/src/routes/waveform.ts index 0514a886fc..3a6054bebd 100644 --- a/packages/studio-server/src/routes/waveform.ts +++ b/packages/studio-server/src/routes/waveform.ts @@ -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);