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
195 changes: 195 additions & 0 deletions apps/editor/app/import/import-client.tsx
Original file line number Diff line number Diff line change
@@ -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=<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')

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

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

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>

<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"
>
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 '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)
})
})
39 changes: 39 additions & 0 deletions apps/editor/lib/import-src.ts
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Import cap exceeds scene store

Medium Severity

MAX_IMPORT_BYTES is 25 MB while the SQLite scene store defaults to 10 MB (DEFAULT_MAX_SCENE_BYTES). A build that passes review can still fail on POST /api/scenes with 413, and the UI only shows a generic create failure instead of a size explanation.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 81715bc. Configure here.


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.' }
}
8 changes: 4 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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