diff --git a/apps/editor/app/import/import-client.tsx b/apps/editor/app/import/import-client.tsx new file mode 100644 index 0000000000..a96fe85152 --- /dev/null +++ b/apps/editor/app/import/import-client.tsx @@ -0,0 +1,220 @@ +'use client' + +import { type ValidateBuildJsonResult, validateBuildJson } from '@pascal-app/core' +import { useRouter } from 'next/navigation' +import { useCallback, useEffect, useRef, useState } from 'react' +import { MAX_IMPORT_BYTES, parseImportSrc } from '@/lib/import-src' + +type Phase = + | { kind: 'fetching' } + // createError keeps the review alive after a failed create: the + // validated graph stays on screen and Import can simply be retried — + // a refresh would re-fetch `src`, and a short-lived scan URL may + // already be gone (review feedback). + | { kind: 'review'; result: ValidateBuildJsonResult; createError?: string } + | { kind: 'creating'; result: ValidateBuildJsonResult } + | { 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') + // Synchronous re-entry guard: a second tap can fire before React + // re-renders into 'creating', and two scenes would be created (review + // feedback — especially likely on the mobile hand-off). + const creating = useRef(false) + + 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 + } + // Blob measures BYTES — text.length counts UTF-16 code units, and + // a graph full of non-ASCII names could pass here yet still 413 + // at the store (review feedback). + if (new Blob([text]).size > 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 + if (creating.current) return + creating.current = true + const review = phase.result + setPhase({ kind: 'creating', result: review }) + 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: 'review', + result: review, + createError: + response.status === 401 || response.status === 403 + ? 'You need to be signed in to import a scene.' + : response.status === 413 + ? 'The scene is too large for the scene store.' + : `Creating the scene failed (${response.status}).`, + }) + return + } + const meta = (await response.json()) as { id: string } + router.push(`/scene/${meta.id}`) + } catch (error) { + setPhase({ + kind: 'review', + result: review, + createError: + error instanceof Error ? error.message : 'Creating the scene failed.', + }) + } finally { + // Released in every path: after an error the user may retry. + creating.current = false + } + }, [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} +
  • + ))} +
+ )} +
+ + {phase.createError && ( +

{phase.createError}

+ )} + +
+ ) +} 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..3176ce13a8 --- /dev/null +++ b/apps/editor/lib/import-src.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'bun:test' +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..800c5700ea --- /dev/null +++ b/apps/editor/lib/import-src.ts @@ -0,0 +1,43 @@ +/** + * 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 the scene store's own limit + * (`DEFAULT_MAX_SCENE_BYTES` in the sqlite scene store, 10 MB): a file + * that passes review must not then fail `POST /api/scenes` with a 413. + */ +export const MAX_IMPORT_BYTES = 10 * 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/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,