diff --git a/apps/editor/app/import/import-client.tsx b/apps/editor/app/import/import-client.tsx new file mode 100644 index 0000000000..97e8686cb4 --- /dev/null +++ b/apps/editor/app/import/import-client.tsx @@ -0,0 +1,195 @@ +'use client' + +import { type ValidateBuildJsonResult, validateBuildJson } from '@pascal-app/core' +import { useRouter } from 'next/navigation' +import { useCallback, useEffect, useState } from 'react' +import { MAX_IMPORT_BYTES, parseImportSrc } from '@/lib/import-src' + +type Phase = + | { kind: 'fetching' } + | { kind: 'review'; result: ValidateBuildJsonResult } + | { kind: 'creating' } + | { kind: 'error'; message: string } + +/** + * Client half of `/import?src=`: fetches the build JSON in the + * visitor's browser (same trust model as dropping a file on Load Build — + * the target must allow CORS), runs the same `validateBuildJson` + * pre-flight as Load Build, shows what would be imported, and only on an + * explicit click creates the scene through the regular `POST /api/scenes` + * route — so auth, origin checks and graph validation all apply + * unchanged. + */ +export function ImportClient({ src, name }: { src: string | null; name: string | null }) { + const router = useRouter() + const [phase, setPhase] = useState({ kind: 'fetching' }) + const [sceneName, setSceneName] = useState(name ?? 'Imported scene') + + useEffect(() => { + // A new src restarts the flow: reset to fetching so a stale review + // (and its Import button) can never act on the previous file, and + // ignore every state update from a superseded run — an abort must + // not surface as an error either. + setPhase({ kind: 'fetching' }) + const parsedSrc = parseImportSrc(src) + if (!parsedSrc.ok) { + setPhase({ kind: 'error', message: parsedSrc.reason }) + return + } + let cancelled = false + const controller = new AbortController() + const update = (next: Phase) => { + if (!cancelled) setPhase(next) + } + ;(async () => { + let response: Response + try { + response = await fetch(parsedSrc.url, { signal: controller.signal }) + } catch { + update({ + kind: 'error', + message: + 'The file could not be fetched. The server hosting it must allow cross-origin requests (CORS).', + }) + return + } + if (!response.ok) { + update({ kind: 'error', message: `The file could not be fetched (${response.status}).` }) + return + } + const declared = Number(response.headers.get('content-length') ?? 0) + if (declared > MAX_IMPORT_BYTES) { + update({ kind: 'error', message: 'The file is too large to import.' }) + return + } + let text: string + try { + text = await response.text() + } catch { + update({ kind: 'error', message: 'The file could not be read.' }) + return + } + if (text.length > MAX_IMPORT_BYTES) { + update({ kind: 'error', message: 'The file is too large to import.' }) + return + } + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + update({ kind: 'error', message: 'The file could not be parsed as JSON.' }) + return + } + update({ kind: 'review', result: validateBuildJson(parsed) }) + })() + return () => { + cancelled = true + controller.abort() + } + }, [src]) + + const handleImport = useCallback(async () => { + if (phase.kind !== 'review' || !phase.result.parsed) return + setPhase({ kind: 'creating' }) + try { + const response = await fetch('/api/scenes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: sceneName || 'Imported scene', + graph: phase.result.parsed, + }), + }) + if (!response.ok) { + setPhase({ + kind: 'error', + message: + response.status === 401 || response.status === 403 + ? 'You need to be signed in to import a scene.' + : `Creating the scene failed (${response.status}).`, + }) + return + } + const meta = (await response.json()) as { id: string } + router.push(`/scene/${meta.id}`) + } catch (error) { + setPhase({ + kind: 'error', + message: error instanceof Error ? error.message : 'Creating the scene failed.', + }) + } + }, [phase, router, sceneName]) + + if (phase.kind === 'fetching') { + return

Fetching the scene…

+ } + if (phase.kind === 'creating') { + return

Creating the scene…

+ } + if (phase.kind === 'error') { + return ( +
+

{phase.message}

+
+ ) + } + + const { result } = phase + const typeEntries = Object.entries(result.stats.byType).sort((a, b) => b[1] - a[1]) + + return ( +
+
+ + setSceneName(event.target.value)} + value={sceneName} + /> + +

Contents

+

+ {result.stats.total} node{result.stats.total === 1 ? '' : 's'} + {result.stats.floorAreaM2 > 0 + ? ` · ${Math.round(result.stats.floorAreaM2)} m² of floor` + : ''} +

+ {typeEntries.length > 0 && ( +

+ {typeEntries.map(([type, count]) => `${count} ${type}`).join(' · ')} +

+ )} + + {result.errors.length > 0 && ( +
    + {result.errors.map((issue) => ( +
  • + {issue.message} +
  • + ))} +
+ )} + {result.warnings.length > 0 && ( +
    + {result.warnings.map((issue) => ( +
  • + {issue.message} +
  • + ))} +
+ )} +
+ + +
+ ) +} diff --git a/apps/editor/app/import/page.tsx b/apps/editor/app/import/page.tsx new file mode 100644 index 0000000000..446baa5f79 --- /dev/null +++ b/apps/editor/app/import/page.tsx @@ -0,0 +1,45 @@ +import Link from 'next/link' +import { ImportClient } from './import-client' + +export const dynamic = 'force-dynamic' + +/** + * `/import?src=[&name=]` — the hand-off point for + * scanning apps and other external tools: they host a build JSON at a + * URL (CORS-enabled) and open this page; the visitor reviews what the + * file contains and imports it as a new scene of their own. + */ +export default async function ImportPage({ + searchParams, +}: { + searchParams: Promise<{ src?: string; name?: string }> +}) { + const params = await searchParams + + return ( +
+
+
+ +
+
+ +
+

Import a scene

+

+ Review the file before it becomes a scene. Nothing is created until you confirm. +

+ +
+
+ ) +} diff --git a/apps/editor/lib/import-src.test.ts b/apps/editor/lib/import-src.test.ts new file mode 100644 index 0000000000..15ab49acb6 --- /dev/null +++ b/apps/editor/lib/import-src.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { parseImportSrc } from './import-src' + +describe('parseImportSrc', () => { + it('accepts plain https URLs', () => { + const result = parseImportSrc('https://example.com/scan/pascal.json') + expect(result.ok).toBe(true) + }) + + it('accepts http for localhost during development', () => { + expect(parseImportSrc('http://localhost:8080/scene.json').ok).toBe(true) + expect(parseImportSrc('http://127.0.0.1/scene.json').ok).toBe(true) + }) + + it('rejects http for non-local hosts', () => { + expect(parseImportSrc('http://example.com/scene.json').ok).toBe(false) + }) + + it('rejects non-http schemes', () => { + expect(parseImportSrc('javascript:alert(1)').ok).toBe(false) + expect(parseImportSrc('file:///etc/passwd').ok).toBe(false) + expect(parseImportSrc('ftp://example.com/x.json').ok).toBe(false) + }) + + it('rejects embedded credentials', () => { + expect(parseImportSrc('https://user:pass@example.com/x.json').ok).toBe(false) + }) + + it('rejects relative and malformed values', () => { + expect(parseImportSrc('/scene.json').ok).toBe(false) + expect(parseImportSrc('').ok).toBe(false) + expect(parseImportSrc(undefined).ok).toBe(false) + }) +}) diff --git a/apps/editor/lib/import-src.ts b/apps/editor/lib/import-src.ts new file mode 100644 index 0000000000..959094b9be --- /dev/null +++ b/apps/editor/lib/import-src.ts @@ -0,0 +1,39 @@ +/** + * Validation for the `src` parameter of the `/import` page: the URL a + * scanning app (or any external tool) hands us to import a build JSON + * from. The fetch itself happens client-side in the visitor's browser — + * same trust model as dropping a file on Load Build — so the checks here + * are about not being tricked into requesting something that is not a + * plain https resource, not about SSRF (no server ever fetches it). + */ + +/** Hard cap on the fetched document; matches generous hand-made scenes. */ +export const MAX_IMPORT_BYTES = 25 * 1024 * 1024 + +export type ImportSrcResult = { ok: true; url: URL } | { ok: false; reason: string } + +/** + * Accepts only absolute `https:` URLs without embedded credentials. + * `http:` is allowed for localhost only, so a scan app on the same + * machine can hand over a file during development. + */ +export function parseImportSrc(raw: string | null | undefined): ImportSrcResult { + if (!raw) { + return { ok: false, reason: 'Missing `src` parameter.' } + } + let url: URL + try { + url = new URL(raw) + } catch { + return { ok: false, reason: 'The `src` parameter is not an absolute URL.' } + } + if (url.username || url.password) { + return { ok: false, reason: 'Credentials in the `src` URL are not allowed.' } + } + const isLocalhost = + url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]' + if (url.protocol === 'https:' || (url.protocol === 'http:' && isLocalhost)) { + return { ok: true, url } + } + return { ok: false, reason: 'Only https URLs can be imported.' } +} diff --git a/bun.lock b/bun.lock index 8e4926934d..63511a4923 100644 --- a/bun.lock +++ b/bun.lock @@ -579,7 +579,7 @@ "@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.17", "", {}, "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="], - "@mint/pascal-plugin": ["@mint/pascal-plugin@github:mintdotgg/mint-pascal-plugin#902c546", { "peerDependencies": { "@pascal-app/core": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "@pascal-app/editor": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "@pascal-app/viewer": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "react": "^18 || ^19", "three": "^0.185" } }, "mintdotgg-mint-pascal-plugin-902c546"], + "@mint/pascal-plugin": ["@mint/pascal-plugin@github:mintdotgg/mint-pascal-plugin#902c546", { "peerDependencies": { "@pascal-app/core": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "@pascal-app/editor": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "@pascal-app/viewer": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "react": "^18 || ^19", "three": "^0.185" } }, "mintdotgg-mint-pascal-plugin-902c546", "sha512-/itUH9r9OIP8ZPrklW8iWe6B2SDOtlL1m8r9hlVM4Rw5josYtDdkDKQ37FbanvxxuUKcQnukHbtxWe3svRaCrQ=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], @@ -783,11 +783,11 @@ "@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"], - "@pascal-app/plugin-bones": ["@pascal-app/plugin-bones@github:pascalorg/plugin-bones#5679260", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-bones-5679260"], + "@pascal-app/plugin-bones": ["@pascal-app/plugin-bones@github:pascalorg/plugin-bones#5679260", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-bones-5679260", "sha512-uQkyHHOl/VuYx2+d/MmcJui/KEAQZ2UUnO4ywp1XaopmD2YfN+Yx/fR/lS/VL2joEoyyZeBLC3KaXK1Sx5qEvg=="], - "@pascal-app/plugin-streetscape": ["@pascal-app/plugin-streetscape@github:sudhir9297/streetscape-pascal-plugin#1c04ec9", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "sudhir9297-streetscape-pascal-plugin-1c04ec9"], + "@pascal-app/plugin-streetscape": ["@pascal-app/plugin-streetscape@github:sudhir9297/streetscape-pascal-plugin#1c04ec9", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "sudhir9297-streetscape-pascal-plugin-1c04ec9", "sha512-X7Zg7wi0ghZRcTtbH5LF6xye493uSZ2ft3AmAQBtguBCU6VCwCexA7pdKDr5AnVxX/JRj2s6JSd/OX4aIZ9Y6Q=="], - "@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@github:pascalorg/plugin-trees#56d978c", { "dependencies": { "@dgreenheck/ez-tree": "^1.1.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-trees-56d978c"], + "@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@github:pascalorg/plugin-trees#56d978c", { "dependencies": { "@dgreenheck/ez-tree": "^1.1.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-trees-56d978c", "sha512-16VzWot1oadvxCPqsRwMJbaP0a3u5FESFy7F7++pY5wAAFq9JTFwzHvKAsllrY5TZ5TJ7Y5vSqvbmQk8sy8HaA=="], "@pascal-app/viewer": ["@pascal-app/viewer@workspace:packages/viewer"], diff --git a/packages/core/src/validation/validate-build-json.test.ts b/packages/core/src/validation/validate-build-json.test.ts index 64e3d0972e..e7d20ebb25 100644 --- a/packages/core/src/validation/validate-build-json.test.ts +++ b/packages/core/src/validation/validate-build-json.test.ts @@ -126,3 +126,48 @@ describe('validateBuildJson with registered plugin kinds', () => { expect(result.schemaIssues[0]?.nodeType).toBe('trees:tree') }) }) + +describe('scene materials', () => { + const minimalGraph = () => ({ + nodes: { + building_1: { id: 'building_1', type: 'building', children: ['level_1'] }, + level_1: { id: 'level_1', type: 'level', children: [] }, + }, + rootNodeIds: ['building_1'], + }) + + test('carries valid materials through to parsed', () => { + const result = validateBuildJson({ + ...minimalGraph(), + materials: { + mat_a: { + id: 'mat_a', + name: 'Measured cabinet', + material: { properties: { color: '#595c5a' } }, + }, + }, + }) + expect(result.ok).toBe(true) + expect(result.parsed?.materials?.mat_a?.name).toBe('Measured cabinet') + }) + + test('skips invalid material entries with a warning, keeps the rest', () => { + const result = validateBuildJson({ + ...minimalGraph(), + materials: { + mat_ok: { id: 'mat_ok', name: 'Fine', material: {} }, + mat_bad: { name: 42 }, + }, + }) + expect(result.ok).toBe(true) + expect(Object.keys(result.parsed?.materials ?? {})).toEqual(['mat_ok']) + expect(result.warnings.some((w) => w.code === 'invalid_materials')).toBe(true) + }) + + test('warns when materials is not an object', () => { + const result = validateBuildJson({ ...minimalGraph(), materials: 'nope' }) + expect(result.ok).toBe(true) + expect(result.parsed?.materials).toBeUndefined() + expect(result.warnings.some((w) => w.code === 'invalid_materials')).toBe(true) + }) +}) diff --git a/packages/core/src/validation/validate-build-json.ts b/packages/core/src/validation/validate-build-json.ts index 1257a43ef2..99b36092fd 100644 --- a/packages/core/src/validation/validate-build-json.ts +++ b/packages/core/src/validation/validate-build-json.ts @@ -1,4 +1,5 @@ import { nodeRegistry } from '../registry' +import { SceneMaterial } from '../schema/scene-material' import { AnyNode, type AnyNodeType } from '../schema/types' import { healSceneNodes } from '../utils/heal-scene-graph' @@ -24,6 +25,8 @@ export type ParsedBuildJson = { nodes: Record rootNodeIds: string[] installedPlugins?: string[] + /** Scene materials referenced by node `slots` (`scene:`). */ + materials?: Record } export type SchemaIssue = { @@ -111,6 +114,7 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult { const nodesRaw = input.nodes const rootNodeIdsRaw = input.rootNodeIds const installedPluginsRaw = input.installedPlugins + const materialsRaw = input.materials if (!isPlainObject(nodesRaw)) { errors.push({ @@ -160,6 +164,38 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult { }) } + // Scene materials ride along with the graph: nodes reference them by + // `scene:` slot refs, so dropping the table here silently strips + // every custom finish from the imported scene. Invalid entries are + // skipped one by one — a bad material must not take the import down. + let materials: Record | undefined + if (isPlainObject(materialsRaw)) { + let skipped = 0 + const kept: Record = {} + for (const [id, value] of Object.entries(materialsRaw)) { + const result = SceneMaterial.safeParse(value) + if (result.success) { + kept[id] = result.data + } else { + skipped += 1 + } + } + if (Object.keys(kept).length > 0) materials = kept + if (skipped > 0) { + warnings.push({ + severity: 'warning', + code: 'invalid_materials', + message: `Ignored ${skipped} invalid scene material${skipped === 1 ? '' : 's'}.`, + }) + } + } else if (materialsRaw !== undefined) { + warnings.push({ + severity: 'warning', + code: 'invalid_materials', + message: 'Ignored invalid "materials" — expected an object of id → material.', + }) + } + if (strippedChildRefs > 0 || droppedWallIds.length > 0) { warnings.push({ severity: 'warning', @@ -373,6 +409,7 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult { nodes, rootNodeIds, ...(installedPlugins ? { installedPlugins } : {}), + ...(materials ? { materials } : {}), } : null, stats, diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx index fecc75978c..cc2fcdb803 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx @@ -291,12 +291,19 @@ export function SettingsPanel({ nodes: Record rootNodeIds: string[] installedPlugins?: string[] + materials?: Record }) => { const currentScene = useScene.getState() setScene( parsed.nodes as Parameters[0], parsed.rootNodeIds as Parameters[1], { + // Without this, every `scene:` slot ref in the imported file + // pointed at a material that no longer existed — custom finishes + // silently reverted to defaults on import. + materials: parsed.materials as NonNullable< + Parameters[2] + >['materials'], installedPlugins: parsed.installedPlugins ?? currentScene.installedPlugins, hasExplicitPluginInstallState: parsed.installedPlugins !== undefined || currentScene.hasExplicitPluginInstallState,