fix: validate renderUrl as absolute http/https before loading in artboard iframe
EmptyArtboardContent.save() accepted any string as renderUrl — a relative path or the Originmain app URL would cause the artboard iframe to resolve against the Originmain origin, loading the app inside itself. - Added URL validation (same as UrlOnboardingOverlay) with inline error UI - toCanvasArtboard now validates renderUrl at read time so stale bad data already in the DB is also guarded against - CanvasArtboard now carries artboard_type, isolation_component, isolation_file, isolation_props so phase-3 isolation artboards render correctly; previously these fields were silently dropped by the hook Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
cdf6f8caf3
commit
f73c7f83d6
@@ -577,16 +577,30 @@ function EmptyArtboardContent({
|
|||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [urlValue, setUrlValue] = useState('');
|
const [urlValue, setUrlValue] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [urlError, setUrlError] = useState('');
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
const url = urlValue.trim();
|
const raw = urlValue.trim();
|
||||||
if (!url) return;
|
if (!raw) return;
|
||||||
|
|
||||||
|
// Validate: must be an absolute http/https URL so the iframe doesn't
|
||||||
|
// accidentally resolve a relative path against the Originmain origin
|
||||||
|
// (which would load the Originmain app inside itself).
|
||||||
|
try {
|
||||||
|
const parsed = new URL(raw);
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error('bad protocol');
|
||||||
|
} catch {
|
||||||
|
setUrlError('Enter a valid http:// or https:// URL (e.g. http://localhost:4170)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUrlError('');
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
await fetch(`/api/artboards/${id}`, {
|
await fetch(`/api/artboards/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
metadata_jsonb: { renderUrl: url, width, height, x: 0, y: 0 },
|
metadata_jsonb: { renderUrl: raw, width, height, x: 0, y: 0 },
|
||||||
}),
|
}),
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
|
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
|
||||||
@@ -620,15 +634,21 @@ function EmptyArtboardContent({
|
|||||||
autoFocus
|
autoFocus
|
||||||
type="url"
|
type="url"
|
||||||
value={urlValue}
|
value={urlValue}
|
||||||
onChange={(e) => setUrlValue(e.target.value)}
|
onChange={(e) => { setUrlValue(e.target.value); setUrlError(''); }}
|
||||||
placeholder="http://localhost:4170"
|
placeholder="http://localhost:4170"
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') void save(); if (e.key === 'Escape') setEditing(false); e.stopPropagation(); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') void save(); if (e.key === 'Escape') setEditing(false); e.stopPropagation(); }}
|
||||||
style={{
|
style={{
|
||||||
padding: '7px 10px', borderRadius: 6, border: '1.5px solid #0066FF',
|
padding: '7px 10px', borderRadius: 6,
|
||||||
|
border: `1.5px solid ${urlError ? '#EF4444' : '#0066FF'}`,
|
||||||
fontSize: 11, fontFamily: 'inherit', width: '100%',
|
fontSize: 11, fontFamily: 'inherit', width: '100%',
|
||||||
boxSizing: 'border-box' as const, color: '#0A0A0A', background: '#fff',
|
boxSizing: 'border-box' as const, color: '#0A0A0A', background: '#fff',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{urlError && (
|
||||||
|
<p style={{ margin: 0, fontSize: 10, color: '#EF4444', lineHeight: 1.4, fontFamily: 'inherit' }}>
|
||||||
|
{urlError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p style={hintStyle}>
|
<p style={hintStyle}>
|
||||||
Use the CLI proxy URL or a preview deployment URL with @originmain/live installed
|
Use the CLI proxy URL or a preview deployment URL with @originmain/live installed
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import type { Artboard, InsertArtboard } from '@originmain/origin-graph';
|
import type { Artboard, InsertArtboard, ArtboardType } from '@originmain/origin-graph';
|
||||||
|
|
||||||
export interface CanvasArtboard {
|
export interface CanvasArtboard {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,19 +11,53 @@ export interface CanvasArtboard {
|
|||||||
renderUrl?: string;
|
renderUrl?: string;
|
||||||
/** Route path appended to renderUrl when rendering a specific screen, e.g. "/dashboard". */
|
/** Route path appended to renderUrl when rendering a specific screen, e.g. "/dashboard". */
|
||||||
route?: string;
|
route?: string;
|
||||||
|
/** Controls which renderer is used: route (default), isolation, or static. */
|
||||||
|
artboard_type?: ArtboardType;
|
||||||
|
/** Isolation artboard: exported component display name. */
|
||||||
|
isolation_component?: string | null;
|
||||||
|
/** Isolation artboard: workspace-relative source file path. */
|
||||||
|
isolation_file?: string | null;
|
||||||
|
/** Isolation artboard: live prop overrides forwarded to the isolation frame. */
|
||||||
|
isolation_props?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function toCanvasArtboard(ab: Artboard): CanvasArtboard | null {
|
function toCanvasArtboard(ab: Artboard): CanvasArtboard | null {
|
||||||
const meta = ab.metadata_jsonb;
|
const meta = ab.metadata_jsonb;
|
||||||
const x = typeof meta['x'] === 'number' ? meta['x'] : null;
|
|
||||||
const y = typeof meta['y'] === 'number' ? meta['y'] : null;
|
// x/y: prefer promoted top-level columns (migration 012), fall back to metadata_jsonb.
|
||||||
|
const x = typeof ab.canvas_x === 'number' ? ab.canvas_x
|
||||||
|
: typeof meta['x'] === 'number' ? meta['x'] : null;
|
||||||
|
const y = typeof ab.canvas_y === 'number' ? ab.canvas_y
|
||||||
|
: typeof meta['y'] === 'number' ? meta['y'] : null;
|
||||||
|
// width/height come from metadata_jsonb (top-level DB columns default to 1440/900,
|
||||||
|
// not from the user's intent — metadata_jsonb carries the actual artboard dimensions).
|
||||||
const width = typeof meta['width'] === 'number' ? meta['width'] : null;
|
const width = typeof meta['width'] === 'number' ? meta['width'] : null;
|
||||||
const height = typeof meta['height'] === 'number' ? meta['height'] : null;
|
const height = typeof meta['height'] === 'number' ? meta['height'] : null;
|
||||||
|
|
||||||
if (x === null || y === null || width === null || height === null) return null;
|
if (x === null || y === null || width === null || height === null) return null;
|
||||||
|
|
||||||
const base: CanvasArtboard = { id: ab.id, label: ab.name, x, y, width, height };
|
const base: CanvasArtboard = { id: ab.id, label: ab.name, x, y, width, height };
|
||||||
if (typeof meta['renderUrl'] === 'string') base.renderUrl = meta['renderUrl'];
|
|
||||||
|
// renderUrl must be an absolute http/https URL — skip relative strings to
|
||||||
|
// avoid loading the Originmain app inside itself when the iframe resolves
|
||||||
|
// against the Originmain origin.
|
||||||
|
if (typeof meta['renderUrl'] === 'string') {
|
||||||
|
const raw = meta['renderUrl'] as string;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(raw);
|
||||||
|
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') base.renderUrl = raw;
|
||||||
|
} catch { /* invalid URL stored in DB — ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof meta['route'] === 'string' && meta['route']) base.route = meta['route'];
|
if (typeof meta['route'] === 'string' && meta['route']) base.route = meta['route'];
|
||||||
|
|
||||||
|
// Phase 3 isolation artboard fields (top-level DB columns, migration 012).
|
||||||
|
if (ab.artboard_type) base.artboard_type = ab.artboard_type;
|
||||||
|
if (ab.isolation_component !== undefined) base.isolation_component = ab.isolation_component ?? null;
|
||||||
|
if (ab.isolation_file !== undefined) base.isolation_file = ab.isolation_file ?? null;
|
||||||
|
if (ab.isolation_props !== undefined) base.isolation_props = ab.isolation_props as Record<string, unknown> ?? null;
|
||||||
|
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user