-
-
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
53b688c
81715bc
1ced72b
bf33cb4
eae2ccc
5a4a46a
f30fc5e
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,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]) | ||
|
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. Scene name stays stale across srcLow Severity
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, | ||
| }), | ||
|
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]) | ||
|
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> | ||
| ) | ||
|
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> | ||
| ) | ||
| } | ||
| 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 '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) | ||
| }) | ||
| }) |
| 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.' } | ||
| } |


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