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.
This commit is contained in:
parent
2f68b3e906
commit
30179bfdc3
|
|
@ -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<string | null> {
|
||||
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}.`,
|
||||
|
|
|
|||
Loading…
Reference in New Issue