From 26bfd381366a5eefccec2f2e64d0ae0025d34a5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 28 Sep 2026 09:48:22 +0000 Subject: [PATCH] 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 --- packages/cli/src/commands/add.ts | 8 +- packages/cli/src/registry/publication.test.ts | 12 +++ packages/cli/src/registry/publication.ts | 19 +---- packages/cli/src/registry/remote.ts | 2 + .../studioServer.registryInstall.test.ts | 38 ++++++++- packages/cli/src/server/studioServer.ts | 2 +- packages/core/src/index.ts | 10 ++- packages/core/src/safePath.test.ts | 53 +++++++++++- packages/core/src/safePath.ts | 50 ++++++++++- packages/studio-server/src/createStudioApi.ts | 6 ++ .../src/helpers/backupJournal.test.ts | 26 +++++- .../src/helpers/backupJournal.ts | 7 +- .../studio-server/src/helpers/hfIdPersist.ts | 14 +--- .../src/helpers/previewDocumentStore.test.ts | 27 ++++++ .../src/helpers/previewDocumentStore.ts | 12 +-- .../src/helpers/projectDirMissing.ts | 3 + .../src/helpers/proxyTranscoder.ts | 11 +-- .../studio-server/src/helpers/safePath.ts | 9 +- .../studio-server/src/history/historyId.ts | 3 +- .../src/routes/files.pathSafety.test.ts | 82 ++++++++++++++++++- packages/studio-server/src/routes/files.ts | 32 ++++---- packages/studio-server/src/routes/media.ts | 8 +- .../studio-server/src/routes/preview.test.ts | 32 +++++++- packages/studio-server/src/routes/preview.ts | 3 +- packages/studio-server/src/routes/registry.ts | 5 ++ packages/studio-server/src/routes/render.ts | 6 +- .../studio-server/src/routes/thumbnail.ts | 10 ++- 27 files changed, 410 insertions(+), 80 deletions(-) create mode 100644 packages/studio-server/src/helpers/previewDocumentStore.test.ts create mode 100644 packages/studio-server/src/helpers/projectDirMissing.ts diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index ad0549d692..a078eaac8e 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -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"); } diff --git a/packages/cli/src/registry/publication.test.ts b/packages/cli/src/registry/publication.test.ts index 3150ae7299..e98368d739 100644 --- a/packages/cli/src/registry/publication.test.ts +++ b/packages/cli/src/registry/publication.test.ts @@ -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 { diff --git a/packages/cli/src/registry/publication.ts b/packages/cli/src/registry/publication.ts index 56a3f9b595..c42054cd57 100644 --- a/packages/cli/src/registry/publication.ts +++ b/packages/cli/src/registry/publication.ts @@ -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)); diff --git a/packages/cli/src/registry/remote.ts b/packages/cli/src/registry/remote.ts index df1d0ccf61..4b6745b8f3 100644 --- a/packages/cli/src/registry/remote.ts +++ b/packages/cli/src/registry/remote.ts @@ -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(entry: CacheEntry): boolean { function writeCache(path: string, data: T): void { try { + mkdirSync(CACHE_DIR, { recursive: true }); const root = registryRoot(CACHE_DIR); const entry: CacheEntry = { fetchedAt: Date.now(), data }; publishRegistryFile(root, basename(path), JSON.stringify(entry)); diff --git a/packages/cli/src/server/studioServer.registryInstall.test.ts b/packages/cli/src/server/studioServer.registryInstall.test.ts index 6e65c1c890..bd21b74bbd 100644 --- a/packages/cli/src/server/studioServer.registryInstall.test.ts +++ b/packages/cli/src/server/studioServer.registryInstall.test.ts @@ -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( `
`, @@ -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(); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 409ec871d4..4db2e531a0 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -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"), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1c8000fb62..7177533485 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/safePath.test.ts b/packages/core/src/safePath.test.ts index 2b1cd7a1d6..df13c4e6c4 100644 --- a/packages/core/src/safePath.test.ts +++ b/packages/core/src/safePath.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/safePath.ts b/packages/core/src/safePath.ts index 750761919f..f5b575ec4b 100644 --- a/packages/core/src/safePath.ts +++ b/packages/core/src/safePath.ts @@ -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; + } + } +} diff --git a/packages/studio-server/src/createStudioApi.ts b/packages/studio-server/src/createStudioApi.ts index 47f0a6a082..59ac80cd42 100644 --- a/packages/studio-server/src/createStudioApi.ts +++ b/packages/studio-server/src/createStudioApi.ts @@ -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); diff --git a/packages/studio-server/src/helpers/backupJournal.test.ts b/packages/studio-server/src/helpers/backupJournal.test.ts index 46c57689ac..2930e2da43 100644 --- a/packages/studio-server/src/helpers/backupJournal.test.ts +++ b/packages/studio-server/src/helpers/backupJournal.test.ts @@ -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("./safePath.js")), + mkdirWithinProject: (root: string) => { + throw new ProjectRootMissingError(root); + }, + })); + const { snapshotBeforeWrite: snapshot } = await import("./backupJournal"); + + expect(() => snapshot(projectDir, file)).toThrow(/Project folder not found/); + }); +}); diff --git a/packages/studio-server/src/helpers/backupJournal.ts b/packages/studio-server/src/helpers/backupJournal.ts index b1f20e6bbd..a81334527c 100644 --- a/packages/studio-server/src/helpers/backupJournal.ts +++ b/packages/studio-server/src/helpers/backupJournal.ts @@ -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" && diff --git a/packages/studio-server/src/helpers/hfIdPersist.ts b/packages/studio-server/src/helpers/hfIdPersist.ts index 29fb876e79..bc11e81353 100644 --- a/packages/studio-server/src/helpers/hfIdPersist.ts +++ b/packages/studio-server/src/helpers/hfIdPersist.ts @@ -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 diff --git a/packages/studio-server/src/helpers/previewDocumentStore.test.ts b/packages/studio-server/src/helpers/previewDocumentStore.test.ts new file mode 100644 index 0000000000..f6f6b51739 --- /dev/null +++ b/packages/studio-server/src/helpers/previewDocumentStore.test.ts @@ -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", ""); + expect(store.read("key")).toBe(""); +}); + +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", ""); + expect(existsSync(projectDir)).toBe(false); +}); diff --git a/packages/studio-server/src/helpers/previewDocumentStore.ts b/packages/studio-server/src/helpers/previewDocumentStore.ts index e3ff4ce527..3c86fdbd47 100644 --- a/packages/studio-server/src/helpers/previewDocumentStore.ts +++ b/packages/studio-server/src/helpers/previewDocumentStore.ts @@ -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)) { diff --git a/packages/studio-server/src/helpers/projectDirMissing.ts b/packages/studio-server/src/helpers/projectDirMissing.ts new file mode 100644 index 0000000000..e893d6891c --- /dev/null +++ b/packages/studio-server/src/helpers/projectDirMissing.ts @@ -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); diff --git a/packages/studio-server/src/helpers/proxyTranscoder.ts b/packages/studio-server/src/helpers/proxyTranscoder.ts index 1ec6cf6d0a..358c8b64ed 100644 --- a/packages/studio-server/src/helpers/proxyTranscoder.ts +++ b/packages/studio-server/src/helpers/proxyTranscoder.ts @@ -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 && diff --git a/packages/studio-server/src/helpers/safePath.ts b/packages/studio-server/src/helpers/safePath.ts index df86009b6f..171da47801 100644 --- a/packages/studio-server/src/helpers/safePath.ts +++ b/packages/studio-server/src/helpers/safePath.ts @@ -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 { diff --git a/packages/studio-server/src/history/historyId.ts b/packages/studio-server/src/history/historyId.ts index a00184ac8f..228d8abbc2 100644 --- a/packages/studio-server/src/history/historyId.ts +++ b/packages/studio-server/src/history/historyId.ts @@ -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); diff --git a/packages/studio-server/src/routes/files.pathSafety.test.ts b/packages/studio-server/src/routes/files.pathSafety.test.ts index fd4a348cad..ac25b50886 100644 --- a/packages/studio-server/src/routes/files.pathSafety.test.ts +++ b/packages/studio-server/src/routes/files.pathSafety.test.ts @@ -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: "", + }); + 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); + }); }); diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index f789b8290e..3d514d1c15 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -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( 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 = { 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( { diff --git a/packages/studio-server/src/routes/media.ts b/packages/studio-server/src/routes/media.ts index 50bdf0dac5..58abc1cdec 100644 --- a/packages/studio-server/src/routes/media.ts +++ b/packages/studio-server/src/routes/media.ts @@ -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({ diff --git a/packages/studio-server/src/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts index c34d72a469..2adef44da1 100644 --- a/packages/studio-server/src/routes/preview.test.ts +++ b/packages/studio-server/src/routes/preview.test.ts @@ -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), ); 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), ); 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( + "../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"); diff --git a/packages/studio-server/src/routes/preview.ts b/packages/studio-server/src/routes/preview.ts index 5d24bc009e..3901f31d83 100644 --- a/packages/studio-server/src/routes/preview.ts +++ b/packages/studio-server/src/routes/preview.ts @@ -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); } diff --git a/packages/studio-server/src/routes/registry.ts b/packages/studio-server/src/routes/registry.ts index 5fdb915656..3830aa3022 100644 --- a/packages/studio-server/src/routes/registry.ts +++ b/packages/studio-server/src/routes/registry.ts @@ -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); } diff --git a/packages/studio-server/src/routes/render.ts b/packages/studio-server/src/routes/render.ts index 871437cb36..acf3ceeea1 100644 --- a/packages/studio-server/src/routes/render.ts +++ b/packages/studio-server/src/routes/render.ts @@ -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(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}`); diff --git a/packages/studio-server/src/routes/thumbnail.ts b/packages/studio-server/src/routes/thumbnail.ts index 888c3d689d..aa77312062 100644 --- a/packages/studio-server/src/routes/thumbnail.ts +++ b/packages/studio-server/src/routes/thumbnail.ts @@ -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); }