fix(studio-server): keep the desktop app's hand-off and chat history out of the file routes (#5113)

* fix(studio-server): keep the desktop app's hand-off and chat history out of the file routes

The file and preview routes reach any path inside the project. The desktop
app keeps two files there that hold the person's agent session and their
chat: .hyperframes/agent-handoff.json and .hyperframes/app-history.jsonl.
No route now reads, writes, renames onto, duplicates or uploads over them,
by any spelling or through a link. The rest of .hyperframes/ (Studio's
motion file, prepared GIF assets) is served as before.

* fix(studio-server): keep the desktop app's run log out of the file routes too

The app records every chat run, the person's words included, in
.hyperframes/agent-runs.jsonl. Only the app's main process reads it.

* fix(studio-server): refuse everything under .hyperframes/ but Studio's own files

Review (Jerrai, Rames): a list of three names missed the read marks the
desktop app and the CLI also keep there, and renaming the folder reached
them under a new name. The check is now an allow-list: no route reaches
.hyperframes/ or anything in it, by any spelling or through a link, except
Studio's studio-motion.json, studio-manual-edits.json and prepared-assets/.
Studio's own internal files (backups, preview documents, history id) are
read by path inside the server, not through a request path.

The project's file tree leaves out the same paths. The upload test now
sends a file uploads accept elsewhere, so the folder is the only reason
it is refused.

* fix(studio-server): serve the app's request pictures, close the renders and rename paths

Review (Rames, Jerrai):
- Desktop loads each request's picture from .hyperframes/requests/ through
  the preview route, so that folder joins the allow-list. An allowed
  folder's own path is allowed too.
- The render-file route resolves against renders/, so a renders/ linked
  into .hyperframes/ reached the records. It now also checks the file
  against the project.
- A rename's reference update walked every JSON/MD file, records
  included, and could rewrite them. It now skips private paths.
- The allow-list reuses STUDIO_MOTION_PATH and STUDIO_MANUAL_EDITS_PATH.

* chore(studio-server): keep render.ts's comment share at the base

* fix(studio-server): list, view and download only renders the render-file route would serve

Review (Jerrai, Rames): the render list found files in renders/ with join
and statSync, which follow links, and registered them for /render/:id/view
and /download, which read them with no check. A project shipping
renders/leak.mp4 -> ../.hyperframes/app-history.jsonl had its chat served.
The list now keeps an entry only when resolveWithinProject accepts it and
it isn't private, and registers the resolved path. View and download serve
only a plain file, so a link swapped in after the list is refused. This
predates the PR; it's the same shipped-link case.
This commit is contained in:
WaterrrForever
2026-10-06 13:20:02 +00:00
committed by GitHub
parent f80614cde4
commit 32516b5a3d
10 changed files with 313 additions and 26 deletions
@@ -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<string, string>() }));
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);
});
});
+31 -2
View File
@@ -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.
@@ -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"]);
});
});
+3 -2
View File
@@ -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);
@@ -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);
});
});
+6 -2
View File
@@ -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);
@@ -22,10 +22,9 @@ const COMPOSITION_HTML = '<html><body><div data-composition-id="main"></div></bo
// - .cache/examples/ a vendored dot-dir. walkDir does NOT special-case
// it, so it stays listed in the file tree, but it is
// gated out of composition discovery (isInHiddenOrVendorDir).
// - .hyperframes/examples/ vendored under Studio's dir — also listed in the
// file tree, also gated out of discovery.
// - .hyperframes/backup/ Studio's internal snapshots — the only thing hidden
// from the file tree (walkDir's shouldIgnoreDir).
// - .hyperframes/examples/ under the app's folder — gated out of discovery,
// and hidden from the tree like every non-Studio record.
// - .hyperframes/backup/ Studio's internal snapshots — hidden from the tree.
function createProjectDir(): string {
const projectDir = mkdtempSync(join(tmpdir(), "hf-projects-test-"));
tempDirs.push(projectDir);
@@ -149,8 +148,10 @@ describe("registerProjectRoutes — composition discovery (#1384)", () => {
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 () => {
@@ -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 });
});
@@ -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");
+26 -9
View File
@@ -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) });
});
}