From 30179bfdc3332a749359088196c71a93e7773391 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Thu, 16 Jul 2026 15:39:05 +0000 Subject: [PATCH] fix: validate imported project JSON with a schema before persisting importProjectFromJSON only checked a few top-level fields before writing the project to storage, so a malformed .opencut.json could be saved and only fail later when the project is opened. Add a Zod schema that validates the full timeline shape (scenes -> tracks -> elements), settings, and timeline view state before saveProject, replacing the presence checks and the unchecked `as ExportedProjectJSON` cast. Zod is already a dependency, so no new deps. Extra/unknown fields are tolerated for forward compatibility; the raw parsed object is persisted so no element fields are dropped. --- apps/web/src/core/managers/project-manager.ts | 84 +++++++++++++++++-- 1 file changed, 77 insertions(+), 7 deletions(-) diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts index 28648b39..479f0251 100644 --- a/apps/web/src/core/managers/project-manager.ts +++ b/apps/web/src/core/managers/project-manager.ts @@ -30,6 +30,7 @@ import { getElementFontFamilies } from "@/lib/timeline/element-utils"; import { getRaisedProjectFpsForImportedMedia } from "@/lib/fps/utils"; import type { MediaAsset } from "@/lib/media/types"; import type { SerializedProject } from "@/services/storage/types"; +import { z } from "zod"; /** Schema version for the JSON export format. Increment when the format changes. */ const EXPORT_SCHEMA_VERSION = 1; @@ -53,6 +54,74 @@ export interface ExportedProjectJSON { media: ExportedMediaManifestEntry[]; } +/** + * Runtime validation for imported `.opencut.json` payloads. + * + * The importer persists the parsed structure directly to storage, so the whole + * timeline shape (scenes -> tracks -> elements), settings, and view state are + * validated here before {@link importProjectFromJSON} writes anything. Extra + * fields are tolerated for forward compatibility; missing or mistyped required + * fields are rejected so a malformed file cannot be saved and only fail later + * when the project is opened. + */ +const importedElementSchema = z.object({ + id: z.string(), + type: z.string(), + name: z.string(), + duration: z.number(), + startTime: z.number(), + trimStart: z.number(), + trimEnd: z.number(), +}); + +const importedTrackSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.enum(["video", "text", "audio", "graphic", "effect"]), + elements: z.array(importedElementSchema), +}); + +const importedSceneTracksSchema = z.object({ + overlay: z.array(importedTrackSchema), + main: importedTrackSchema, + audio: z.array(importedTrackSchema), +}); + +const importedSceneSchema = z.object({ + id: z.string(), + name: z.string(), + isMain: z.boolean(), + tracks: importedSceneTracksSchema, + bookmarks: z.array(z.unknown()).optional(), +}); + +const importedSettingsSchema = z.object({ + fps: z.number(), + canvasSize: z.object({ width: z.number(), height: z.number() }), + background: z.object({ type: z.string() }), +}); + +const importedTimelineViewStateSchema = z.object({ + zoomLevel: z.number(), + scrollLeft: z.number(), + playheadTime: z.number(), +}); + +const importedProjectJsonSchema = z.object({ + schema_version: z.number(), + project: z.object({ + metadata: z.object({ + name: z.string(), + duration: z.number().optional(), + }), + scenes: z.array(importedSceneSchema).min(1), + currentSceneId: z.string().optional(), + settings: importedSettingsSchema, + timelineViewState: importedTimelineViewStateSchema.optional(), + }), + media: z.array(z.unknown()).optional(), +}); + export interface MigrationState { isMigrating: boolean; fromVersion: number | null; @@ -861,20 +930,21 @@ export class ProjectManager { json: string; }): Promise { try { - const parsed = JSON.parse(json) as ExportedProjectJSON; + const parsedJson: unknown = JSON.parse(json); - if ( - !parsed.project || - !parsed.project.metadata || - !parsed.project.scenes || - parsed.project.scenes.length === 0 - ) { + const validation = importedProjectJsonSchema.safeParse(parsedJson); + if (!validation.success) { toast.error("Invalid project file", { description: "The file does not contain a valid OpenCut project.", }); return null; } + // Structure validated above, so it is safe to treat the payload as an + // exported project. We keep the original parsed object (rather than the + // stripped `validation.data`) to preserve every element/track field. + const parsed = parsedJson as ExportedProjectJSON; + if (parsed.schema_version !== EXPORT_SCHEMA_VERSION) { toast.error("Incompatible project file", { description: `Unsupported schema version ${parsed.schema_version}, expected ${EXPORT_SCHEMA_VERSION}.`,