Files
site-builder/craft/src/components/media/ImageBlock.tsx
T
shadowdaoandClaude Opus 4.8 48d0441be3 fix(builder): neutralize javascript:/breakout URLs in export
Every user-controlled URL emitted by a component's static toHtml (href,
src, action, and CSS url()) now runs through escapeAttr(safeUrl(...))
before hitting the exported HTML string, closing the XSS gaps flagged
in the A2 review (PricingTable buttonHref was fully unescaped, Gallery/
HeroSimple/ImageBlock/VideoBlock/etc. lacked scheme filtering) plus a
few more found via a grep sweep of href=/src=/action=/url( inside
toHtml template strings: MapEmbed's iframe src and the shared
form-relay-wiring fallback form action (a javascript: form action
executes on submit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:05:57 -07:00

482 lines
20 KiB
TypeScript

import React, { CSSProperties, useCallback, useRef, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23f1f5f9'/%3E%3Cstop offset='100%25' stop-color='%23e2e8f0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23bg)' width='400' height='300' rx='12'/%3E%3Crect x='2' y='2' width='396' height='296' rx='10' fill='none' stroke='%23cbd5e1' stroke-width='2' stroke-dasharray='8 4'/%3E%3Cg transform='translate(200,110)'%3E%3Crect x='-28' y='-28' width='56' height='56' rx='12' fill='%23cbd5e1' opacity='0.5'/%3E%3Cpath d='M-12 8 L-4 -2 L2 4 L8 -6 L16 8Z' fill='%2394a3b8'/%3E%3Ccircle cx='-6' cy='-10' r='5' fill='%2394a3b8'/%3E%3C/g%3E%3Ctext x='200' y='160' text-anchor='middle' fill='%2364748b' font-family='Inter,sans-serif' font-size='15' font-weight='500'%3EDrop image here%3C/text%3E%3Ctext x='200' y='182' text-anchor='middle' fill='%2394a3b8' font-family='Inter,sans-serif' font-size='12'%3Eor click to upload%3C/text%3E%3C/svg%3E";
interface ImageBlockProps {
src?: string;
alt?: string;
style?: CSSProperties;
}
// Helper: upload a file to the WHP API and return the proxy URL
async function uploadToWhp(file: File): Promise<string | null> {
const cfg = (window as any).WHP_CONFIG;
if (!cfg) return URL.createObjectURL(file); // Standalone fallback
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, {
method: 'POST',
headers: { 'X-CSRF-Token': cfg.csrfToken },
body: formData,
});
const data = await resp.json();
if (data.success && data.url) return data.url;
return null;
} catch { return null; }
}
export const ImageBlock: UserComponent<ImageBlockProps> = ({
src = PLACEHOLDER_SRC,
alt = '',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
actions: { setProp },
} = useNode((node) => ({ selected: node.events.selected }));
const imgRef = useRef<HTMLImageElement | null>(null);
const isPlaceholder = !src || src === PLACEHOLDER_SRC || src.startsWith('data:image/svg');
// Handle drag-and-drop of files directly onto the image
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer.files?.[0];
if (file && file.type.startsWith('image/')) {
const url = await uploadToWhp(file);
if (url) setProp((p: ImageBlockProps) => { p.src = url; });
}
}, [setProp]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}, []);
return (
<img
ref={(ref: HTMLImageElement | null) => {
imgRef.current = ref;
if (ref) connect(drag(ref));
}}
src={src}
alt={alt || 'Image'}
onDrop={handleDrop}
onDragOver={handleDragOver}
style={{
display: 'block',
maxWidth: '100%',
outline: 'none',
cursor: selected ? 'move' : 'pointer',
...style,
}}
/>
);
};
/* ---------- Helpers for parsing CSS unit values ---------- */
type SizeUnit = 'px' | '%' | 'auto';
function parseSizeValue(value: string | number | undefined): { num: string; unit: SizeUnit } {
if (!value || value === 'auto') return { num: '', unit: 'auto' };
const str = String(value);
if (str === 'auto') return { num: '', unit: 'auto' };
const match = str.match(/^(\d+(?:\.\d+)?)\s*(px|%)$/);
if (match) return { num: match[1], unit: match[2] as SizeUnit };
// Pure number = px
if (/^\d+(?:\.\d+)?$/.test(str)) return { num: str, unit: 'px' };
return { num: '', unit: 'px' };
}
function buildSizeString(num: string, unit: SizeUnit): string | undefined {
if (unit === 'auto') return 'auto';
if (!num) return undefined;
return `${num}${unit}`;
}
type Alignment = 'left' | 'center' | 'right';
function detectAlignment(style: CSSProperties | undefined): Alignment {
if (!style) return 'left';
const ml = style.marginLeft;
const mr = style.marginRight;
if (ml === 'auto' && mr === 'auto') return 'center';
if (ml === 'auto' && mr !== 'auto') return 'right';
return 'left';
}
/* ---------- Settings panel ---------- */
const ImageBlockSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as ImageBlockProps,
}));
const isPlaceholder = !props.src || props.src === PLACEHOLDER_SRC || props.src?.startsWith('data:image/svg');
const fileInputRef = useRef<HTMLInputElement>(null);
const [showBrowser, setShowBrowser] = useState(false);
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
const [browserLoading, setBrowserLoading] = useState(false);
// Sizing unit state
const widthParsed = parseSizeValue(props.style?.width);
const [widthUnit, setWidthUnit] = useState<SizeUnit>(widthParsed.unit === 'auto' ? 'px' : widthParsed.unit);
const heightParsed = parseSizeValue(props.style?.height);
const [heightUnit, setHeightUnit] = useState<SizeUnit>(heightParsed.unit === 'auto' ? 'px' : heightParsed.unit);
const maxWidthParsed = parseSizeValue(props.style?.maxWidth);
const [maxWidthUnit, setMaxWidthUnit] = useState<SizeUnit>(maxWidthParsed.unit === 'auto' ? '%' : maxWidthParsed.unit);
const alignment = detectAlignment(props.style);
const handleUpload = useCallback(async (file: File) => {
const url = await uploadToWhp(file);
if (url) setProp((p: ImageBlockProps) => { p.src = url; });
}, [setProp]);
const handleBrowse = useCallback(async () => {
if (showBrowser) { setShowBrowser(false); return; }
const cfg = (window as any).WHP_CONFIG;
if (!cfg) return;
setBrowserLoading(true);
try {
const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
const data = await resp.json();
if (data.success && Array.isArray(data.assets)) {
const images = data.assets.filter((a: any) => (a.type || '').startsWith('image'));
setBrowserAssets(images);
setShowBrowser(true);
}
} catch (e) {
console.error('Browse failed:', e);
} finally {
setBrowserLoading(false);
}
}, [showBrowser]);
const radiusPresets = ['0', '8px', '16px', '32px', '50%'];
const setPropStyle = useCallback((key: string, value: string | undefined) => {
setProp((p: ImageBlockProps) => {
p.style = { ...p.style, [key]: value };
});
}, [setProp]);
const setAlignment = useCallback((align: Alignment) => {
setProp((p: ImageBlockProps) => {
const s = { ...p.style };
if (align === 'center') {
s.marginLeft = 'auto';
s.marginRight = 'auto';
s.display = 'block';
} else if (align === 'right') {
s.marginLeft = 'auto';
s.marginRight = undefined;
s.display = 'block';
} else {
s.marginLeft = undefined;
s.marginRight = undefined;
s.display = 'block';
}
p.style = s;
});
}, [setProp]);
// Extract friendly filename from URL
const getFriendlyName = (src: string) => {
const match = src.match(/filename=([^&]+)/);
if (match) return decodeURIComponent(match[1]).replace(/^\d+_[a-f0-9]+_/, '');
return src.split('/').pop() || 'image';
};
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
const inputStyle: CSSProperties = { flex: 1, minWidth: 0, padding: '4px 6px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 };
const selectStyle: CSSProperties = { padding: '4px 2px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, cursor: 'pointer' };
const btnStyle = (active: boolean): CSSProperties => ({
flex: 1, padding: '4px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: active ? '#3b82f6' : '#27272a',
color: active ? '#fff' : '#a1a1aa',
});
return (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Image preview */}
<div>
<label style={labelStyle}>Image Source</label>
{!isPlaceholder ? (
<>
{/* Current image thumbnail + filename + remove */}
<div style={{ marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
<img src={props.src} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 150, objectFit: 'cover' }} />
<button onClick={() => setProp((p: ImageBlockProps) => { p.src = PLACEHOLDER_SRC; })}
style={{ position: 'absolute', top: 4, right: 4, width: 24, height: 24, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
title="Remove image">
<i className="fa fa-times" />
</button>
</div>
<div style={{ fontSize: 11, color: '#a1a1aa', marginBottom: 8, display: 'flex', alignItems: 'center', gap: 4 }}>
<i className="fa fa-check-circle" style={{ color: '#10b981' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{getFriendlyName(props.src || '')}</span>
</div>
</>
) : (
/* Drop zone when no image set */
<div
style={{ padding: '20px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 12, cursor: 'pointer', marginBottom: 8, transition: 'border-color 0.15s' }}
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = '#3b82f6'; }}
onDragLeave={(e) => { e.currentTarget.style.borderColor = '#3f3f46'; }}
onDrop={async (e) => {
e.preventDefault();
e.currentTarget.style.borderColor = '#3f3f46';
const file = e.dataTransfer.files?.[0];
if (file && file.type.startsWith('image/')) await handleUpload(file);
}}
onClick={() => fileInputRef.current?.click()}
>
<i className="fa fa-cloud-upload" style={{ fontSize: 24, display: 'block', marginBottom: 6, color: '#3b82f6' }} />
Drop image here or click to upload
</div>
)}
{/* Action buttons: Upload + Browse */}
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => fileInputRef.current?.click()}
style={{ flex: 1, padding: '8px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff', fontWeight: 500 }}
>
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
</button>
<button
onClick={handleBrowse}
style={{ flex: 1, padding: '8px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: showBrowser ? '#3b82f6' : '#27272a', color: showBrowser ? '#fff' : '#e4e4e7' }}
>
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 4 }} /> Browse
</button>
</div>
{/* Inline asset browser grid */}
{showBrowser && (
<div style={{ maxHeight: 200, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginTop: 8, background: '#18181b', borderRadius: 6, padding: 4 }}>
{browserAssets.map(asset => (
<div
key={asset.name}
onClick={() => { setProp((p: ImageBlockProps) => { p.src = asset.url; }); setShowBrowser(false); }}
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1', transition: 'border-color 0.15s' }}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
>
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
</div>
))}
{browserAssets.length === 0 && (
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '12px 0', margin: 0 }}>No images uploaded yet. Use Upload above.</p>
)}
</div>
)}
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleUpload(file); e.target.value = ''; }} />
{/* URL input (collapsed, for advanced users) */}
<div style={{ marginTop: 6 }}>
<input type="text"
value={isPlaceholder ? '' : (props.src || '')}
onChange={(e) => setProp((p: ImageBlockProps) => { p.src = e.target.value || PLACEHOLDER_SRC; })}
placeholder="Or paste image URL..."
style={{ width: '100%', padding: '4px 8px', background: '#1e1e2a', color: '#71717a', border: '1px solid #27272a', borderRadius: 4, fontSize: 10 }}
/>
</div>
</div>
{/* Alt Text */}
<div>
<label style={labelStyle}>Alt Text</label>
<input
type="text"
value={props.alt || ''}
onChange={(e) => setProp((p: ImageBlockProps) => { p.alt = e.target.value; })}
placeholder="Describe the image..."
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
{/* Width */}
<div>
<label style={labelStyle}>Width</label>
<div style={{ display: 'flex', gap: 4 }}>
<input
type="number"
min={0}
value={widthParsed.num}
disabled={props.style?.width === 'auto'}
onChange={(e) => {
const val = buildSizeString(e.target.value, widthUnit);
setPropStyle('width', val || 'auto');
}}
placeholder="auto"
style={inputStyle}
/>
<select
value={props.style?.width === 'auto' ? 'auto' : widthUnit}
onChange={(e) => {
const unit = e.target.value as SizeUnit;
if (unit === 'auto') {
setPropStyle('width', 'auto');
} else {
setWidthUnit(unit);
const num = widthParsed.num || '100';
setPropStyle('width', `${num}${unit}`);
}
}}
style={selectStyle}
>
<option value="px">px</option>
<option value="%">%</option>
<option value="auto">auto</option>
</select>
</div>
</div>
{/* Max Width */}
<div>
<label style={labelStyle}>Max Width</label>
<div style={{ display: 'flex', gap: 4 }}>
<input
type="number"
min={0}
value={maxWidthParsed.num}
onChange={(e) => {
const val = buildSizeString(e.target.value, maxWidthUnit);
setPropStyle('maxWidth', val || '100%');
}}
placeholder="100%"
style={inputStyle}
/>
<select
value={maxWidthUnit}
onChange={(e) => {
const unit = e.target.value as SizeUnit;
setMaxWidthUnit(unit);
const num = maxWidthParsed.num || '100';
setPropStyle('maxWidth', `${num}${unit}`);
}}
style={selectStyle}
>
<option value="px">px</option>
<option value="%">%</option>
</select>
</div>
</div>
{/* Height */}
<div>
<label style={labelStyle}>Height</label>
<div style={{ display: 'flex', gap: 4 }}>
<input
type="number"
min={0}
value={heightParsed.num}
disabled={props.style?.height === 'auto'}
onChange={(e) => {
const val = buildSizeString(e.target.value, heightUnit);
setPropStyle('height', val || 'auto');
}}
placeholder="auto"
style={inputStyle}
/>
<select
value={props.style?.height === 'auto' ? 'auto' : heightUnit}
onChange={(e) => {
const unit = e.target.value as SizeUnit;
if (unit === 'auto') {
setPropStyle('height', 'auto');
} else {
setHeightUnit(unit);
const num = heightParsed.num || '300';
setPropStyle('height', `${num}${unit}`);
}
}}
style={selectStyle}
>
<option value="px">px</option>
<option value="auto">auto</option>
</select>
</div>
</div>
{/* Object Fit (visible when both width and height are explicit values) */}
{props.style?.width && props.style.width !== 'auto' && props.style?.height && props.style.height !== 'auto' && (
<div>
<label style={labelStyle}>Object Fit</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['cover', 'contain', 'fill', 'none'] as const).map((fit) => (
<button
key={fit}
onClick={() => setPropStyle('objectFit', fit)}
style={btnStyle(props.style?.objectFit === fit)}
>
{fit}
</button>
))}
</div>
</div>
)}
{/* Alignment */}
<div>
<label style={labelStyle}>Alignment</label>
<div style={{ display: 'flex', gap: 4 }}>
<button onClick={() => setAlignment('left')} style={btnStyle(alignment === 'left')}>
<i className="fa fa-align-left" style={{ marginRight: 3 }} />Left
</button>
<button onClick={() => setAlignment('center')} style={btnStyle(alignment === 'center')}>
<i className="fa fa-align-center" style={{ marginRight: 3 }} />Center
</button>
<button onClick={() => setAlignment('right')} style={btnStyle(alignment === 'right')}>
<i className="fa fa-align-right" style={{ marginRight: 3 }} />Right
</button>
</div>
</div>
{/* Border Radius */}
<div>
<label style={labelStyle}>Border Radius</label>
<div style={{ display: 'flex', gap: 4 }}>
{radiusPresets.map((r) => (
<button key={r} onClick={() => setPropStyle('borderRadius', r)}
style={btnStyle(props.style?.borderRadius === r)}
>{r}</button>
))}
</div>
</div>
</div>
);
};
ImageBlock.craft = {
displayName: 'Image',
props: { src: PLACEHOLDER_SRC, alt: '', style: { width: '100%', height: 'auto' } },
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
related: { settings: ImageBlockSettings },
};
(ImageBlock as any).toHtml = (props: ImageBlockProps, _c: string) => {
// Skip placeholder/empty images in export
const src = props.src || '';
if (!src || src.startsWith('data:image/svg') || src === PLACEHOLDER_SRC) {
return { html: '' };
}
const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style });
const alt = props.alt ? ` alt="${escapeAttr(props.alt)}"` : ' alt=""';
return { html: `<img src="${escapeAttr(safeUrl(src))}"${alt}${s ? ` style="${s}"` : ''} />` };
};