diff --git a/packages/studio-server/src/helpers/safePath.test.ts b/packages/studio-server/src/helpers/safePath.test.ts index 6afbea122ac..3977babdf16 100644 --- a/packages/studio-server/src/helpers/safePath.test.ts +++ b/packages/studio-server/src/helpers/safePath.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { walkDir } from "./safePath"; +import { isPrivateProjectFile, walkDir } from "./safePath"; const hooks = vi.hoisted(() => ({ unreadable: new Map() })); vi.mock("node:fs", async (importOriginal) => { @@ -82,3 +82,24 @@ describe("walkDir", () => { expect(() => walkDir(projectDir)).toThrow("EACCES"); }); }); + +describe("isPrivateProjectFile", () => { + it("names everything under .hyperframes/ but Studio's own files and request pictures, in any case", () => { + for (const name of [ + "agent-handoff.json", + "agent-handoff-read.json", + "app-history-seen.json", + "share.json", + ]) + expect(isPrivateProjectFile("/p", `/p/.hyperframes/${name}`)).toBe(true); + expect(isPrivateProjectFile("/p", "/p/.HyperFrames/App-History.JSONL")).toBe(true); + expect(isPrivateProjectFile("/p", "/p/.hyperframes")).toBe(true); + expect(isPrivateProjectFile("/p", "/p/.hyperframes/studio-motion.json")).toBe(false); + expect(isPrivateProjectFile("/p", "/p/.hyperframes/studio-manual-edits.json")).toBe(false); + expect(isPrivateProjectFile("/p", "/p/.hyperframes/prepared-assets/gif/a.mp4")).toBe(false); + expect(isPrivateProjectFile("/p", "/p/.hyperframes/prepared-assets")).toBe(false); + expect(isPrivateProjectFile("/p", "/p/.hyperframes/requests/1/before.png")).toBe(false); + expect(isPrivateProjectFile("/p", "/p/.hyperframes/requests-old/x")).toBe(true); + expect(isPrivateProjectFile("/p", "/p/agent-handoff.json")).toBe(false); + }); +}); diff --git a/packages/studio-server/src/helpers/safePath.ts b/packages/studio-server/src/helpers/safePath.ts index f6ff3510ebf..500927d40b0 100644 --- a/packages/studio-server/src/helpers/safePath.ts +++ b/packages/studio-server/src/helpers/safePath.ts @@ -1,6 +1,8 @@ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; import { readdirSync, type Dirent } from "node:fs"; -import { realpath, resolveWithinProject } from "@hyperframes/core/safe-path"; +import { realpath, resolveWithinProject as resolveInProject } from "@hyperframes/core/safe-path"; +import { STUDIO_MANUAL_EDITS_PATH } from "./manualEditsRenderScript.js"; +import { STUDIO_MOTION_PATH } from "./studioMotionRenderScript.js"; // `isSafePath` lives at the package root so non-studio-api layers (compiler, // CLI, engine) can share it without a backwards dependency on studio-api. @@ -12,9 +14,14 @@ export { mkdirWithinProject, realpath, realProjectRoot, - resolveWithinProject, } from "@hyperframes/core/safe-path"; +/** Core's `resolveWithinProject`, closed to the desktop app's private files for every route a request path reaches. */ +export function resolveWithinProject(base: string, relativePath: string): string | null { + const resolved = resolveInProject(base, relativePath); + return resolved && !isPrivateProjectFile(base, resolved) ? resolved : null; +} + /** The real path; for a path not there (yet, or any more), the nearest existing folder's real path plus the rest. */ export function realFilePath(filePath: string): string { try { @@ -25,6 +32,28 @@ export function realFilePath(filePath: string): string { } } +// `.hyperframes/` holds the desktop app's and the CLI's own records (a hand-off, chat history, read marks): no route +// reaches any of it, the folder itself included, but Studio's own files and the app's request pictures. +const STUDIO_FILES = new Set([STUDIO_MOTION_PATH, STUDIO_MANUAL_EDITS_PATH]); +const STUDIO_FOLDERS = [".hyperframes/prepared-assets", ".hyperframes/requests"]; + +/** For a project-relative path with `/` separators. */ +export function isPrivateProjectPath(relPath: string): boolean { + const path = relPath.toLowerCase(); + return ( + (path === ".hyperframes" || path.startsWith(".hyperframes/")) && + !STUDIO_FILES.has(path) && + !STUDIO_FOLDERS.some((folder) => path === folder || path.startsWith(`${folder}/`)) + ); +} + +export function isPrivateProjectFile(projectDir: string, filePath: string): boolean { + const rel = (from: string, to: string) => relative(from, to).split(sep).join("/"); + return [rel(projectDir, filePath), rel(realFilePath(projectDir), realFilePath(filePath))].some( + isPrivateProjectPath, + ); +} + /** * `resolveWithinProject` with every link inside the project resolved now, so a write to the result lands on the * file checked here even if a link on the way is retargeted before the write. diff --git a/packages/studio-server/src/routes/files.pathSafety.test.ts b/packages/studio-server/src/routes/files.pathSafety.test.ts index f7cb5cdf76a..9f11493b2fe 100644 --- a/packages/studio-server/src/routes/files.pathSafety.test.ts +++ b/packages/studio-server/src/routes/files.pathSafety.test.ts @@ -695,3 +695,124 @@ describe("rename reference updates", () => { }, ); }); + +describe("the desktop app's private files", () => { + it("are never read, written or deleted through the file routes, however the path is spelled", async (context) => { + const { app, project } = fixture(); + mkdirSync(join(project, ".hyperframes")); + writeFileSync( + join(project, ".hyperframes", "agent-handoff.json"), + '{"engine":"claude","sessionId":"secret"}', + ); + writeFileSync(join(project, ".hyperframes", "app-history.jsonl"), '{"said":"secret"}'); + writeFileSync( + join(project, ".hyperframes", "agent-handoff-read.json"), + '{"sessionId":"secret"}', + ); + for (const path of [ + ".hyperframes/agent-handoff.json", + ".hyperframes/app-history.jsonl", + ".HyperFrames/Agent-Handoff.JSON", + ".hyperframes/agent-handoff-read.json", + ]) { + const res = await app.request(`/projects/p/files/${path}`); + expect([403, 404]).toContain(res.status); + expect(await res.text()).not.toContain("secret"); + } + const put = await app.request("/projects/p/files/.hyperframes/agent-handoff.json", { + method: "PUT", + body: "{}", + }); + expect(put.status).toBe(403); + const del = await app.request("/projects/p/files/.hyperframes/app-history.jsonl", { + method: "DELETE", + }); + expect(del.status).toBe(403); + linkOrSkip( + context, + join(project, ".hyperframes", "agent-handoff.json"), + join(project, "link.json"), + "file", + ); + const viaLink = await app.request("/projects/p/files/link.json"); + expect(await viaLink.text()).not.toContain("secret"); + }); + + it("can't be reached by renaming their folder", async () => { + const { app, project } = fixture(); + mkdirSync(join(project, ".hyperframes")); + writeFileSync(join(project, ".hyperframes", "app-history.jsonl"), '{"said":"secret"}'); + const rename = await app.request("/projects/p/files/.hyperframes", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ newPath: "x" }), + }); + expect(rename.status).toBeGreaterThanOrEqual(400); + expect(existsSync(join(project, "x"))).toBe(false); + }); + + it("are neither read nor rewritten when a rename updates references", async () => { + const { app, project } = fixture(); + mkdirSync(join(project, ".hyperframes")); + const share = join(project, ".hyperframes", "share.json"); + writeFileSync(share, '{"ref":"inside.txt"}'); + const response = await app.request("/projects/p/files/inside.txt", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ newPath: "moved.txt" }), + }); + expect(response.status).toBe(200); + expect(readFileSync(share, "utf8")).toBe('{"ref":"inside.txt"}'); + expect((await response.json()).updatedReferences).toBe(0); + }); + + it("leave Studio's own files under .hyperframes/ reachable", async () => { + const { app, project } = fixture(); + mkdirSync(join(project, ".hyperframes", "prepared-assets", "gif"), { recursive: true }); + writeFileSync( + join(project, ".hyperframes", "studio-motion.json"), + '{"version":1,"motions":[]}', + ); + writeFileSync(join(project, ".hyperframes", "prepared-assets", "gif", "a.mp4"), "mp4"); + for (const path of ["studio-motion.json", "prepared-assets/gif/a.mp4"]) + expect((await app.request(`/projects/p/files/.hyperframes/${path}`)).status).toBe(200); + }); +}); + +describe("routes that write a path from the request", () => { + it("never plant or copy a file into .hyperframes/", async () => { + const { app, project } = fixture(); + mkdirSync(join(project, ".hyperframes")); + writeFileSync( + join(project, ".hyperframes", "agent-handoff.json"), + '{"engine":"claude","sessionId":"secret"}', + ); + const rename = await app.request("/projects/p/files/inside.txt", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ newPath: ".hyperframes/app-history.jsonl" }), + }); + expect(rename.status).toBeGreaterThanOrEqual(400); + expect(existsSync(join(project, ".hyperframes", "app-history.jsonl"))).toBe(false); + const duplicate = await app.request("/projects/p/duplicate-file", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path: ".hyperframes/agent-handoff.json" }), + }); + expect(duplicate.status).toBeGreaterThanOrEqual(400); + expect(readdirSync(join(project, ".hyperframes"))).toEqual(["agent-handoff.json"]); + const form = new FormData(); + // A file uploads accept anywhere else, so the folder is the only reason it is refused. + form.append("file", new File(["plain words"], "notes.txt")); + const upload = await app.request("/projects/p/upload?dir=.hyperframes", { + method: "POST", + body: form, + }); + expect(upload.status).toBe(403); + expect(existsSync(join(project, ".hyperframes", "notes.txt"))).toBe(false); + const elsewhere = new FormData(); + elsewhere.append("file", new File(["plain words"], "notes.txt")); + const fine = await app.request("/projects/p/upload", { method: "POST", body: elsewhere }); + expect((await fine.json()).files).toEqual(["notes.txt"]); + }); +}); diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index 1c30b50a280..8d8083e0279 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -30,6 +30,7 @@ import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js"; import { folderGone, isInHiddenOrVendorDir, + isPrivateProjectFile, isSafePath, mkdirWithinProject, pinWithinProject, @@ -802,7 +803,7 @@ function updateReferences( const rewrite = referenceRewriter(oldPath, newPath, isDirectory, projectPaths(projectDir)); let updatedCount = 0; for (const file of textFiles) { - if (!isSafePath(projectDir, file)) continue; + if (!isSafePath(projectDir, file) || isPrivateProjectFile(projectDir, file)) continue; const content = readableText(file); if (content === null) continue; @@ -2481,7 +2482,7 @@ async function processUploadedFiles( // The collision suffix chooses a different path; validate that destination // too, including dangling symlinks that existsSync treats as absent. - if (!isSafePath(projectDir, finalPath)) continue; + if (!isSafePath(projectDir, finalPath) || isPrivateProjectFile(projectDir, finalPath)) continue; const buffer = Buffer.from(await value.arrayBuffer()); const validation = validateUploadedMediaBuffer(finalName, buffer); diff --git a/packages/studio-server/src/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts index e8abd0afd9f..70b77113fe5 100644 --- a/packages/studio-server/src/routes/preview.test.ts +++ b/packages/studio-server/src/routes/preview.test.ts @@ -2281,3 +2281,34 @@ describe("preview asset byte ranges", () => { expect(res.headers.get("Content-Range")).toBe("bytes */3"); }); }); + +describe("the desktop app's private folder", () => { + it("is served by neither the asset route nor the sub-composition route", async () => { + const projectDir = createProjectDir(); + mkdirSync(join(projectDir, ".hyperframes"), { recursive: true }); + writeFileSync( + join(projectDir, ".hyperframes", "agent-handoff.json"), + '{"engine":"claude","sessionId":"secret"}', + ); + const app = new Hono(); + registerPreviewRoutes(app, createAdapter(projectDir)); + for (const route of ["preview", "preview/comp"]) { + const res = await app.request( + `http://localhost/projects/demo/${route}/.hyperframes/agent-handoff.json`, + ); + expect(res.status).toBe(404); + expect(await res.text()).not.toContain("secret"); + } + }); + it("still serves the app's request pictures", async () => { + const projectDir = createProjectDir(); + mkdirSync(join(projectDir, ".hyperframes", "requests", "1"), { recursive: true }); + writeFileSync(join(projectDir, ".hyperframes", "requests", "1", "before.png"), "png"); + const app = new Hono(); + registerPreviewRoutes(app, createAdapter(projectDir)); + const res = await app.request( + "http://localhost/projects/demo/preview/.hyperframes/requests/1/before.png", + ); + expect(res.status).toBe(200); + }); +}); diff --git a/packages/studio-server/src/routes/preview.ts b/packages/studio-server/src/routes/preview.ts index e0d7577e977..89719fa2622 100644 --- a/packages/studio-server/src/routes/preview.ts +++ b/packages/studio-server/src/routes/preview.ts @@ -18,7 +18,11 @@ import { gsapCdnDist, motionPathPluginUrl } from "@hyperframes/core/gsap-cdn"; import { findStartTags, injectTagsAtHeadStart } from "@hyperframes/core/compiler/html-document"; import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution"; import type { ResolvedProject, StudioApiAdapter } from "../types.js"; -import { isProjectRootMissing, resolveWithinProject } from "../helpers/safePath.js"; +import { + isPrivateProjectFile, + isProjectRootMissing, + resolveWithinProject, +} from "../helpers/safePath.js"; import { getMimeType } from "../helpers/mime.js"; import { buildSubCompositionHtml, hasBaseElement } from "../helpers/subComposition.js"; import { @@ -615,7 +619,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo // use resolveWithinProject because saves write their data-hf-id values. const candidate = resolve(project.dir, subPath); const file = isWithinProjectRoot(project.dir, candidate) ? candidate : null; - if (!file) { + if (!file || isPrivateProjectFile(project.dir, file)) { return c.text("not found", 404); } recordPreviewRead(project.dir, file); diff --git a/packages/studio-server/src/routes/projects.test.ts b/packages/studio-server/src/routes/projects.test.ts index 63804eb23ce..a484bc2649d 100644 --- a/packages/studio-server/src/routes/projects.test.ts +++ b/packages/studio-server/src/routes/projects.test.ts @@ -22,10 +22,9 @@ const COMPOSITION_HTML = '
{ expect(payload.compositions).not.toContain(".hyperframes/examples/preset.html"); }); - it("lists vendored dot-directory files in the file tree but hides Studio backups", async () => { + it("lists vendored dot-directory files in the file tree, but nothing under .hyperframes/ that isn't Studio's", async () => { const projectDir = createProjectDir(); + writeFileSync(join(projectDir, ".hyperframes", "app-history.jsonl"), "{}"); + writeFileSync(join(projectDir, ".hyperframes", "studio-motion.json"), "{}"); const app = new Hono(); registerProjectRoutes(app, createAdapter(projectDir)); @@ -159,9 +160,9 @@ describe("registerProjectRoutes — composition discovery (#1384)", () => { // Vendored dot-dirs stay browsable — discovery is gated, the file tree is not. expect(payload.files).toContain(".cache/examples/preset.html"); - expect(payload.files).toContain(".hyperframes/examples/preset.html"); - // Only Studio's own backup snapshots are hidden from the tree (#1366). - expect(payload.files).not.toContain(".hyperframes/backup/snapshot.html"); + expect(payload.files).toContain(".hyperframes/studio-motion.json"); + for (const hidden of ["examples/preset.html", "backup/snapshot.html", "app-history.jsonl"]) + expect(payload.files).not.toContain(`.hyperframes/${hidden}`); }); it("omits generated cache files from the project file tree", async () => { diff --git a/packages/studio-server/src/routes/projects.ts b/packages/studio-server/src/routes/projects.ts index cec4e418995..70e0af4b82c 100644 --- a/packages/studio-server/src/routes/projects.ts +++ b/packages/studio-server/src/routes/projects.ts @@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import type { Hono } from "hono"; import type { StudioApiAdapter } from "../types.js"; -import { isInHiddenOrVendorDir, walkDir } from "../helpers/safePath.js"; +import { isInHiddenOrVendorDir, isPrivateProjectPath, walkDir } from "../helpers/safePath.js"; import { isCompositionSource } from "../helpers/hfIdPersist.js"; import { resolveProjectSignature } from "../helpers/projectSignature.js"; @@ -50,7 +50,7 @@ export function registerProjectRoutes(api: Hono, adapter: StudioApiAdapter): voi api.get("/projects/:id", async (c) => { const project = await adapter.resolveProject(c.req.param("id")); if (!project) return c.json({ error: "not found" }, 404); - const files = walkDir(project.dir); + const files = walkDir(project.dir).filter((file) => !isPrivateProjectPath(file)); const compositions = await filterCompositionFiles(project.dir, files); return c.json({ id: project.id, dir: project.dir, title: project.title, files, compositions }); }); diff --git a/packages/studio-server/src/routes/render.test.ts b/packages/studio-server/src/routes/render.test.ts index e888951de17..303af447019 100644 --- a/packages/studio-server/src/routes/render.test.ts +++ b/packages/studio-server/src/routes/render.test.ts @@ -491,6 +491,68 @@ describe("GET /projects/:id/renders/file/* — path safety", () => { } }); + it("refuses the desktop app's records when renders/ is linked into .hyperframes/", async () => { + const project = mkdtempSync(join(tmpdir(), "hf-renders-project-")); + tmpDirs.push(project); + mkdirSync(join(project, ".hyperframes")); + writeFileSync(join(project, ".hyperframes", "app-history.jsonl"), '{"asked":"secret"}'); + if (!tryCreateSymlink(join(project, ".hyperframes"), join(project, "renders"), "dir")) return; + const app = new Hono(); + registerRenderRoutes(app, { + listProjects: () => [], + resolveProject: async (id: string) => ({ id, dir: project }), + bundle: async () => null, + lint: async () => ({ findings: [] }), + runtimeUrl: "/api/runtime.js", + rendersDir: () => join(project, "renders"), + startRender: (opts) => ({ + id: opts.jobId, + status: "rendering", + progress: 0, + outputPath: opts.outputPath, + }), + }); + const res = await app.request("http://localhost/projects/demo/renders/file/app-history.jsonl"); + expect(res.status).toBe(403); + expect(await res.text()).not.toContain("secret"); + }); + + it("never lists, views or downloads a render that links to the app's records", async () => { + const project = mkdtempSync(join(tmpdir(), "hf-renders-project-")); + tmpDirs.push(project); + mkdirSync(join(project, ".hyperframes")); + mkdirSync(join(project, "renders")); + writeFileSync(join(project, ".hyperframes", "app-history.jsonl"), '{"asked":"secret"}'); + writeFileSync(join(project, "renders", "real.mp4"), "render-bytes"); + const leak = join(project, "renders", "leak.mp4"); + if (!tryCreateSymlink(join(project, ".hyperframes", "app-history.jsonl"), leak, "file")) return; + const app = new Hono(); + registerRenderRoutes(app, { + listProjects: () => [], + resolveProject: async (id: string) => ({ id, dir: project }), + bundle: async () => null, + lint: async () => ({ findings: [] }), + runtimeUrl: "/api/runtime.js", + rendersDir: () => join(project, "renders"), + startRender: (opts) => ({ + id: opts.jobId, + status: "rendering", + progress: 0, + outputPath: opts.outputPath, + }), + }); + const list = (await (await app.request("http://localhost/projects/demo/renders")).json()) as { + renders: { id: string }[]; + }; + expect(list.renders.map((r) => r.id)).toEqual(["real"]); + for (const kind of ["view", "download"]) { + const res = await app.request(`http://localhost/render/leak/${kind}`); + expect(res.status).toBe(404); + expect(await res.text()).not.toContain("secret"); + expect((await app.request(`http://localhost/render/real/${kind}`)).status).toBe(200); + } + }); + it("serves a render file that lives inside rendersDir", async () => { const { app, rendersDir } = buildApp(); writeFileSync(join(rendersDir, "demo.mp4"), "render-bytes"); diff --git a/packages/studio-server/src/routes/render.ts b/packages/studio-server/src/routes/render.ts index 9e312a97fc3..7a885e48309 100644 --- a/packages/studio-server/src/routes/render.ts +++ b/packages/studio-server/src/routes/render.ts @@ -1,11 +1,16 @@ import type { Hono } from "hono"; import { streamSSE } from "hono/streaming"; -import { existsSync, readFileSync, unlinkSync, readdirSync, statSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync, unlinkSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; import type { StudioApiAdapter, RenderJobState } from "../types.js"; import { VALID_CANVAS_RESOLUTIONS, type CanvasResolution } from "@hyperframes/parsers"; import { formatRenderOutputTimestamp, parseFps } from "@hyperframes/core"; -import { folderGone, mkdirWithinProject, resolveWithinProject } from "../helpers/safePath.js"; +import { + folderGone, + isPrivateProjectFile, + mkdirWithinProject, + resolveWithinProject, +} from "../helpers/safePath.js"; import { projectDirMissing } from "../helpers/projectDirMissing.js"; import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js"; @@ -199,6 +204,14 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void }; const RENDER_EXTENSIONS = Object.keys(RENDER_MIME); + const isPlainFile = (path: string): boolean => { + try { + return lstatSync(path).isFile(); + } catch { + return false; + } + }; + function renderContentType(filePath: string): string { const ext = RENDER_EXTENSIONS.find((e) => filePath.endsWith(e)); return (ext && RENDER_MIME[ext]) ?? "video/mp4"; @@ -209,7 +222,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void api.get("/render/:jobId/view", (c) => { const { jobId } = c.req.param(); const job = renderJobs.get(jobId); - if (!job?.outputPath || !existsSync(job.outputPath)) { + if (!job?.outputPath || !isPlainFile(job.outputPath)) { return c.json({ error: "not found" }, 404); } const contentType = renderContentType(job.outputPath); @@ -230,7 +243,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void api.get("/render/:jobId/download", (c) => { const { jobId } = c.req.param(); const job = renderJobs.get(jobId); - if (!job?.outputPath || !existsSync(job.outputPath)) { + if (!job?.outputPath || !isPlainFile(job.outputPath)) { return c.json({ error: "not found" }, 404); } const contentType = renderContentType(job.outputPath); @@ -274,7 +287,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void // readFileSync still followed an in-rendersDir symlink pointing outside the // dir; resolveWithinProject canonicalizes with realpath before serving. const fp = resolveWithinProject(rendersDir, filename); - if (!fp) return c.json({ error: "forbidden" }, 403); + if (!fp || isPrivateProjectFile(project.dir, fp)) return c.json({ error: "forbidden" }, 403); if (!existsSync(fp)) return c.json({ error: "not found" }, 404); const contentType = renderContentType(fp); const content = readFileSync(fp); @@ -296,8 +309,11 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void if (!existsSync(rendersDir)) return c.json({ renders: [] }); const files = readdirSync(rendersDir) .filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov")) - .map((f) => { - const fp = join(rendersDir, f); + .flatMap((f) => { + const fp = resolveWithinProject(rendersDir, f); + return fp && !isPrivateProjectFile(project.dir, fp) ? [{ f, fp }] : []; + }) + .map(({ f, fp }) => { const stat = statSync(fp); const rid = f.replace(/\.(mp4|webm|mov)$/, ""); const metaPath = join(rendersDir, `${rid}.meta.json`); @@ -321,6 +337,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void return { id: rid, filename: f, + path: fp, size: stat.size, createdAt: stat.mtimeMs, status, @@ -337,11 +354,11 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void id: file.id, status: file.status, progress: 100, - outputPath: join(rendersDir, file.filename), + outputPath: file.path, createdAt: file.createdAt, } as RenderJobState & { createdAt: number }); } } - return c.json({ renders: files }); + return c.json({ renders: files.map(({ path: _path, ...render }) => render) }); }); }