mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(studio-server): a request that fails after its project folder vanished says the folder is gone (#4617)
* fix(studio-server): a save never recreates a project folder renamed away * style(cli): wrap imports after merging main * fix(studio-server): proxy and upload answer that the project folder is gone instead of 502 or an empty 201 * test(studio-server): rename the project folder while an upload is being read * fix(studio-server): a proxy or backup answers that a vanished project folder is gone * test(studio-server): clean up the renamed folder after the proxy test * refactor(studio-server): drop an unused re-export and share the folder-gone test setup * fix(studio-server): any request failing after its project folder vanished says the folder is gone * fix(studio-server): the folder-gone answer works on every host and drops the failed route's headers * fix(studio-server): the folder-gone 404 keeps the host's own headers * fix(core): no folder outside a project is created once the project folder is gone * test(studio-server): a rename that makes the codec probe fail answers that the folder is gone * test(studio-server): a refused render leaves no outside renders folder behind * docs(core): wrap the mkdirWithinProject comment under the line limit * refactor(studio-server): let the folder-gone middleware's names carry what its comments said * fix(studio-server): say the folder is gone when a render names a composition * fix(studio-server): check renders against the folder path and share one folder-gone answer * test(studio-server): pass the project folder to the preview document store
This commit is contained in:
@@ -281,6 +281,7 @@ export {
|
||||
} from "./inline-scripts/parityContract";
|
||||
export { redactKnownPaths, redactTelemetryString } from "./telemetryRedaction";
|
||||
export {
|
||||
folderGone,
|
||||
isProjectRootMissing,
|
||||
isSafePath,
|
||||
mkdirWithinProject,
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs";
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
symlinkSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import {
|
||||
folderGone,
|
||||
isProjectRootMissing,
|
||||
isSafePath,
|
||||
mkdirWithinProject,
|
||||
@@ -225,3 +234,32 @@ describe("mkdirWithinProject", () => {
|
||||
expect(existsSync(join(parent, "renders"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("folderGone", () => {
|
||||
it("is true only when nothing is there", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-folder-gone-"));
|
||||
writeFileSync(join(dir, "file"), "");
|
||||
try {
|
||||
expect(folderGone(dir)).toBe(false);
|
||||
expect(folderGone(join(dir, "missing"))).toBe(true);
|
||||
expect(folderGone(join(dir, "file", "below"))).toBe(true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||
"is false for a folder that cannot be looked at",
|
||||
() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-folder-gone-"));
|
||||
mkdirSync(join(dir, "film"));
|
||||
chmodSync(dir, 0o000);
|
||||
try {
|
||||
expect(folderGone(join(dir, "film"))).toBe(false);
|
||||
} finally {
|
||||
chmodSync(dir, 0o700);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { resolve, sep, join, dirname, basename, relative, isAbsolute } from "node:path";
|
||||
import { existsSync, lstatSync, mkdirSync, realpathSync } from "node:fs";
|
||||
import { lstatSync, mkdirSync, realpathSync, statSync } from "node:fs";
|
||||
|
||||
export function realpath(path: string): string {
|
||||
try {
|
||||
@@ -110,12 +110,21 @@ export class ProjectRootMissingError extends Error {
|
||||
export const isProjectRootMissing = (error: unknown): boolean =>
|
||||
error instanceof Error && error.name === "ProjectRootMissingError";
|
||||
|
||||
/** True only when nothing is at `dir` any more; a folder that cannot be looked at (EACCES, EIO) is not gone. */
|
||||
export function folderGone(dir: string): boolean {
|
||||
try {
|
||||
return !statSync(dir, { throwIfNoEntry: false });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The project folder's real path; ProjectRootMissingError when it is gone. */
|
||||
export function realProjectRoot(root: string): string {
|
||||
try {
|
||||
return realpath(root);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new ProjectRootMissingError(root);
|
||||
if (folderGone(root)) throw new ProjectRootMissingError(root);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -125,7 +134,7 @@ export function realProjectRoot(root: string): string {
|
||||
* A `dir` outside `root` is created recursively, but only while `root` exists.
|
||||
*/
|
||||
export function mkdirWithinProject(root: string, dir: string): void {
|
||||
if (!existsSync(root)) throw new ProjectRootMissingError(root);
|
||||
if (folderGone(root)) throw new ProjectRootMissingError(root);
|
||||
const inside = relative(resolve(root), resolve(dir));
|
||||
if (inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
@@ -138,7 +147,7 @@ export function mkdirWithinProject(root: string, dir: string): void {
|
||||
mkdirSync(path);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT" && !existsSync(root)) throw new ProjectRootMissingError(root);
|
||||
if (code === "ENOENT" && folderGone(root)) throw new ProjectRootMissingError(root);
|
||||
if (code !== "EEXIST") throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import { registerSelectionRoutes } from "./routes/selection.js";
|
||||
import { registerMediaRoutes } from "./routes/media.js";
|
||||
import { registerGlobalAssetRoutes } from "./routes/globalAssets.js";
|
||||
import { registerHistoryRoutes } from "./routes/history.js";
|
||||
import { projectDirMissing } from "./helpers/projectDirMissing.js";
|
||||
import { isProjectRootMissing } from "./helpers/safePath.js";
|
||||
import { replaceWithProjectDirMissing } from "./helpers/projectDirMissing.js";
|
||||
import { folderGone, isProjectRootMissing } from "./helpers/safePath.js";
|
||||
|
||||
/**
|
||||
* Create a Hono sub-app with all studio API routes.
|
||||
@@ -26,8 +26,21 @@ import { isProjectRootMissing } from "./helpers/safePath.js";
|
||||
export function createStudioApi(adapter: StudioApiAdapter): Hono {
|
||||
const api = new Hono();
|
||||
api.use(async function answerProjectDirMissingAfterErrorHandlers(c, next) {
|
||||
const hostHeaders = new Headers(c.res.headers);
|
||||
await next();
|
||||
if (isProjectRootMissing(c.error)) c.res = projectDirMissing(c);
|
||||
if (isProjectRootMissing(c.error)) replaceWithProjectDirMissing(c, hostHeaders);
|
||||
});
|
||||
api.use("/projects/:id/*", async function answerProjectDirMissingForVanishedFolder(c, next) {
|
||||
const hostHeaders = new Headers(c.res.headers);
|
||||
const dirBeforeRoute = await Promise.resolve()
|
||||
.then(() => adapter.resolveProject(c.req.param("id")))
|
||||
.then(
|
||||
(project) => project?.dir,
|
||||
() => undefined,
|
||||
);
|
||||
await next();
|
||||
if (c.res.status >= 403 && dirBeforeRoute && folderGone(dirBeforeRoute))
|
||||
replaceWithProjectDirMissing(c, hostHeaders);
|
||||
});
|
||||
|
||||
registerProjectRoutes(api, adapter);
|
||||
|
||||
@@ -174,4 +174,20 @@ describe("insertCompositionIntoSource", () => {
|
||||
expect(() => insert("invalid.html")).toThrow(/valid data-composition-duration/);
|
||||
expect(() => insert("../outside.html")).toThrow(CompositionInsertionError);
|
||||
});
|
||||
|
||||
it("says the project folder is gone rather than that the source escapes it", () => {
|
||||
const dir = project();
|
||||
rmSync(dir, { recursive: true });
|
||||
const insert = () =>
|
||||
insertCompositionIntoSource({
|
||||
projectDir: dir,
|
||||
targetPath: "index.html",
|
||||
sourcePath: "headline.html",
|
||||
parentSource: parent,
|
||||
start: 0,
|
||||
desiredTrack: 0,
|
||||
});
|
||||
|
||||
expect(insert).toThrow(expect.objectContaining({ name: "ProjectRootMissingError" }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { dirname, relative, resolve, sep } from "node:path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { isSafePath, realpath, resolveWithinProject } from "./safePath.js";
|
||||
import { ProjectRootMissingError } from "@hyperframes/core";
|
||||
import { folderGone, isSafePath, realpath, resolveWithinProject } from "./safePath.js";
|
||||
|
||||
export class CompositionInsertionError extends Error {
|
||||
constructor(
|
||||
@@ -38,6 +39,7 @@ function positiveAttribute(root: Element, ...names: string[]): number {
|
||||
|
||||
function canonicalProjectPath(projectDir: string, candidate: string | null): string {
|
||||
if (!candidate) {
|
||||
if (folderGone(projectDir)) throw new ProjectRootMissingError(projectDir);
|
||||
throw new CompositionInsertionError("Composition source escapes the project", 400);
|
||||
}
|
||||
if (!existsSync(candidate)) {
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
import type { Context } from "hono";
|
||||
|
||||
const PROJECT_DIR_MISSING = { error: "not found", why: "project_dir_missing" };
|
||||
|
||||
export const projectDirMissing = (c: {
|
||||
json: (data: { error: string; why: string }, status: 404) => Response;
|
||||
}) => c.json({ error: "not found", why: "project_dir_missing" }, 404);
|
||||
}) => c.json(PROJECT_DIR_MISSING, 404);
|
||||
|
||||
export function replaceWithProjectDirMissing(c: Context, hostHeaders: Headers): void {
|
||||
hostHeaders.delete("content-type");
|
||||
c.res = undefined;
|
||||
c.res = Response.json(PROJECT_DIR_MISSING, { status: 404, headers: hostHeaders });
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { realpath, resolveWithinProject } from "@hyperframes/core";
|
||||
// CLI, engine) can share it without a backwards dependency on studio-api.
|
||||
// Re-exported here for back-compat with existing `../helpers/safePath.js` imports.
|
||||
export {
|
||||
folderGone,
|
||||
isProjectRootMissing,
|
||||
isSafePath,
|
||||
mkdirWithinProject,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi, type TestContext } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
@@ -15,6 +16,7 @@ import { join, relative } from "node:path";
|
||||
import { registerFileRoutes } from "./files";
|
||||
import { createStudioApi } from "../createStudioApi";
|
||||
import { fileContentVersion } from "../helpers/fileVersion";
|
||||
import { ProjectRootMissingError } from "@hyperframes/core";
|
||||
import { mkdirWithinProject } from "../helpers/safePath";
|
||||
import type { StudioApiAdapter } from "../types";
|
||||
|
||||
@@ -308,6 +310,123 @@ describe("resolveProjectPath why", () => {
|
||||
await expectProjectGone(await upload(createStudioApi(adapter)), project);
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["a host that keeps resolving the project", (adapter: StudioApiAdapter) => adapter],
|
||||
[
|
||||
"a host that stops resolving a vanished project",
|
||||
(adapter: StudioApiAdapter): StudioApiAdapter => ({
|
||||
...adapter,
|
||||
resolveProject: async (id) => {
|
||||
const project = await adapter.resolveProject(id);
|
||||
return project && existsSync(project.dir) ? project : null;
|
||||
},
|
||||
}),
|
||||
],
|
||||
])("on %s", (_, host) => {
|
||||
function vanishWhileReading(
|
||||
read: "arrayBuffer" | "text",
|
||||
method: string,
|
||||
route: string,
|
||||
body: string,
|
||||
) {
|
||||
const { project, adapter } = fixture();
|
||||
const readBody = Request.prototype[read];
|
||||
vi.spyOn(Request.prototype, read).mockImplementation(function (this: Request) {
|
||||
renameSync(project, `${project}-renamed`);
|
||||
return readBody.call(this);
|
||||
});
|
||||
const response = createStudioApi(host(adapter)).request(
|
||||
`http://localhost/projects/demo/${route}`,
|
||||
{
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", "If-Match": fileContentVersion("inside") },
|
||||
body,
|
||||
},
|
||||
);
|
||||
return { project, response };
|
||||
}
|
||||
|
||||
it.each([
|
||||
["a save", "arrayBuffer", "PUT", "files/inside.txt", "new"],
|
||||
["a rename", "text", "PATCH", "files/inside.txt", JSON.stringify({ newPath: "moved.txt" })],
|
||||
["a duplicate", "text", "POST", "duplicate-file", JSON.stringify({ path: "inside.txt" })],
|
||||
[
|
||||
"a render that names a composition",
|
||||
"text",
|
||||
"POST",
|
||||
"render",
|
||||
JSON.stringify({ composition: "index.html" }),
|
||||
],
|
||||
] as const)(
|
||||
"answers that the project folder is gone when it vanishes while %s reads its body",
|
||||
async (_, read, method, route, body) => {
|
||||
const { project, response } = vanishWhileReading(read, method, route, body);
|
||||
await expectProjectGone(await response, project);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a malformed request's 400 when the folder vanishes", async () => {
|
||||
const { response } = vanishWhileReading("text", "PATCH", "files/inside.txt", "{}");
|
||||
expect((await response).status).toBe(400);
|
||||
});
|
||||
|
||||
it("keeps a save conflict's 409 while the folder is there", async () => {
|
||||
const { adapter } = fixture();
|
||||
const response = await createStudioApi(host(adapter)).request(fileUrl("inside.txt"), {
|
||||
method: "PUT",
|
||||
headers: { "If-Match": fileContentVersion("stale") },
|
||||
body: "new",
|
||||
});
|
||||
expect(response.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||
"keeps a route's error when the project folder is there but cannot be looked at",
|
||||
async () => {
|
||||
const { project, adapter } = fixture();
|
||||
const api = createStudioApi(adapter);
|
||||
api.get("/projects/:id/broken", () => {
|
||||
throw new Error("read failed");
|
||||
});
|
||||
chmodSync(join(project, ".."), 0o000);
|
||||
try {
|
||||
expect((await api.request("http://localhost/projects/demo/broken")).status).toBe(500);
|
||||
} finally {
|
||||
chmodSync(join(project, ".."), 0o700);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["an error after the folder vanished", () => new Error("read failed"), true],
|
||||
["the missing-folder error", () => new ProjectRootMissingError("gone"), false],
|
||||
])(
|
||||
"keeps the host's headers but not the route's on the 404 when a route throws %s",
|
||||
async (_, error, removeFolder) => {
|
||||
const { project, adapter } = fixture();
|
||||
const api = createStudioApi(adapter);
|
||||
api.get("/projects/:id/cached", (c) => {
|
||||
c.header("ETag", '"thumb"');
|
||||
throw error();
|
||||
});
|
||||
const host = new Hono();
|
||||
host.use(async (c, next) => {
|
||||
c.header("Access-Control-Allow-Origin", "*");
|
||||
await next();
|
||||
});
|
||||
host.route("/api", api);
|
||||
if (removeFolder) rmSync(project, { recursive: true, force: true });
|
||||
|
||||
const response = await host.request("http://localhost/api/projects/demo/cached");
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toMatchObject({ why: "project_dir_missing" });
|
||||
expect(response.headers.get("ETag")).toBeNull();
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
},
|
||||
);
|
||||
|
||||
it("does not start a render into an outside folder once the project folder is gone", async () => {
|
||||
const { project, adapter } = fixture();
|
||||
const startRender = vi.fn(adapter.startRender);
|
||||
@@ -324,6 +443,30 @@ describe("resolveProjectPath why", () => {
|
||||
expect(existsSync(adapter.rendersDir({ id: "demo", dir: project }))).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a render composition that leaves a symlinked project folder through ..", async (context) => {
|
||||
const { project, adapter } = fixture();
|
||||
const root = join(project, "..");
|
||||
mkdirSync(join(root, "data"));
|
||||
renameSync(project, join(root, "data", "project"));
|
||||
mkdirSync(join(root, "home", "project"), { recursive: true });
|
||||
writeFileSync(join(root, "home", "project", "secret.html"), "<html></html>");
|
||||
linkOrSkip(context, join(root, "data", "project"), join(root, "home", "link"), "dir");
|
||||
const startRender = vi.fn(adapter.startRender);
|
||||
const api = createStudioApi({
|
||||
...adapter,
|
||||
resolveProject: async (id) => ({ id, dir: join(root, "home", "link") }),
|
||||
startRender,
|
||||
});
|
||||
|
||||
const response = await api.request("http://localhost/projects/demo/render", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ composition: "../project/secret.html" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(startRender).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a missing project directory as 404, not 403", async () => {
|
||||
const { app, project } = fixture();
|
||||
rmSync(project, { recursive: true, force: true });
|
||||
|
||||
@@ -27,6 +27,7 @@ import { replaceFileAtomically } from "../helpers/atomicFile.js";
|
||||
import { generateWaveformCache } from "../helpers/waveform.js";
|
||||
import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js";
|
||||
import {
|
||||
folderGone,
|
||||
isSafePath,
|
||||
mkdirWithinProject,
|
||||
pinWithinProject,
|
||||
@@ -197,7 +198,7 @@ async function resolveProjectPath(
|
||||
// `realpathSync(base)` throws when the base itself is gone) and reported as
|
||||
// `403 forbidden` — indistinguishable from a real path-traversal attempt.
|
||||
// Checked here, once, so every route built on this shares the fix.
|
||||
if (!existsSync(project.dir)) {
|
||||
if (folderGone(project.dir)) {
|
||||
return { error: projectDirMissing(c) } as const;
|
||||
}
|
||||
|
||||
@@ -3252,7 +3253,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
if (!existsSync(project.dir)) return projectDirMissing(c);
|
||||
if (folderGone(project.dir)) return projectDirMissing(c);
|
||||
|
||||
// Optional subdirectory within the project (e.g. "assets/audio")
|
||||
const subDir = c.req.query("dir") ?? "";
|
||||
@@ -3262,7 +3263,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
const formData = await c.req.formData();
|
||||
mkdirWithinProject(project.dir, targetDir);
|
||||
const result = await processUploadedFiles(formData, targetDir, project.dir);
|
||||
if (!existsSync(project.dir)) return projectDirMissing(c);
|
||||
if (folderGone(project.dir)) return projectDirMissing(c);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
|
||||
@@ -1798,6 +1798,31 @@ describe("hf-proxy codec probe", () => {
|
||||
expect(await response.json()).toMatchObject({ why: "project_dir_missing" });
|
||||
});
|
||||
|
||||
it("answers that the project folder is gone when a rename makes the codec probe fail", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes");
|
||||
vi.resetModules();
|
||||
vi.doMock("../helpers/mediaMetadata.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../helpers/mediaMetadata.js")>(
|
||||
"../helpers/mediaMetadata.js",
|
||||
)),
|
||||
probeMediaMetadata: async () => {
|
||||
tempDirs.push(`${projectDir}-renamed`);
|
||||
renameSync(projectDir, `${projectDir}-renamed`);
|
||||
return { kind: "video" as const, color: {}, probeError: "ffprobe failed" };
|
||||
},
|
||||
}));
|
||||
const { createStudioApi: create } = await import("../createStudioApi.js");
|
||||
const api = create(createAdapter(projectDir));
|
||||
|
||||
const response = await api.request(
|
||||
"http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264",
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toMatchObject({ why: "project_dir_missing" });
|
||||
});
|
||||
|
||||
it("runs ffprobe once for repeated proxy requests of the same unchanged clip", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes");
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import type { Hono } from "hono";
|
||||
import type { StudioApiAdapter } from "../types.js";
|
||||
import { projectDirMissing } from "../helpers/projectDirMissing.js";
|
||||
import { isProjectRootMissing } from "../helpers/safePath.js";
|
||||
import { folderGone, isProjectRootMissing } from "../helpers/safePath.js";
|
||||
|
||||
export function registerRegistryRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
api.get("/registry/blocks", async (c) => {
|
||||
@@ -20,7 +19,7 @@ export function registerRegistryRoutes(api: Hono, adapter: StudioApiAdapter): vo
|
||||
}
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "Project not found" }, 404);
|
||||
if (!existsSync(project.dir)) return projectDirMissing(c);
|
||||
if (folderGone(project.dir)) return projectDirMissing(c);
|
||||
|
||||
const body = await c.req.json<{ blockName?: string }>().catch(() => null);
|
||||
if (!body?.blockName) {
|
||||
|
||||
@@ -5,7 +5,8 @@ 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 { mkdirWithinProject, resolveWithinProject } from "../helpers/safePath.js";
|
||||
import { folderGone, mkdirWithinProject, resolveWithinProject } from "../helpers/safePath.js";
|
||||
import { projectDirMissing } from "../helpers/projectDirMissing.js";
|
||||
import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js";
|
||||
|
||||
const VALID_RESOLUTIONS = new Set<string>(VALID_CANVAS_RESOLUTIONS);
|
||||
@@ -105,6 +106,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
|
||||
// resolveWithinProject dereferences symlinks, so an in-project symlink
|
||||
// pointing outside the root can't smuggle the render target out.
|
||||
if (!resolveWithinProject(project.dir, body.composition)) {
|
||||
if (folderGone(project.dir)) return projectDirMissing(c);
|
||||
return c.json({ error: "composition path must be within the project directory" }, 400);
|
||||
}
|
||||
composition = body.composition;
|
||||
|
||||
Reference in New Issue
Block a user