diff --git a/packages/core/package.json b/packages/core/package.json index e65854920..ded9ace39 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -54,6 +54,10 @@ "import": "./src/studio-api/helpers/draftMarkers.ts", "types": "./src/studio-api/helpers/draftMarkers.ts" }, + "./studio-api/finite-mutation": { + "import": "./src/studio-api/helpers/finiteMutation.ts", + "types": "./src/studio-api/helpers/finiteMutation.ts" + }, "./text": { "import": "./src/text/index.ts", "types": "./src/text/index.ts" @@ -133,6 +137,10 @@ "import": "./dist/studio-api/helpers/draftMarkers.js", "types": "./dist/studio-api/helpers/draftMarkers.d.ts" }, + "./studio-api/finite-mutation": { + "import": "./dist/studio-api/helpers/finiteMutation.js", + "types": "./dist/studio-api/helpers/finiteMutation.d.ts" + }, "./text": { "import": "./dist/text/index.js", "types": "./dist/text/index.d.ts" diff --git a/packages/core/src/studio-api/helpers/finiteMutation.test.ts b/packages/core/src/studio-api/helpers/finiteMutation.test.ts new file mode 100644 index 000000000..df2df70af --- /dev/null +++ b/packages/core/src/studio-api/helpers/finiteMutation.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { findUnsafeDomPatchValues, findUnsafeMutationValues } from "./finiteMutation"; + +describe("finiteMutation", () => { + it("reports non-finite numbers before mutation serialization", () => { + expect( + findUnsafeMutationValues({ + type: "set-arc-path", + segments: [{ curviness: Number.NaN, cp1: { x: Infinity, y: 0 } }], + }).map((field) => field.path), + ).toEqual(["body.segments[0].curviness", "body.segments[0].cp1.x"]); + }); + + it("treats null as unsafe because JSON serializes NaN and Infinity to null", () => { + expect( + findUnsafeMutationValues({ + type: "update-property", + property: "x", + value: null, + }), + ).toEqual([{ path: "body.value", reason: "null" }]); + }); + + it("allows explicit DOM patch value removals while rejecting unsafe patch metadata", () => { + expect( + findUnsafeDomPatchValues({ + target: { id: "title", selectorIndex: null }, + operations: [{ type: "inline-style", property: "opacity", value: null }], + }), + ).toEqual([{ path: "body.target.selectorIndex", reason: "null" }]); + }); + + it("rejects non-finite DOM patch values before JSON serialization can turn them into null", () => { + expect( + findUnsafeDomPatchValues({ + target: { id: "title" }, + operations: [{ type: "inline-style", property: "left", value: Number.NaN }], + }), + ).toEqual([{ path: "body.operations[0].value", reason: "non-finite-number" }]); + }); +}); diff --git a/packages/core/src/studio-api/helpers/finiteMutation.ts b/packages/core/src/studio-api/helpers/finiteMutation.ts new file mode 100644 index 000000000..1e145e4fa --- /dev/null +++ b/packages/core/src/studio-api/helpers/finiteMutation.ts @@ -0,0 +1,38 @@ +export interface UnsafeMutationValue { + path: string; + reason: "non-finite-number" | "null"; +} + +interface FindUnsafeMutationValuesOptions { + allowNullPath?: (path: string) => boolean; +} + +export function findUnsafeMutationValues( + value: unknown, + path = "body", + options: FindUnsafeMutationValuesOptions = {}, +): UnsafeMutationValue[] { + if (value === null) { + return options.allowNullPath?.(path) ? [] : [{ path, reason: "null" }]; + } + if (typeof value === "number") { + return Number.isFinite(value) ? [] : [{ path, reason: "non-finite-number" }]; + } + if (!value || typeof value !== "object") return []; + if (Array.isArray(value)) { + return value.flatMap((item, index) => + findUnsafeMutationValues(item, `${path}[${index}]`, options), + ); + } + return Object.entries(value).flatMap(([key, item]) => + findUnsafeMutationValues(item, `${path}.${key}`, options), + ); +} + +const DOM_PATCH_NULL_VALUE_PATH = /^body\.operations\[\d+\]\.value$/; + +export function findUnsafeDomPatchValues(value: unknown): UnsafeMutationValue[] { + return findUnsafeMutationValues(value, "body", { + allowNullPath: (path) => DOM_PATCH_NULL_VALUE_PATH.test(path), + }); +} diff --git a/packages/core/src/studio-api/helpers/safePath.test.ts b/packages/core/src/studio-api/helpers/safePath.test.ts index f3b8ff47e..fbfce353f 100644 --- a/packages/core/src/studio-api/helpers/safePath.test.ts +++ b/packages/core/src/studio-api/helpers/safePath.test.ts @@ -19,18 +19,21 @@ function createProjectDir(): string { } describe("walkDir", () => { - it("hides internal HyperFrames backup files from project listings", () => { + it("hides internal HyperFrames files from project listings", () => { const projectDir = createProjectDir(); mkdirSync(join(projectDir, ".hyperframes", "backup"), { recursive: true }); mkdirSync(join(projectDir, ".hyperframes", "examples"), { recursive: true }); + mkdirSync(join(projectDir, ".cache", "examples"), { recursive: true }); mkdirSync(join(projectDir, "compositions"), { recursive: true }); writeFileSync(join(projectDir, ".hyperframes", "backup", "snapshot.html"), "backup"); writeFileSync(join(projectDir, ".hyperframes", "examples", "preset.html"), "preset"); + writeFileSync(join(projectDir, ".cache", "examples", "preset.html"), "preset"); writeFileSync(join(projectDir, "compositions", "scene.html"), "scene"); - expect(walkDir(projectDir)).toEqual([ - ".hyperframes/examples/preset.html", - "compositions/scene.html", - ]); + const files = walkDir(projectDir); + expect(files).toContain(".cache/examples/preset.html"); + expect(files).toContain("compositions/scene.html"); + expect(files).not.toContain(".hyperframes/backup/snapshot.html"); + expect(files).not.toContain(".hyperframes/examples/preset.html"); }); }); diff --git a/packages/core/src/studio-api/helpers/safePath.ts b/packages/core/src/studio-api/helpers/safePath.ts index 25edab21f..7626c3b66 100644 --- a/packages/core/src/studio-api/helpers/safePath.ts +++ b/packages/core/src/studio-api/helpers/safePath.ts @@ -7,11 +7,7 @@ export function isSafePath(base: string, resolved: string): boolean { return resolved.startsWith(norm) || resolved === resolve(base); } -const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]); - -function shouldIgnoreDir(rel: string): boolean { - return rel === ".hyperframes/backup"; -} +const IGNORE_DIRS = new Set([".thumbnails", ".hyperframes", "node_modules", ".git"]); /** * True when any directory segment of a relative path is a dot-directory or @@ -30,7 +26,7 @@ export function walkDir(dir: string, prefix = ""): string[] { const files: string[] = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const rel = prefix ? `${prefix}/${entry.name}` : entry.name; - if (IGNORE_DIRS.has(entry.name) || shouldIgnoreDir(rel)) continue; + if (IGNORE_DIRS.has(entry.name)) continue; if (entry.isDirectory()) { files.push(...walkDir(join(dir, entry.name), rel)); } else { diff --git a/packages/core/src/studio-api/routes/files.test.ts b/packages/core/src/studio-api/routes/files.test.ts index 08e8644eb..302831dba 100644 --- a/packages/core/src/studio-api/routes/files.test.ts +++ b/packages/core/src/studio-api/routes/files.test.ts @@ -239,6 +239,86 @@ tl.fromTo("#box", { opacity: 0, x: -50 }, { opacity: 1, x: 0, duration: 1.5, eas expect(result.parsed.animations[0].fromProperties?.x).toBe(-50); }); + it("rejects serialized non-finite mutation values before writing source", async () => { + const projectDir = createProjectDir(); + writeHtml(projectDir, "comp.html", FROMTO_COMP); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const anim = await getFirstAnimation(app, "comp.html"); + const before = readFileSync(join(projectDir, "comp.html"), "utf-8"); + const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "update-property", + animationId: anim.id, + property: "x", + value: Number.NaN, + }), + }); + const payload = (await res.json()) as { error?: string; fields?: string[] }; + + expect(res.status).toBe(400); + expect(payload.error).toContain("unsafe values"); + expect(payload.fields).toContain("body.value"); + expect(readFileSync(join(projectDir, "comp.html"), "utf-8")).toBe(before); + }); + + it("rejects unsafe DOM patch metadata before writing source", async () => { + const projectDir = createProjectDir(); + writeFileSync(join(projectDir, "index.html"), '
Before
'); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await app.request( + "http://localhost/projects/demo/file-mutations/patch-element/index.html", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + target: { id: "title", selectorIndex: Number.NaN }, + operations: [{ type: "text-content", property: "textContent", value: "After" }], + }), + }, + ); + const payload = (await response.json()) as { error?: string; fields?: string[] }; + + expect(response.status).toBe(400); + expect(payload.error).toContain("unsafe values"); + expect(payload.fields).toContain("body.target.selectorIndex"); + expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe( + '
Before
', + ); + }); + + it("allows DOM patch null values used for explicit style removals", async () => { + const projectDir = createProjectDir(); + writeFileSync( + join(projectDir, "index.html"), + '
Before
', + ); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await app.request( + "http://localhost/projects/demo/file-mutations/patch-element/index.html", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + target: { id: "title" }, + operations: [{ type: "inline-style", property: "opacity", value: null }], + }), + }, + ); + const payload = (await response.json()) as { changed?: boolean; content?: string }; + + expect(response.status).toBe(200); + expect(payload.changed).toBe(true); + expect(payload.content).not.toContain("opacity"); + }); + it("update-from-property returns 400 for a non-fromTo animation", async () => { const projectDir = createProjectDir(); const TO_COMP = `