mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix: serve projects and assets whose names contain @ % # & or ? (#4415)
* fix(studio-server): serve projects whose names contain @ % # & or ? Five routes cut the decoded project id out of Hono's c.req.path, which leaves %40 %25 %23 %26 %3F encoded, so the cut missed and files, preview assets, sub-compositions, thumbnails and waveforms 404ed or 403ed for those names. One helper now takes the sub-path by segment from the raw URL. * fix(studio-server): thumbnails and waveforms stay inside the project for any name The thumbnail route built its preview URL from the raw project id and path, so # ? and % broke it, and neither route checked that the decoded path stays inside the project. Thumbnails now use the composition guard and an encoded URL; waveforms use the read-only asset guard. * fix(cli): play and present serve assets whose names contain @ % # & or ? Both /composition/* routes cut a prefix out of Hono's c.req.path with no decode. They now share studio-server's requestSubPath (renamed from projectSubPath and generalised to any route) with the five studio-server routes, so one helper owns how a request path becomes a file path. * fix(studio-server): a thumbnail request for the project folder answers 404 An empty thumbnail path resolved to the project directory and crashed reading it (500). * fix(studio-server): read the thumbnail's composition with one open Checking the path with stat and then reading it let the file change in between (CodeQL js/file-system-race). One open now serves the type check, the mtime and the read; a missing file still thumbnails from the preview as before, anything that is not a file answers 404.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Hono } from "hono";
|
||||
import type { ProjectDir } from "../utils/project.js";
|
||||
import { registerCompositionRoute } from "./play.js";
|
||||
import { registerPresentCompositionRoute } from "./present.js";
|
||||
|
||||
// Hono's c.req.path leaves %40 %25 %23 %26 %3F encoded; Windows file names cannot hold "?".
|
||||
const NAMES = [
|
||||
"A @HyperFrames launch",
|
||||
"50% off",
|
||||
"#2 take",
|
||||
"Tom & Jerry",
|
||||
...(process.platform === "win32" ? [] : ["why?"]),
|
||||
"two spaces",
|
||||
"café crème",
|
||||
"🎬 film",
|
||||
];
|
||||
|
||||
const ROUTES = [
|
||||
["play", (app: Hono, project: ProjectDir) => registerCompositionRoute(app, project, false)],
|
||||
["present", registerPresentCompositionRoute],
|
||||
] as const;
|
||||
|
||||
let dir: string | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
dir = undefined;
|
||||
});
|
||||
|
||||
async function requestAsset(
|
||||
register: (app: Hono, project: ProjectDir) => Promise<void>,
|
||||
name: string,
|
||||
) {
|
||||
dir = mkdtempSync(join(tmpdir(), "hf-composition-route-"));
|
||||
mkdirSync(join(dir, "scenes"));
|
||||
writeFileSync(join(dir, "scenes", `${name}.txt`), `ASSET ${name}`);
|
||||
const app = new Hono();
|
||||
await register(app, { dir, name: "test-project", indexPath: join(dir, "index.html") });
|
||||
const response = await app.request(`/composition/scenes/${encodeURIComponent(`${name}.txt`)}`);
|
||||
return { status: response.status, text: await response.text() };
|
||||
}
|
||||
|
||||
describe.each(ROUTES)("%s /composition/*", (_route, register) => {
|
||||
it.each(NAMES)("serves an asset named %j", async (name) => {
|
||||
const result = await requestAsset(register, name);
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.text).toBe(`ASSET ${name}`);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ export const examples: Example[] = [
|
||||
];
|
||||
import { resolve } from "node:path";
|
||||
import type { Hono } from "hono";
|
||||
import { requestSubPath } from "@hyperframes/studio-server";
|
||||
import * as clack from "@clack/prompts";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { resolveProject, type ProjectDir } from "../utils/project.js";
|
||||
@@ -213,7 +214,7 @@ export async function registerCompositionRoute(
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
app.get("/composition/*", async (ctx) => {
|
||||
const reqPath = ctx.req.path.replace("/composition/", "");
|
||||
const reqPath = requestSubPath(ctx.req.url, "composition");
|
||||
const filePath = resolve(project.dir, reqPath);
|
||||
|
||||
// Security: don't allow path traversal outside project dir. isSafePath
|
||||
|
||||
@@ -13,7 +13,9 @@ export const examples: Example[] = [
|
||||
import { resolve } from "node:path";
|
||||
import * as clack from "@clack/prompts";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
import { resolveProject, type ProjectDir } from "../utils/project.js";
|
||||
import type { Hono } from "hono";
|
||||
import { requestSubPath } from "@hyperframes/studio-server";
|
||||
import {
|
||||
openBrowser,
|
||||
parseRemoteDebuggingPort,
|
||||
@@ -113,7 +115,6 @@ export default defineCommand({
|
||||
|
||||
const { Hono } = await import("hono");
|
||||
const { createAdaptorServer } = await import("@hono/node-server");
|
||||
const { isSafePath } = await import("@hyperframes/core/studio-api");
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -133,16 +134,7 @@ export default defineCommand({
|
||||
// timelines (no engine runtime injected) — the same model demo.html / the
|
||||
// standalone harness use; injecting a runtime would leave the composition
|
||||
// engine-paused and blank.
|
||||
app.get("/composition/*", (ctx) => {
|
||||
const reqPath = ctx.req.path.replace("/composition/", "");
|
||||
const filePath = resolve(project.dir, reqPath);
|
||||
// Security: canonicalizes symlinks + guards the trailing separator so neither
|
||||
// an in-project symlink nor a sibling dir sharing the prefix can escape.
|
||||
if (!isSafePath(project.dir, filePath)) return ctx.text("Forbidden", 403);
|
||||
if (!existsSync(filePath)) return ctx.text("Not found", 404);
|
||||
if (filePath.endsWith(".html")) return ctx.html(readFileSync(filePath, "utf-8"));
|
||||
return ctx.body(readFileSync(filePath), 200, { "Content-Type": assetContentType(filePath) });
|
||||
});
|
||||
await registerPresentCompositionRoute(app, project);
|
||||
|
||||
// Both the presenter window and the audience window (opened by present() with
|
||||
// ?mode=audience) load this same page; the component reads the mode from the URL.
|
||||
@@ -309,3 +301,20 @@ function escHtml(value: string): string {
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
export async function registerPresentCompositionRoute(
|
||||
app: Hono,
|
||||
project: ProjectDir,
|
||||
): Promise<void> {
|
||||
const { isSafePath } = await import("@hyperframes/core/studio-api");
|
||||
app.get("/composition/*", (ctx) => {
|
||||
const reqPath = requestSubPath(ctx.req.url, "composition");
|
||||
const filePath = resolve(project.dir, reqPath);
|
||||
// Security: canonicalizes symlinks + guards the trailing separator so neither
|
||||
// an in-project symlink nor a sibling dir sharing the prefix can escape.
|
||||
if (!isSafePath(project.dir, filePath)) return ctx.text("Forbidden", 403);
|
||||
if (!existsSync(filePath)) return ctx.text("Not found", 404);
|
||||
if (filePath.endsWith(".html")) return ctx.html(readFileSync(filePath, "utf-8"));
|
||||
return ctx.body(readFileSync(filePath), 200, { "Content-Type": assetContentType(filePath) });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// The decoded path after `route` ("projects/:id/preview", "composition"), cut by segment from the raw URL:
|
||||
// Hono's c.req.path leaves %40 %25 %23 %26 %3F encoded, so cutting a decoded prefix out of it misses.
|
||||
export function requestSubPath(url: string, route: string): string {
|
||||
const routeSegments = route.split("/");
|
||||
const segments = new URL(url).pathname.split("/");
|
||||
const start = segments.indexOf(routeSegments[0] ?? "");
|
||||
return decodeURIComponent(segments.slice(start + routeSegments.length).join("/"));
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export {
|
||||
export type { PreviewApiAdapter } from "./helpers/mediaProxyPreview.js";
|
||||
export { PREVIEW_BUNDLE_OPTIONS } from "./routes/preview.js";
|
||||
export { getMimeType, MIME_TYPES } from "./helpers/mime.js";
|
||||
export { requestSubPath } from "./helpers/requestSubPath.js";
|
||||
export {
|
||||
consumeFileWriteReceipt,
|
||||
identifyFileWrite,
|
||||
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
insertCompositionIntoSource,
|
||||
} from "../helpers/compositionInsertion.js";
|
||||
import { resolveGsapWriter } from "./gsapMutationCapabilities.js";
|
||||
import { requestSubPath } from "../helpers/requestSubPath.js";
|
||||
|
||||
// ── Server cutover flag ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -114,7 +115,7 @@ async function loadGsapParser() {
|
||||
interface RouteContext {
|
||||
req: {
|
||||
param: (name: string) => string;
|
||||
path: string;
|
||||
url: string;
|
||||
query: (name: string) => string | undefined;
|
||||
header: (name: string) => string | undefined;
|
||||
};
|
||||
@@ -172,7 +173,7 @@ function isDanglingSymlinkInProject(projectDir: string, lexicalPath: string): bo
|
||||
async function resolveProjectPath(
|
||||
c: RouteContext,
|
||||
adapter: StudioApiAdapter,
|
||||
pathPrefix: (projectId: string) => string,
|
||||
route: string,
|
||||
opts?: { mustExist?: boolean },
|
||||
) {
|
||||
const id = c.req.param("id");
|
||||
@@ -195,7 +196,7 @@ async function resolveProjectPath(
|
||||
} as const;
|
||||
}
|
||||
|
||||
const filePath = decodeURIComponent(c.req.path.replace(pathPrefix(project.id), ""));
|
||||
const filePath = requestSubPath(c.req.url, `projects/:id/${route}`);
|
||||
if (filePath.includes("\0")) {
|
||||
return { error: c.json({ error: "forbidden", why: "nul" }, 403) } as const;
|
||||
}
|
||||
@@ -220,11 +221,11 @@ function resolveProjectFile(
|
||||
adapter: StudioApiAdapter,
|
||||
opts?: { mustExist?: boolean },
|
||||
) {
|
||||
return resolveProjectPath(c, adapter, (id) => `/projects/${id}/files/`, opts);
|
||||
return resolveProjectPath(c, adapter, "files", opts);
|
||||
}
|
||||
|
||||
function resolveFileMutationContext(c: RouteContext, adapter: StudioApiAdapter, operation: string) {
|
||||
return resolveProjectPath(c, adapter, (id) => `/projects/${id}/file-mutations/${operation}/`);
|
||||
return resolveProjectPath(c, adapter, `file-mutations/${operation}`);
|
||||
}
|
||||
|
||||
type MutationTarget = {
|
||||
@@ -3222,7 +3223,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
// ── GSAP Animations (parse) ──
|
||||
|
||||
api.get("/projects/:id/gsap-animations/*", async (c) => {
|
||||
const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-animations/`, {
|
||||
const res = await resolveProjectPath(c, adapter, "gsap-animations", {
|
||||
mustExist: true,
|
||||
});
|
||||
if ("error" in res) return res.error;
|
||||
@@ -3251,7 +3252,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
});
|
||||
|
||||
api.post("/projects/:id/gsap-mutations/*", async (c) => {
|
||||
const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-mutations/`, {
|
||||
const res = await resolveProjectPath(c, adapter, "gsap-mutations", {
|
||||
mustExist: true,
|
||||
});
|
||||
if ("error" in res) return res.error;
|
||||
@@ -3264,12 +3265,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
});
|
||||
|
||||
api.post("/projects/:id/gsap-mutations-batch/*", async (c) => {
|
||||
const res = await resolveProjectPath(
|
||||
c,
|
||||
adapter,
|
||||
(id) => `/projects/${id}/gsap-mutations-batch/`,
|
||||
{ mustExist: true },
|
||||
);
|
||||
const res = await resolveProjectPath(c, adapter, "gsap-mutations-batch", { mustExist: true });
|
||||
if ("error" in res) return res.error;
|
||||
|
||||
const body = (await c.req.json().catch(() => null)) as {
|
||||
@@ -3289,12 +3285,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
// mutation wrote. Keep compare + write in this synchronous server section so
|
||||
// another request cannot land between a client-side check and the restore.
|
||||
api.post("/projects/:id/gsap-mutation-rollback/*", async (c) => {
|
||||
const res = await resolveProjectPath(
|
||||
c,
|
||||
adapter,
|
||||
(id) => `/projects/${id}/gsap-mutation-rollback/`,
|
||||
{ mustExist: true },
|
||||
);
|
||||
const res = await resolveProjectPath(c, adapter, "gsap-mutation-rollback", { mustExist: true });
|
||||
if ("error" in res) return res.error;
|
||||
|
||||
const body = (await c.req.json().catch(() => null)) as {
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
resolvePreviewMediaCodecProbeCache,
|
||||
type PreviewApiAdapter,
|
||||
} from "../helpers/mediaProxyPreview.js";
|
||||
import { requestSubPath } from "../helpers/requestSubPath.js";
|
||||
|
||||
const PROJECT_SIGNATURE_META = "hyperframes-project-signature";
|
||||
const GSAP_CDN_VERSION = "3.15.0";
|
||||
@@ -471,9 +472,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
|
||||
const vars = previewVariablesFromRequest(c.req.query("variables"));
|
||||
if (vars.error !== undefined) return c.json({ error: vars.error }, 400);
|
||||
const previewVariables = vars.values;
|
||||
const compPath = decodeURIComponent(
|
||||
c.req.path.replace(`/projects/${project.id}/preview/comp/`, "").split("?")[0] ?? "",
|
||||
);
|
||||
const compPath = requestSubPath(c.req.url, "projects/:id/preview/comp");
|
||||
const compFile = resolveWithinProject(project.dir, compPath);
|
||||
if (!compFile || !existsSync(compFile) || !statSync(compFile).isFile()) {
|
||||
return c.text("not found", 404);
|
||||
@@ -516,9 +515,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
|
||||
api.get("/projects/:id/preview/*", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
const subPath = decodeURIComponent(
|
||||
c.req.path.replace(`/projects/${project.id}/preview/`, "").split("?")[0] ?? "",
|
||||
);
|
||||
const subPath = requestSubPath(c.req.url, "projects/:id/preview");
|
||||
// Assets are read-only and should mirror the renderer: permit a path that
|
||||
// is lexically inside the project even if an explicit project symlink
|
||||
// targets a shared directory outside it. Composition source files still
|
||||
|
||||
@@ -1,30 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "node:fs";
|
||||
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { registerFileRoutes } from "./files.js";
|
||||
import { registerPreviewRoutes } from "./preview.js";
|
||||
import { registerThumbnailRoutes } from "./thumbnail.js";
|
||||
import { registerWaveformRoutes } from "./waveform.js";
|
||||
import { buildWaveformCacheKey } from "../helpers/waveform.js";
|
||||
import type { StudioApiAdapter } from "../types.js";
|
||||
|
||||
/**
|
||||
* Pins the path-resolution contract of the project-file read that Studio's SDK
|
||||
* session depends on. If this route fails, `openComposition` never runs and
|
||||
* every edit in that project silently falls back to the server path.
|
||||
*
|
||||
* The shapes here are the ones that looked most likely to break it, because
|
||||
* `resolveProjectPath` strips a prefix built from the *decoded* `project.id`
|
||||
* out of `c.req.path`, and the CLI derives that id straight from the folder
|
||||
* name (`projectId = projectName || basename(projectDir)`). A folder named with
|
||||
* a space or non-ASCII character therefore produces an id that percent-encodes,
|
||||
* and sub-compositions — the house pattern, one per scene — put a separator in
|
||||
* the file path that encodes as %2F.
|
||||
*
|
||||
* All of them pass today: Hono hands `c.req.path` over already decoded. These
|
||||
* are regression tests, not a reproduction — they were written while hunting a
|
||||
* production failure (`stage: read`) that turned out to be something else, and
|
||||
* they are kept because the decoded/encoded seam is a real latent hazard that a
|
||||
* refactor of `resolveProjectPath` could reopen silently.
|
||||
*/
|
||||
// Project ids come from folder names, so any character a folder allows must survive the URL.
|
||||
function createAdapter(projectDir: string): StudioApiAdapter {
|
||||
return {
|
||||
listProjects: () => [],
|
||||
@@ -92,3 +78,118 @@ describe("project ids that percent-encode in a URL", () => {
|
||||
expect(result.content).toContain("SCENE ONE");
|
||||
});
|
||||
});
|
||||
|
||||
// A Home sentence with an @ mention names the project; Hono leaves %40 %25 %23 %26 %3F encoded in c.req.path.
|
||||
const RESERVED_NAMES = [
|
||||
"A @HyperFrames launch",
|
||||
"50% off",
|
||||
"#2 take",
|
||||
"Tom & Jerry",
|
||||
"why?",
|
||||
"two spaces",
|
||||
"café crème",
|
||||
"🎬 film",
|
||||
];
|
||||
|
||||
type ThumbnailCall = { compPath: string; previewUrl: string };
|
||||
|
||||
async function requestProject(projectId: string, route: string, encodedSubPath: string) {
|
||||
const { dir, cleanup } = projectWithComposition();
|
||||
writeFileSync(join(dir, "scenes", "voice.wav"), "RIFF");
|
||||
// The waveform route's own cache hit, so no audio decoder runs.
|
||||
const voice = statSync(join(dir, "scenes", "voice.wav"));
|
||||
mkdirSync(join(dir, ".waveform-cache"));
|
||||
writeFileSync(
|
||||
join(dir, ".waveform-cache", buildWaveformCacheKey("scenes/voice.wav", voice)),
|
||||
"[0.5]",
|
||||
);
|
||||
const thumbnails: ThumbnailCall[] = [];
|
||||
const adapter = {
|
||||
...createAdapter(dir),
|
||||
generateThumbnail: async (opts: ThumbnailCall) => {
|
||||
thumbnails.push({ compPath: opts.compPath, previewUrl: opts.previewUrl });
|
||||
return Buffer.from("jpeg");
|
||||
},
|
||||
} as StudioApiAdapter;
|
||||
try {
|
||||
const app = new Hono();
|
||||
registerFileRoutes(app, adapter);
|
||||
registerPreviewRoutes(app, adapter);
|
||||
registerThumbnailRoutes(app, adapter);
|
||||
registerWaveformRoutes(app, adapter);
|
||||
const response = await app.request(
|
||||
`http://localhost/projects/${encodeURIComponent(projectId)}/${route}/${encodedSubPath}`,
|
||||
);
|
||||
return { status: response.status, text: await response.text(), thumbnails };
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
describe.each(RESERVED_NAMES)("project id %j", (projectId) => {
|
||||
it("reads a project file", async () => {
|
||||
const result = await readComposition(projectId, "scenes/scene-1.html");
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.content).toContain("SCENE ONE");
|
||||
});
|
||||
|
||||
it("serves a preview asset", async () => {
|
||||
const result = await requestProject(projectId, "preview", "scenes/scene-1.html");
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.text).toContain("SCENE ONE");
|
||||
});
|
||||
|
||||
it("serves a preview sub-composition", async () => {
|
||||
const result = await requestProject(projectId, "preview/comp", "scenes/scene-1.html");
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.text).toContain("SCENE ONE");
|
||||
});
|
||||
|
||||
it("thumbnails a sub-composition through a preview URL that parses back to it", async () => {
|
||||
const result = await requestProject(projectId, "thumbnail", "scenes/scene-1.html");
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.thumbnails).toHaveLength(1);
|
||||
expect(result.thumbnails[0]?.compPath).toBe("scenes/scene-1.html");
|
||||
const url = new URL(result.thumbnails[0]?.previewUrl ?? "");
|
||||
expect(url.search + url.hash).toBe("");
|
||||
const segments = url.pathname.split("/").map(decodeURIComponent);
|
||||
expect(segments).toEqual([
|
||||
"",
|
||||
"api",
|
||||
"projects",
|
||||
projectId,
|
||||
"preview",
|
||||
"comp",
|
||||
"scenes",
|
||||
"scene-1.html",
|
||||
]);
|
||||
});
|
||||
|
||||
it("serves an audio waveform", async () => {
|
||||
const result = await requestProject(projectId, "waveform", "scenes/voice.wav");
|
||||
expect(result.status).toBe(200);
|
||||
expect(JSON.parse(result.text)).toEqual({ peaks: [0.5] });
|
||||
});
|
||||
});
|
||||
|
||||
// Encoded as one segment: a literal ../ is normalised away before routing.
|
||||
const OUTSIDE_PROJECT = encodeURIComponent(`${"../".repeat(24)}etc/hosts`);
|
||||
|
||||
describe("a sub-path that decodes to a parent directory", () => {
|
||||
it("is not thumbnailed", async () => {
|
||||
const result = await requestProject("demo-project", "thumbnail", OUTSIDE_PROJECT);
|
||||
expect(result.status).toBe(404);
|
||||
expect(result.thumbnails).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("thumbnails nothing for the project folder itself", async () => {
|
||||
const result = await requestProject("demo-project", "thumbnail", "");
|
||||
expect(result.status).toBe(404);
|
||||
expect(result.thumbnails).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is not read for a waveform", async () => {
|
||||
const result = await requestProject("demo-project", "waveform", OUTSIDE_PROJECT);
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { Hono } from "hono";
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
fstatSync,
|
||||
openSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
@@ -18,6 +21,8 @@ import { compositionInputSignature } from "../helpers/compositionInputs.js";
|
||||
import { createProjectSignature, resolveProjectAndSignature } from "../helpers/projectSignature.js";
|
||||
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";
|
||||
|
||||
const THUMBNAIL_CACHE_VERSION = "v4";
|
||||
const THUMBNAIL_MAX_OUTPUT_WIDTH = 240;
|
||||
@@ -74,6 +79,26 @@ function writeThumbnailAtomically(path: string, buffer: Buffer): void {
|
||||
}
|
||||
}
|
||||
|
||||
type CompositionSource = { html: string; mtimeMs: number } | "missing" | "not-a-file";
|
||||
|
||||
// One open for the stat and the read, so the file cannot change between the check and the use.
|
||||
function readCompositionSource(file: string): CompositionSource {
|
||||
let fd: number;
|
||||
try {
|
||||
fd = openSync(file, "r");
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") return "missing";
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
const stat = fstatSync(fd);
|
||||
if (!stat.isFile()) return "not-a-file";
|
||||
return { html: readFileSync(fd, "utf-8"), mtimeMs: stat.mtimeMs };
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
api.get("/projects/:id/thumbnail/*", async (c) => {
|
||||
if (!adapter.generateThumbnail) {
|
||||
@@ -83,10 +108,11 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
|
||||
if (!resolved) return c.json({ error: "not found" }, 404);
|
||||
const { project, signature: projectSignature } = resolved;
|
||||
|
||||
let compPath = decodeURIComponent(
|
||||
c.req.path.replace(`/projects/${project.id}/thumbnail/`, "").split("?")[0] ?? "",
|
||||
);
|
||||
let compPath = requestSubPath(c.req.url, "projects/:id/thumbnail");
|
||||
if (compPath && !compPath.includes(".")) compPath += ".html";
|
||||
const htmlFile = resolveWithinProject(project.dir, compPath);
|
||||
const source = htmlFile ? readCompositionSource(htmlFile) : "not-a-file";
|
||||
if (source === "not-a-file") return c.json({ error: "not found" }, 404);
|
||||
// Keyed on what this composition renders from, so editing one scene leaves the others cached.
|
||||
const inputSignature = compositionInputSignature(project.dir, compPath, projectSignature);
|
||||
|
||||
@@ -122,11 +148,10 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
|
||||
// edit, even on a hard reload. Keyed on content (like manualEdits/motion), not
|
||||
// just mtime, so a restore/copy with a preserved mtime can't serve stale.
|
||||
let sourceKey = "";
|
||||
const htmlFile = join(project.dir, compPath);
|
||||
if (existsSync(htmlFile)) {
|
||||
const html = readFileSync(htmlFile, "utf-8");
|
||||
if (source !== "missing") {
|
||||
const { html } = source;
|
||||
sourceKey = `_${createHash("sha1").update(html).digest("hex").slice(0, 16)}`;
|
||||
sourceMtime = Math.round(statSync(htmlFile).mtimeMs);
|
||||
sourceMtime = Math.round(source.mtimeMs);
|
||||
if (!vpWidth) {
|
||||
const wMatch = html.match(/data-width=["'](\d+)["']/);
|
||||
const hMatch = html.match(/data-height=["'](\d+)["']/);
|
||||
@@ -149,10 +174,11 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
|
||||
sourceMtime = Math.max(sourceMtime, Math.round(statSync(motionFile).mtimeMs));
|
||||
}
|
||||
|
||||
const projectUrl = `http://${c.req.header("host")}/api/projects/${encodeURIComponent(project.id)}`;
|
||||
const previewUrl =
|
||||
compPath === "index.html"
|
||||
? `http://${c.req.header("host")}/api/projects/${project.id}/preview`
|
||||
: `http://${c.req.header("host")}/api/projects/${project.id}/preview/comp/${compPath}`;
|
||||
? `${projectUrl}/preview`
|
||||
: `${projectUrl}/preview/comp/${compPath.split("/").map(encodeURIComponent).join("/")}`;
|
||||
|
||||
// Cache
|
||||
const cacheDir = join(project.dir, ".thumbnails");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
|
||||
import type { Hono } from "hono";
|
||||
import type { StudioApiAdapter } from "../types.js";
|
||||
import {
|
||||
@@ -8,16 +9,18 @@ import {
|
||||
writeWaveformCache,
|
||||
isWaveformCacheDirectory,
|
||||
} from "../helpers/waveform.js";
|
||||
import { requestSubPath } from "../helpers/requestSubPath.js";
|
||||
|
||||
export function registerWaveformRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
api.get("/projects/:id/waveform/*", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
|
||||
const assetPath = decodeURIComponent(
|
||||
c.req.path.replace(`/projects/${project.id}/waveform/`, "").split("?")[0] ?? "",
|
||||
);
|
||||
const audioPath = join(project.dir, assetPath);
|
||||
const assetPath = requestSubPath(c.req.url, "projects/:id/waveform");
|
||||
const audioPath = resolve(project.dir, assetPath);
|
||||
if (!isWithinProjectRoot(project.dir, audioPath)) {
|
||||
return c.json({ error: "file not found" }, 404);
|
||||
}
|
||||
const stats = statSync(audioPath, { throwIfNoEntry: false });
|
||||
if (!stats) return c.json({ error: "file not found" }, 404);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user