Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 220 additions & 0 deletions apps/editor/app/import/import-client.tsx
Original file line number Diff line number Diff line change
@@ -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=<url>`: 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<Phase>({ 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])
Comment thread
cursor[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scene name stays stale across src

Low Severity

sceneName is initialized from the name query once and the fetch effect only depends on src. A new /import?src=…&name=… navigation reuses the client instance, refetches the file, and still keeps the previous name, so the created scene can be labeled incorrectly.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f30fc5e. Configure here.


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,
}),
Comment thread
cursor[bot] marked this conversation as resolved.
})
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])
Comment thread
cursor[bot] marked this conversation as resolved.

if (phase.kind === 'fetching') {
return <p className="text-muted-foreground text-sm">Fetching the scene…</p>
}
if (phase.kind === 'creating') {
return <p className="text-muted-foreground text-sm">Creating the scene…</p>
}
if (phase.kind === 'error') {
return (
<div className="rounded-xl border border-border/60 bg-background p-6">
<p className="text-destructive text-sm">{phase.message}</p>
</div>
)
Comment thread
cursor[bot] marked this conversation as resolved.
}

const { result } = phase
const typeEntries = Object.entries(result.stats.byType).sort((a, b) => b[1] - a[1])

return (
<div className="space-y-6">
<div className="rounded-xl border border-border/60 bg-background p-6">
<label className="mb-1 block font-medium text-muted-foreground text-xs uppercase">
Scene name
</label>
<input
className="w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm"
onChange={(event) => setSceneName(event.target.value)}
value={sceneName}
/>

<p className="mt-4 mb-1 font-medium text-muted-foreground text-xs uppercase">Contents</p>
<p className="text-sm">
{result.stats.total} node{result.stats.total === 1 ? '' : 's'}
{result.stats.floorAreaM2 > 0
? ` · ${Math.round(result.stats.floorAreaM2)} m² of floor`
: ''}
</p>
{typeEntries.length > 0 && (
<p className="mt-1 text-muted-foreground text-xs">
{typeEntries.map(([type, count]) => `${count} ${type}`).join(' · ')}
</p>
)}

{result.errors.length > 0 && (
<ul className="mt-4 space-y-1">
{result.errors.map((issue) => (
<li className="text-destructive text-xs" key={`${issue.code}:${issue.message}`}>
{issue.message}
</li>
))}
</ul>
)}
{result.warnings.length > 0 && (
<ul className="mt-2 space-y-1">
{result.warnings.map((issue) => (
<li className="text-muted-foreground text-xs" key={`${issue.code}:${issue.message}`}>
{issue.message}
</li>
))}
</ul>
)}
</div>

{phase.createError && (
<p className="text-destructive text-sm">{phase.createError}</p>
)}
<button
className="rounded-md border border-border bg-accent px-4 py-2 font-medium text-sm hover:bg-accent/80 disabled:opacity-50"
disabled={!result.ok || !result.parsed}
onClick={handleImport}
type="button"
>
{phase.createError ? 'Try again' : 'Import as a new scene'}
</button>
</div>
)
}
45 changes: 45 additions & 0 deletions apps/editor/app/import/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Link from 'next/link'
import { ImportClient } from './import-client'

export const dynamic = 'force-dynamic'

/**
* `/import?src=<https-url>[&name=<scene 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 (
<div className="min-h-screen bg-background">
<header className="sticky top-0 z-10 border-border border-b bg-background/95 backdrop-blur">
<div className="container mx-auto flex items-center justify-between gap-4 px-6 py-4">
<nav className="flex items-center gap-4 text-sm">
<Link
className="text-muted-foreground transition-colors hover:text-foreground"
href="/"
>
Home
</Link>
<span className="text-muted-foreground">/</span>
<span className="font-medium text-foreground">Import</span>
</nav>
</div>
</header>

<main className="container mx-auto max-w-2xl px-6 py-12">
<h1 className="mb-2 font-bold text-3xl">Import a scene</h1>
<p className="mb-8 text-muted-foreground text-sm">
Review the file before it becomes a scene. Nothing is created until you confirm.
</p>
<ImportClient name={params.name ?? null} src={params.src ?? null} />
</main>
</div>
)
}
34 changes: 34 additions & 0 deletions apps/editor/lib/import-src.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
43 changes: 43 additions & 0 deletions apps/editor/lib/import-src.ts
Original file line number Diff line number Diff line change
@@ -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.' }
}
45 changes: 45 additions & 0 deletions packages/core/src/validation/validate-build-json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
Loading