mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(studio-server): a save never recreates a project folder renamed away (#4602)
* 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(core): no folder outside a project is created once the project 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 * test(studio-server): pass the project folder to the preview document store
This commit is contained in:
@@ -14,7 +14,12 @@ export const examples: Example[] = [
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { resolve, relative } from "node:path";
|
||||
import { ITEM_TYPE_DIRS, realpath, type RegistryItem } from "@hyperframes/core";
|
||||
import {
|
||||
isProjectRootMissing,
|
||||
ITEM_TYPE_DIRS,
|
||||
realpath,
|
||||
type RegistryItem,
|
||||
} from "@hyperframes/core";
|
||||
import { c } from "../ui/colors.js";
|
||||
import {
|
||||
DEFAULT_REGISTRY_URL,
|
||||
@@ -273,6 +278,7 @@ async function installAll(
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isProjectRootMissing(err)) throw err;
|
||||
if (err instanceof InvalidVariableValuesError) throw new AddError(err.message, "invalid-vars");
|
||||
throw new AddError(describeInstallFailure(err, baseUrl), "install-failed");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
@@ -70,6 +71,17 @@ it("atomically replaces a hard-linked leaf without altering its other name", ()
|
||||
}
|
||||
});
|
||||
|
||||
it("does not recreate a project folder renamed away before an install", () => {
|
||||
const parent = mkdtempSync(join(tmpdir(), "hf-publish-gone-"));
|
||||
try {
|
||||
const project = join(parent, "film");
|
||||
expect(() => registryRoot(project)).toThrow(/Project folder not found/);
|
||||
expect(existsSync(project)).toBe(false);
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes through an internal leaf alias to its physical target", () => {
|
||||
const root = registryRoot(mkdtempSync(join(tmpdir(), "hf-publish-")));
|
||||
try {
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
import {
|
||||
lstatSync,
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { lstatSync, chmodSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import { realpath } from "@hyperframes/core";
|
||||
import { mkdirWithinProject, realpath } from "@hyperframes/core";
|
||||
|
||||
export function registryRoot(directory: string): string {
|
||||
mkdirSync(directory, { recursive: true });
|
||||
return realpath(directory);
|
||||
}
|
||||
export { realProjectRoot as registryRoot } from "@hyperframes/core";
|
||||
|
||||
export function registryTargetPath(root: string, target: string): string {
|
||||
const parts = target.split(/[\\/]/);
|
||||
@@ -53,7 +42,7 @@ export function publishRegistryFile(
|
||||
bytes: Uint8Array | string,
|
||||
): string {
|
||||
const path = registryTargetPath(root, target);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
mkdirWithinProject(root, dirname(path));
|
||||
const parent = realpath(dirname(path));
|
||||
assertContained(root, parent);
|
||||
const destination = resolve(parent, basename(path));
|
||||
|
||||
@@ -18,6 +18,7 @@ import { validRegistryName, validRegistryManifest, validRegistryItem } from "./v
|
||||
*/
|
||||
|
||||
import { join, basename } from "node:path";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import {
|
||||
ITEM_TYPE_DIRS,
|
||||
@@ -88,6 +89,7 @@ function isFresh<T>(entry: CacheEntry<T>): boolean {
|
||||
|
||||
function writeCache<T>(path: string, data: T): void {
|
||||
try {
|
||||
mkdirSync(CACHE_DIR, { recursive: true });
|
||||
const root = registryRoot(CACHE_DIR);
|
||||
const entry: CacheEntry<T> = { fetchedAt: Date.now(), data };
|
||||
publishRegistryFile(root, basename(path), JSON.stringify(entry));
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
@@ -61,7 +62,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
/** A project behind a symlink whose hyperframes.json points at a stubbed registry; `fetched` logs every URL. */
|
||||
function projectWithRegistry(): {
|
||||
function projectWithRegistry(onItemFetch?: () => void): {
|
||||
link: string;
|
||||
real: string;
|
||||
registry: string;
|
||||
@@ -84,7 +85,10 @@ function projectWithRegistry(): {
|
||||
);
|
||||
}
|
||||
const item = ITEMS.find((candidate) => url.includes(`/${candidate.name}/`));
|
||||
if (item && url.endsWith("/registry-item.json")) return new Response(JSON.stringify(item));
|
||||
if (item && url.endsWith("/registry-item.json")) {
|
||||
onItemFetch?.();
|
||||
return new Response(JSON.stringify(item));
|
||||
}
|
||||
if (item && url.endsWith(".html")) {
|
||||
return new Response(
|
||||
`<meta name="viewport" content="width=1080, height=1350"><div data-composition-id="${item.name}"></div>`,
|
||||
@@ -117,6 +121,36 @@ function installer(link: string) {
|
||||
}
|
||||
|
||||
describe("Studio catalog install", () => {
|
||||
it("answers that the project folder is gone when it is renamed while the item downloads", async () => {
|
||||
let real = "";
|
||||
({ real } = projectWithRegistry(() => renameSync(real, `${real}-renamed`)));
|
||||
|
||||
const response = await server!.app.request("/api/projects/link/registry/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ blockName: "studio-drop-block" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toMatchObject({ why: "project_dir_missing" });
|
||||
expect(existsSync(real)).toBe(false);
|
||||
});
|
||||
|
||||
it("answers that the project folder is gone, and does not recreate it, after a rename", async () => {
|
||||
const { real } = projectWithRegistry();
|
||||
renameSync(real, `${real}-renamed`);
|
||||
|
||||
const response = await server!.app.request("/api/projects/link/registry/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ blockName: "studio-drop-block" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toMatchObject({ why: "project_dir_missing" });
|
||||
expect(existsSync(real)).toBe(false);
|
||||
});
|
||||
|
||||
it("installs through add: honours the project's block folder and records the item", async () => {
|
||||
const { link, real } = projectWithRegistry();
|
||||
|
||||
|
||||
@@ -466,7 +466,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
|
||||
// Salted with the running CLI file, so an upgraded CLI never serves an older build's document.
|
||||
previewDocuments: createPreviewDocumentStore(
|
||||
join(projectDir, ".hyperframes", "preview"),
|
||||
projectDir,
|
||||
createHash("sha256")
|
||||
.update(readFileSync(fileURLToPath(import.meta.url)))
|
||||
.digest("hex"),
|
||||
|
||||
@@ -280,7 +280,15 @@ export {
|
||||
type MediaVisualStyleProperty,
|
||||
} from "./inline-scripts/parityContract";
|
||||
export { redactKnownPaths, redactTelemetryString } from "./telemetryRedaction";
|
||||
export { isSafePath, realpath, resolveWithinProject } from "./safePath";
|
||||
export {
|
||||
isProjectRootMissing,
|
||||
isSafePath,
|
||||
mkdirWithinProject,
|
||||
ProjectRootMissingError,
|
||||
realpath,
|
||||
realProjectRoot,
|
||||
resolveWithinProject,
|
||||
} from "./safePath";
|
||||
export { isHyperframesProject, PROJECT_MARKER_FILES } from "./projectRule";
|
||||
export type {
|
||||
HyperframePickerApi,
|
||||
|
||||
@@ -2,7 +2,12 @@ import { describe, it, expect, afterEach } from "vitest";
|
||||
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { isSafePath, resolveWithinProject } from "./safePath.js";
|
||||
import {
|
||||
isProjectRootMissing,
|
||||
isSafePath,
|
||||
mkdirWithinProject,
|
||||
resolveWithinProject,
|
||||
} from "./safePath.js";
|
||||
|
||||
const recased = (path: string) => join(dirname(path), basename(path).toUpperCase());
|
||||
|
||||
@@ -174,3 +179,49 @@ describe("resolveWithinProject", () => {
|
||||
expect(resolveWithinProject(base, "link/secret.txt")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mkdirWithinProject", () => {
|
||||
const made: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of made.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
const tempDir = () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-mkdir-within-"));
|
||||
made.push(dir);
|
||||
return dir;
|
||||
};
|
||||
|
||||
it("creates the missing folders below a project folder that exists", () => {
|
||||
const root = tempDir();
|
||||
mkdirWithinProject(root, join(root, ".hyperframes", "backup"));
|
||||
expect(existsSync(join(root, ".hyperframes", "backup"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not bring back a project folder that was renamed away", () => {
|
||||
const parent = tempDir();
|
||||
const root = join(parent, "film");
|
||||
let thrown: unknown;
|
||||
try {
|
||||
mkdirWithinProject(root, join(root, "assets", "audio"));
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
expect(isProjectRootMissing(thrown)).toBe(true);
|
||||
expect(existsSync(root)).toBe(false);
|
||||
});
|
||||
|
||||
it("creates a folder outside the project as before", () => {
|
||||
const parent = tempDir();
|
||||
mkdirSync(join(parent, "film"));
|
||||
mkdirWithinProject(join(parent, "film"), join(parent, "cache", "renders"));
|
||||
expect(existsSync(join(parent, "cache", "renders"))).toBe(true);
|
||||
});
|
||||
|
||||
it("creates no folder outside the project once the project folder is gone", () => {
|
||||
const parent = tempDir();
|
||||
expect(() => mkdirWithinProject(join(parent, "film"), join(parent, "renders"))).toThrow(
|
||||
/Project folder not found/,
|
||||
);
|
||||
expect(existsSync(join(parent, "renders"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { resolve, sep, join, dirname, basename } from "node:path";
|
||||
import { lstatSync, realpathSync } from "node:fs";
|
||||
import { resolve, sep, join, dirname, basename, relative, isAbsolute } from "node:path";
|
||||
import { existsSync, lstatSync, mkdirSync, realpathSync } from "node:fs";
|
||||
|
||||
export function realpath(path: string): string {
|
||||
try {
|
||||
@@ -97,3 +97,49 @@ export function resolveWithinProject(base: string, relativePath: string): string
|
||||
const resolved = resolve(base, relativePath);
|
||||
return isSafePath(base, resolved) ? resolved : null;
|
||||
}
|
||||
|
||||
/** The project folder is gone, renamed or deleted while open; a write must not bring it back. */
|
||||
export class ProjectRootMissingError extends Error {
|
||||
constructor(readonly root: string) {
|
||||
super(`Project folder not found: ${root}`);
|
||||
this.name = "ProjectRootMissingError";
|
||||
}
|
||||
}
|
||||
|
||||
// By name, so a copy of this module bundled into another package still matches.
|
||||
export const isProjectRootMissing = (error: unknown): boolean =>
|
||||
error instanceof Error && error.name === "ProjectRootMissingError";
|
||||
|
||||
/** 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);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates `dir` below `root` one folder at a time, so a root moved away fails instead of reappearing.
|
||||
* 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);
|
||||
const inside = relative(resolve(root), resolve(dir));
|
||||
if (inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return;
|
||||
}
|
||||
let path = resolve(root);
|
||||
for (const part of inside.split(sep).filter(Boolean)) {
|
||||
path = join(path, part);
|
||||
try {
|
||||
mkdirSync(path);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT" && !existsSync(root)) throw new ProjectRootMissingError(root);
|
||||
if (code !== "EEXIST") throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +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";
|
||||
|
||||
/**
|
||||
* Create a Hono sub-app with all studio API routes.
|
||||
@@ -23,6 +25,10 @@ import { registerHistoryRoutes } from "./routes/history.js";
|
||||
*/
|
||||
export function createStudioApi(adapter: StudioApiAdapter): Hono {
|
||||
const api = new Hono();
|
||||
api.use(async function answerProjectDirMissingAfterErrorHandlers(c, next) {
|
||||
await next();
|
||||
if (isProjectRootMissing(c.error)) c.res = projectDirMissing(c);
|
||||
});
|
||||
|
||||
registerProjectRoutes(api, adapter);
|
||||
registerFileRoutes(api, adapter);
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ProjectRootMissingError } from "@hyperframes/core";
|
||||
import { backupPathForResponse, snapshotBeforeWrite } from "./backupJournal";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -86,3 +87,26 @@ describe("snapshotBeforeWrite", () => {
|
||||
).toEqual(["space", "underscore"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapshotBeforeWrite when the project folder is gone", () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock("./safePath.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("lets the missing-folder error through instead of reporting a failed backup", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
const file = join(projectDir, "index.html");
|
||||
writeFileSync(file, "before");
|
||||
vi.resetModules();
|
||||
vi.doMock("./safePath.js", async () => ({
|
||||
...(await vi.importActual<typeof import("./safePath.js")>("./safePath.js")),
|
||||
mkdirWithinProject: (root: string) => {
|
||||
throw new ProjectRootMissingError(root);
|
||||
},
|
||||
}));
|
||||
const { snapshotBeforeWrite: snapshot } = await import("./backupJournal");
|
||||
|
||||
expect(() => snapshot(projectDir, file)).toThrow(/Project folder not found/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { join, relative } from "node:path";
|
||||
import { isSafePath } from "./safePath.js";
|
||||
import { isProjectRootMissing, isSafePath, mkdirWithinProject } from "./safePath.js";
|
||||
|
||||
const DEFAULT_KEEP_PER_FILE = 10;
|
||||
|
||||
@@ -40,7 +40,7 @@ export function snapshotBeforeWrite(
|
||||
|
||||
const relativePath = relative(projectDir, absPath);
|
||||
const backupDir = join(projectDir, ".hyperframes", "backup");
|
||||
mkdirSync(backupDir, { recursive: true });
|
||||
mkdirWithinProject(projectDir, backupDir);
|
||||
|
||||
const backupKey = backupKeyForPath(relativePath);
|
||||
const backupPath = nextBackupPath(backupDir, backupKey);
|
||||
@@ -48,6 +48,7 @@ export function snapshotBeforeWrite(
|
||||
pruneBackups(backupDir, backupKey, options.keepPerFile ?? DEFAULT_KEEP_PER_FILE);
|
||||
return { backupPath };
|
||||
} catch (error) {
|
||||
if (isProjectRootMissing(error)) throw error;
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
closeSync,
|
||||
constants,
|
||||
fstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { closeSync, constants, fstatSync, openSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { replaceFileAtomically } from "./atomicFile.js";
|
||||
import { isInHiddenOrVendorDir, walkDir } from "./safePath.js";
|
||||
import { isInHiddenOrVendorDir, mkdirWithinProject, walkDir } from "./safePath.js";
|
||||
|
||||
export const isCompositionSource = (html: string): boolean => /data-composition-id\s*=/.test(html);
|
||||
|
||||
@@ -49,7 +41,7 @@ export function stampProjectHfIds(projectDir: string): void {
|
||||
next[file] = hash;
|
||||
}
|
||||
try {
|
||||
mkdirSync(dirname(recordPath), { recursive: true });
|
||||
mkdirWithinProject(projectDir, dirname(recordPath));
|
||||
writeFileSync(recordPath, JSON.stringify(next));
|
||||
} catch {
|
||||
// read-only project: the next start parses again
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { createPreviewDocumentStore } from "./previewDocumentStore";
|
||||
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("stores a built document inside the project", () => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-preview-store-"));
|
||||
dirs.push(projectDir);
|
||||
const store = createPreviewDocumentStore(projectDir, "salt");
|
||||
store.write("key", "<html></html>");
|
||||
expect(store.read("key")).toBe("<html></html>");
|
||||
});
|
||||
|
||||
it("does not recreate a project folder renamed away while its preview is open", () => {
|
||||
const parent = mkdtempSync(join(tmpdir(), "hf-preview-store-"));
|
||||
dirs.push(parent);
|
||||
const projectDir = join(parent, "film");
|
||||
const store = createPreviewDocumentStore(projectDir, "salt");
|
||||
store.write("key", "<html></html>");
|
||||
expect(existsSync(projectDir)).toBe(false);
|
||||
});
|
||||
@@ -1,12 +1,14 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { replaceFileAtomically } from "./atomicFile.js";
|
||||
import { mkdirWithinProject } from "./safePath.js";
|
||||
import type { PreviewDocumentStore } from "./mediaProxyPreview.js";
|
||||
|
||||
/** One built preview document on disk. `salt` names the server build, so new code never reads
|
||||
* a document an older build wrote. A failed read or write only costs a rebuild. */
|
||||
export function createPreviewDocumentStore(dir: string, salt: string): PreviewDocumentStore {
|
||||
/** One built preview document under the project's `.hyperframes/preview`. `salt` names the server build, so new
|
||||
* code never reads a document an older build wrote. A failed read or write only costs a rebuild. */
|
||||
export function createPreviewDocumentStore(projectDir: string, salt: string): PreviewDocumentStore {
|
||||
const dir = join(projectDir, ".hyperframes", "preview");
|
||||
const fileFor = (key: string) =>
|
||||
join(dir, `${createHash("sha256").update(`${salt}\n${key}`).digest("hex").slice(0, 32)}.html`);
|
||||
return {
|
||||
@@ -19,7 +21,7 @@ export function createPreviewDocumentStore(dir: string, salt: string): PreviewDo
|
||||
},
|
||||
write(key, html) {
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
mkdirWithinProject(projectDir, dir);
|
||||
const file = fileFor(key);
|
||||
replaceFileAtomically(file, html, 0o644);
|
||||
for (const name of readdirSync(dir)) {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const projectDirMissing = (c: {
|
||||
json: (data: { error: string; why: string }, status: 404) => Response;
|
||||
}) => c.json({ error: "not found", why: "project_dir_missing" }, 404);
|
||||
@@ -1,12 +1,12 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, renameSync, statSync, unlinkSync, utimesSync } from "node:fs";
|
||||
import { existsSync, renameSync, statSync, unlinkSync, utimesSync } from "node:fs";
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
|
||||
import { probeMediaMetadata } from "./mediaMetadata.js";
|
||||
import { cleanupProxyCache } from "./proxyCache.js";
|
||||
import { PROXY_VARIANT_CONFIG, type ProxyVariant } from "./mediaCodecMap.js";
|
||||
import { realpath } from "./safePath.js";
|
||||
import { mkdirWithinProject, realpath, realProjectRoot } from "./safePath.js";
|
||||
|
||||
/**
|
||||
* Transcodes browser-hostile local video sources (HEVC, ProRes, ...) into a
|
||||
@@ -151,7 +151,7 @@ function canonicalizeProxySource(
|
||||
throw new ProxySourceOutsideProjectError();
|
||||
}
|
||||
|
||||
const canonicalProjectDir = realpath(projectDir);
|
||||
const canonicalProjectDir = realProjectRoot(projectDir);
|
||||
const canonicalSourcePath = realpath(absoluteSourcePath);
|
||||
const canonicalRelativePath = relative(canonicalProjectDir, canonicalSourcePath);
|
||||
const sourceIsInsideCanonicalProject =
|
||||
@@ -433,6 +433,7 @@ async function runFfmpeg(
|
||||
}
|
||||
|
||||
async function transcodeToCache(
|
||||
projectDir: string,
|
||||
absoluteSourcePath: string,
|
||||
cachePath: string,
|
||||
variant: ProxyVariant,
|
||||
@@ -443,7 +444,7 @@ async function transcodeToCache(
|
||||
if (existsSync(cachePath)) return cachePath;
|
||||
|
||||
const cacheDir = dirname(cachePath);
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
mkdirWithinProject(projectDir, cacheDir);
|
||||
const tempPath = join(cacheDir, `.tmp-${randomUUID()}-${basename(cachePath)}`);
|
||||
try {
|
||||
await runFfmpeg(absoluteSourcePath, tempPath, variant);
|
||||
@@ -490,7 +491,7 @@ export async function resolveProxy(
|
||||
const existing = inFlight.get(cachePath);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = transcodeToCache(source.sourcePath, cachePath, variant)
|
||||
const promise = transcodeToCache(source.projectDir, source.sourcePath, cachePath, variant)
|
||||
.catch((err: unknown) => {
|
||||
if (
|
||||
err instanceof ProxyTranscodeError &&
|
||||
|
||||
@@ -5,7 +5,14 @@ import { realpath, resolveWithinProject } from "@hyperframes/core";
|
||||
// `isSafePath` lives at the package root so non-studio-api layers (compiler,
|
||||
// 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 { isSafePath, realpath, resolveWithinProject } from "@hyperframes/core";
|
||||
export {
|
||||
isProjectRootMissing,
|
||||
isSafePath,
|
||||
mkdirWithinProject,
|
||||
realpath,
|
||||
realProjectRoot,
|
||||
resolveWithinProject,
|
||||
} from "@hyperframes/core";
|
||||
|
||||
/** 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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { replaceFileAtomically } from "../helpers/atomicFile.js";
|
||||
import { mkdirWithinProject } from "../helpers/safePath.js";
|
||||
|
||||
export const ID_PATH = join(".hyperframes", "history-id");
|
||||
/** The only shape minted here; the id is project content and becomes a path, so nothing else is trusted. */
|
||||
@@ -69,7 +70,7 @@ export function projectHistoryId(projectDir: string, historyRoot: string): strin
|
||||
(existsSync(join(historyRoot, id)) && !isRecordedFolder(join(historyRoot, id), folder))
|
||||
) {
|
||||
id = randomUUID();
|
||||
mkdirSync(join(dir, ".hyperframes"), { recursive: true });
|
||||
mkdirWithinProject(dir, join(dir, ".hyperframes"));
|
||||
writeFileSync(join(dir, ID_PATH), `${id}\n`);
|
||||
}
|
||||
recordProject(join(historyRoot, id), dir, folder);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
@@ -12,7 +13,9 @@ import {
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, relative } from "node:path";
|
||||
import { registerFileRoutes } from "./files";
|
||||
import { createStudioApi } from "../createStudioApi";
|
||||
import { fileContentVersion } from "../helpers/fileVersion";
|
||||
import { mkdirWithinProject } from "../helpers/safePath";
|
||||
import type { StudioApiAdapter } from "../types";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -41,7 +44,7 @@ function fixture() {
|
||||
};
|
||||
const app = new Hono();
|
||||
registerFileRoutes(app, adapter);
|
||||
return { app, project, outside };
|
||||
return { app, project, outside, adapter };
|
||||
}
|
||||
|
||||
function linkOrSkip(context: TestContext, target: string, link: string, type: "file" | "dir") {
|
||||
@@ -72,6 +75,12 @@ function upload(app: Hono, dir = "", filename = "upload.txt") {
|
||||
});
|
||||
}
|
||||
|
||||
async function expectProjectGone(response: Response, project: string) {
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toMatchObject({ why: "project_dir_missing" });
|
||||
expect(existsSync(project)).toBe(false);
|
||||
}
|
||||
|
||||
describe("file route containment", () => {
|
||||
it("writes the file a link resolves to when its target climbs out of a linked folder", async (context) => {
|
||||
const { app, project } = fixture();
|
||||
@@ -250,6 +259,71 @@ describe("resolveProjectPath why", () => {
|
||||
// fails closed when its base doesn't exist), indistinguishable from a real
|
||||
// path-traversal attempt. This must be a 404 with its own `why`, checked
|
||||
// BEFORE the NUL/traversal checks so it wins when both are true.
|
||||
it("does not recreate a project folder renamed away while Studio has it open", async () => {
|
||||
const { project, adapter } = fixture();
|
||||
const api = createStudioApi({
|
||||
...adapter,
|
||||
rendersDir: () => join(project, "renders"),
|
||||
installRegistryBlock: async () => {
|
||||
mkdirWithinProject(project, join(project, "compositions"));
|
||||
return { written: [] };
|
||||
},
|
||||
});
|
||||
const render = () =>
|
||||
api.request("http://localhost/projects/demo/render", { method: "POST", body: "{}" });
|
||||
renameSync(project, `${project}-renamed`);
|
||||
|
||||
const save = await api.request(fileUrl("scenes/intro.html"), {
|
||||
method: "PUT",
|
||||
headers: { "If-None-Match": "*" },
|
||||
body: "<html></html>",
|
||||
});
|
||||
const form = new FormData();
|
||||
form.append("files", new File(["upload bytes"], "clip.txt"));
|
||||
const upload = await api.request("http://localhost/projects/demo/upload?dir=assets", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
const install = await api.request("http://localhost/projects/demo/registry/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ blockName: "card" }),
|
||||
});
|
||||
|
||||
for (const response of [save, upload, install, await render()]) {
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toMatchObject({ why: "project_dir_missing" });
|
||||
}
|
||||
expect(existsSync(project)).toBe(false);
|
||||
});
|
||||
|
||||
it("answers that the project folder is gone when it is renamed while an upload is being read", async () => {
|
||||
const { project, adapter } = fixture();
|
||||
const readForm = Request.prototype.formData;
|
||||
vi.spyOn(Request.prototype, "formData").mockImplementation(function (this: Request) {
|
||||
renameSync(project, `${project}-renamed`);
|
||||
return readForm.call(this);
|
||||
});
|
||||
|
||||
await expectProjectGone(await upload(createStudioApi(adapter)), project);
|
||||
});
|
||||
|
||||
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);
|
||||
const api = createStudioApi({ ...adapter, startRender });
|
||||
rmSync(project, { recursive: true, force: true });
|
||||
|
||||
const response = await api.request("http://localhost/projects/demo/render", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
await expectProjectGone(response, project);
|
||||
expect(startRender).not.toHaveBeenCalled();
|
||||
expect(existsSync(adapter.rendersDir({ id: "demo", dir: project }))).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a missing project directory as 404, not 403", async () => {
|
||||
const { app, project } = fixture();
|
||||
rmSync(project, { recursive: true, force: true });
|
||||
@@ -425,4 +499,10 @@ describe("upload collision races", () => {
|
||||
expect(await response.json()).toMatchObject({ files: [] });
|
||||
expect(readFileSync(join(outside, "secret.txt"), "utf8")).toBe("outside secret");
|
||||
});
|
||||
|
||||
it("answers that the project folder is gone when it is renamed while a file is read", async () => {
|
||||
const { app, project } = fixture();
|
||||
raceDuringRead("upload.txt", () => renameSync(project, `${project}-renamed`));
|
||||
await expectProjectGone(await upload(app), project);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
readlinkSync,
|
||||
writeFileSync,
|
||||
writeSync,
|
||||
mkdirSync,
|
||||
unlinkSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
@@ -27,8 +26,14 @@ import { isAudioFile } from "../helpers/mime.js";
|
||||
import { replaceFileAtomically } from "../helpers/atomicFile.js";
|
||||
import { generateWaveformCache } from "../helpers/waveform.js";
|
||||
import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js";
|
||||
import { isSafePath, pinWithinProject, resolveWithinProject } from "../helpers/safePath.js";
|
||||
import {
|
||||
isSafePath,
|
||||
mkdirWithinProject,
|
||||
pinWithinProject,
|
||||
resolveWithinProject,
|
||||
} from "../helpers/safePath.js";
|
||||
import { backupPathForResponse, snapshotBeforeWrite } from "../helpers/backupJournal.js";
|
||||
import { projectDirMissing } from "../helpers/projectDirMissing.js";
|
||||
import {
|
||||
createWriteToken,
|
||||
fileContentVersion,
|
||||
@@ -193,9 +198,7 @@ async function resolveProjectPath(
|
||||
// `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)) {
|
||||
return {
|
||||
error: c.json({ error: "not found", why: "project_dir_missing" }, 404),
|
||||
} as const;
|
||||
return { error: projectDirMissing(c) } as const;
|
||||
}
|
||||
|
||||
const filePath = requestSubPath(c.req.url, `projects/:id/${route}`);
|
||||
@@ -586,10 +589,9 @@ async function parseMutationBody<T extends { target?: MutationTarget }>(
|
||||
return { target: body.target, body };
|
||||
}
|
||||
|
||||
/** Ensure the parent directory of a path exists. */
|
||||
function ensureDir(filePath: string) {
|
||||
const dir = dirname(filePath);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
/** Ensure the parent directory of a path exists, never recreating a project folder that is gone. */
|
||||
function ensureDir(projectDir: string, filePath: string) {
|
||||
mkdirWithinProject(projectDir, dirname(filePath));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2427,7 +2429,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
let backup: ReturnType<typeof snapshotBeforeWrite> = { backupPath: null };
|
||||
let overwrote: Buffer | undefined;
|
||||
if (createOnly) {
|
||||
ensureDir(res.absPath);
|
||||
ensureDir(res.project.dir, res.absPath);
|
||||
let fd: number;
|
||||
try {
|
||||
fd = openSync(res.absPath, "wx");
|
||||
@@ -2514,7 +2516,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
const res = await resolveProjectFile(c, adapter);
|
||||
if ("error" in res) return res.error;
|
||||
|
||||
ensureDir(res.absPath);
|
||||
ensureDir(res.project.dir, res.absPath);
|
||||
const body = Buffer.from(await c.req.arrayBuffer());
|
||||
try {
|
||||
writeFileSync(res.absPath, body, { flag: "wx" });
|
||||
@@ -3193,7 +3195,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
return c.json({ error: "already exists" }, 409);
|
||||
}
|
||||
|
||||
ensureDir(newAbs);
|
||||
ensureDir(res.project.dir, newAbs);
|
||||
renameSync(res.absPath, newAbs);
|
||||
|
||||
// Update references to the old path across all project files
|
||||
@@ -3224,7 +3226,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
return c.json({ error: "forbidden" }, 403);
|
||||
}
|
||||
|
||||
ensureDir(destAbs);
|
||||
ensureDir(project.dir, destAbs);
|
||||
try {
|
||||
writeFileSync(destAbs, readFileSync(srcAbs), { flag: "wx" });
|
||||
} catch (error) {
|
||||
@@ -3250,15 +3252,17 @@ 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);
|
||||
|
||||
// Optional subdirectory within the project (e.g. "assets/audio")
|
||||
const subDir = c.req.query("dir") ?? "";
|
||||
const targetDir = subDir ? resolveWithinProject(project.dir, subDir) : project.dir;
|
||||
if (!targetDir) return c.json({ error: "forbidden" }, 403);
|
||||
if (subDir && !existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
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);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Hono } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { existsSync } from "node:fs";
|
||||
import { basename, dirname, extname, join, posix } from "node:path";
|
||||
import type { MediaProcessingJobState, StudioApiAdapter } from "../types.js";
|
||||
import { pinWithinProject, resolveWithinProject } from "../helpers/safePath.js";
|
||||
import { mkdirWithinProject, pinWithinProject, resolveWithinProject } from "../helpers/safePath.js";
|
||||
import { probeMediaMetadata } from "../helpers/mediaMetadata.js";
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set([
|
||||
@@ -231,8 +231,8 @@ export function registerMediaRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
if (backgroundOutputPath) mkdirSync(dirname(backgroundOutputPath), { recursive: true });
|
||||
mkdirWithinProject(project.dir, dirname(outputPath));
|
||||
if (backgroundOutputPath) mkdirWithinProject(project.dir, dirname(backgroundOutputPath));
|
||||
|
||||
const jobId = makeJobId(project.id, mediaJobs);
|
||||
const state = adapter.startBackgroundRemoval({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
openSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
@@ -557,7 +558,6 @@ describe("built preview reuse", () => {
|
||||
|
||||
it("serves a restarted server from the document store unless the build changed", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
const storeDir = join(projectDir, ".hyperframes", "preview");
|
||||
const serve = async (salt: string) => {
|
||||
const bundle = vi.fn(async () => BUILT);
|
||||
const app = new Hono();
|
||||
@@ -565,7 +565,7 @@ describe("built preview reuse", () => {
|
||||
app,
|
||||
createAdapter(projectDir, {
|
||||
bundle,
|
||||
previewDocuments: createPreviewDocumentStore(storeDir, salt),
|
||||
previewDocuments: createPreviewDocumentStore(projectDir, salt),
|
||||
} as Partial<StudioApiAdapter>),
|
||||
);
|
||||
const html = await (await app.request("http://localhost/projects/demo/preview")).text();
|
||||
@@ -581,7 +581,6 @@ describe("built preview reuse", () => {
|
||||
|
||||
it("keeps the preview in the document store after a capture build", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
const storeDir = join(projectDir, ".hyperframes", "preview");
|
||||
const session = async (paths: string[]) => {
|
||||
const bundle = vi.fn(async () => BUILT);
|
||||
const app = new Hono();
|
||||
@@ -589,7 +588,7 @@ describe("built preview reuse", () => {
|
||||
app,
|
||||
createAdapter(projectDir, {
|
||||
bundle,
|
||||
previewDocuments: createPreviewDocumentStore(storeDir, "build-a"),
|
||||
previewDocuments: createPreviewDocumentStore(projectDir, "build-a"),
|
||||
} as Partial<StudioApiAdapter>),
|
||||
);
|
||||
for (const path of paths) await app.request(`http://localhost/projects/demo/${path}`);
|
||||
@@ -1774,6 +1773,31 @@ describe("hf-proxy codec probe", () => {
|
||||
return { proxy, probeMediaMetadata };
|
||||
}
|
||||
|
||||
it("answers that the project folder is gone when it is renamed while a proxy is requested", 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: { codecName: "hevc", pixelFormat: "yuv420p" } };
|
||||
},
|
||||
}));
|
||||
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");
|
||||
|
||||
@@ -14,7 +14,7 @@ import { STUDIO_PREVIEW_MARK_META } from "@hyperframes/core/studio-preview-mark"
|
||||
import { injectTagsAtHeadStart } from "@hyperframes/core/compiler/html-document";
|
||||
import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
|
||||
import type { ResolvedProject, StudioApiAdapter } from "../types.js";
|
||||
import { resolveWithinProject } from "../helpers/safePath.js";
|
||||
import { isProjectRootMissing, resolveWithinProject } from "../helpers/safePath.js";
|
||||
import { getMimeType } from "../helpers/mime.js";
|
||||
import { buildSubCompositionHtml, hasBaseElement } from "../helpers/subComposition.js";
|
||||
import {
|
||||
@@ -638,6 +638,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
|
||||
if (err instanceof ProxyCapacityError) {
|
||||
return c.text(err.message, 503, { "Retry-After": "5" });
|
||||
}
|
||||
if (isProjectRootMissing(err)) throw err;
|
||||
const message = err instanceof ProxyTranscodeError ? err.message : "proxy transcode failed";
|
||||
return c.text(message, 502);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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";
|
||||
|
||||
export function registerRegistryRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
api.get("/registry/blocks", async (c) => {
|
||||
@@ -17,6 +20,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);
|
||||
|
||||
const body = await c.req.json<{ blockName?: string }>().catch(() => null);
|
||||
if (!body?.blockName) {
|
||||
@@ -27,6 +31,7 @@ export function registerRegistryRoutes(api: Hono, adapter: StudioApiAdapter): vo
|
||||
const result = await adapter.installRegistryBlock({ project, blockName: body.blockName });
|
||||
return c.json(result);
|
||||
} catch (err) {
|
||||
if (isProjectRootMissing(err)) return projectDirMissing(c);
|
||||
const message = err instanceof Error ? err.message : "Install failed";
|
||||
return c.json({ error: message }, 500);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Hono } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import { existsSync, readFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from "node:fs";
|
||||
import { existsSync, 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 { resolveWithinProject } from "../helpers/safePath.js";
|
||||
import { mkdirWithinProject, resolveWithinProject } from "../helpers/safePath.js";
|
||||
import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js";
|
||||
|
||||
const VALID_RESOLUTIONS = new Set<string>(VALID_CANVAS_RESOLUTIONS);
|
||||
@@ -123,7 +123,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
|
||||
const now = new Date();
|
||||
const jobId = `${project.id}_${formatRenderOutputTimestamp(now)}`;
|
||||
const rendersDir = adapter.rendersDir(project);
|
||||
if (!existsSync(rendersDir)) mkdirSync(rendersDir, { recursive: true });
|
||||
mkdirWithinProject(project.dir, rendersDir);
|
||||
const ext = FORMAT_EXT[format] ?? ".mp4";
|
||||
const outputPath = join(rendersDir, `${jobId}${ext}`);
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
type Dirent,
|
||||
fstatSync,
|
||||
openSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
@@ -22,7 +21,11 @@ import { createProjectSignature, resolveProjectAndSignature } from "../helpers/p
|
||||
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";
|
||||
import {
|
||||
isProjectRootMissing,
|
||||
mkdirWithinProject,
|
||||
resolveWithinProject,
|
||||
} from "../helpers/safePath.js";
|
||||
import { PREVIEW_CAPTURE_PARAM } from "./preview.js";
|
||||
|
||||
const THUMBNAIL_CACHE_VERSION = "v4";
|
||||
@@ -253,7 +256,7 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
|
||||
// but never file them under a signature they do not prove.
|
||||
return generated;
|
||||
}
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
mkdirWithinProject(project.dir, cacheDir);
|
||||
writeThumbnailAtomically(cachePath, generated);
|
||||
return generated;
|
||||
},
|
||||
@@ -272,6 +275,7 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
return new Response(null, { status: 499 });
|
||||
}
|
||||
if (isProjectRootMissing(err)) throw err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return c.json({ error: `Thumbnail generation failed: ${msg}` }, 500);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user