-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Add /import?src=<url>: a hand-off point for scanning apps #720
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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]) | ||
|
|
||
| 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, | ||
| }), | ||
|
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> | ||
| ) | ||
| } | ||
| 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> | ||
| ) | ||
| } |
| 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) | ||
| }) | ||
| }) |
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Import cap exceeds scene storeMedium Severity
Additional Locations (2)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.' } | ||
| } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.


Uh oh!
There was an error while loading. Please reload this page.