48d0441be3
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>
415 lines
15 KiB
TypeScript
415 lines
15 KiB
TypeScript
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
|
|
import { useNode, UserComponent } from '@craftjs/core';
|
|
import { cssPropsToString } from '../../utils/style-helpers';
|
|
import { useSiteDesign } from '../../state/SiteDesignContext';
|
|
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
|
|
|
/* ---------- Types ---------- */
|
|
|
|
interface LogoProps {
|
|
type?: 'text' | 'image';
|
|
text?: string;
|
|
imageSrc?: string;
|
|
imageWidth?: string;
|
|
href?: string;
|
|
fontFamily?: string;
|
|
fontSize?: string;
|
|
fontWeight?: string;
|
|
color?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
/* ---------- Image upload helper ---------- */
|
|
|
|
async function uploadToWhp(file: File): Promise<string | null> {
|
|
const cfg = (window as any).WHP_CONFIG;
|
|
if (!cfg) return URL.createObjectURL(file);
|
|
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; }
|
|
}
|
|
|
|
/* ---------- Component ---------- */
|
|
|
|
export const Logo: UserComponent<LogoProps> = ({
|
|
type = 'text',
|
|
text = 'MySite',
|
|
imageSrc = '',
|
|
imageWidth = '120px',
|
|
href = '/',
|
|
fontFamily = 'Inter, sans-serif',
|
|
fontSize = '20px',
|
|
fontWeight = '700',
|
|
color,
|
|
style = {},
|
|
}) => {
|
|
const {
|
|
connectors: { connect, drag },
|
|
} = useNode();
|
|
|
|
const { design } = useSiteDesign();
|
|
const resolvedColor = color || design.textColor;
|
|
|
|
return (
|
|
<a
|
|
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
|
href={href}
|
|
onClick={(e) => e.preventDefault()}
|
|
style={{
|
|
textDecoration: 'none',
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
flexShrink: 0,
|
|
...style,
|
|
}}
|
|
>
|
|
{type === 'image' && imageSrc ? (
|
|
<img
|
|
src={imageSrc}
|
|
alt={text || 'Logo'}
|
|
style={{ width: imageWidth, height: 'auto', display: 'block' }}
|
|
/>
|
|
) : (
|
|
<span style={{
|
|
fontWeight,
|
|
fontSize,
|
|
fontFamily,
|
|
color: resolvedColor,
|
|
}}>
|
|
{text}
|
|
</span>
|
|
)}
|
|
</a>
|
|
);
|
|
};
|
|
|
|
/* ---------- Settings panel ---------- */
|
|
|
|
const LogoSettings: React.FC = () => {
|
|
const { actions: { setProp }, props } = useNode((node) => ({
|
|
props: node.data.props as LogoProps,
|
|
}));
|
|
|
|
const { design } = useSiteDesign();
|
|
const logoType = props.type || 'text';
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const [showBrowser, setShowBrowser] = useState(false);
|
|
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
|
|
const [browserLoading, setBrowserLoading] = useState(false);
|
|
|
|
const fontFamilies = [
|
|
{ label: 'Inter', value: 'Inter, sans-serif' },
|
|
{ label: 'Roboto', value: 'Roboto, sans-serif' },
|
|
{ label: 'Poppins', value: 'Poppins, sans-serif' },
|
|
{ label: 'Montserrat', value: 'Montserrat, sans-serif' },
|
|
{ label: 'Playfair', value: 'Playfair Display, serif' },
|
|
{ label: 'Merriweather', value: 'Merriweather, serif' },
|
|
{ label: 'Source Code', value: 'Source Code Pro, monospace' },
|
|
{ label: 'Open Sans', value: 'Open Sans, sans-serif' },
|
|
];
|
|
|
|
const handleLogoUpload = useCallback(async (file: File) => {
|
|
const url = await uploadToWhp(file);
|
|
if (url) setProp((p: LogoProps) => { p.imageSrc = 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]);
|
|
|
|
/* ---- Shared styles ---- */
|
|
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
|
|
const inputStyle: CSSProperties = {
|
|
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
|
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
|
};
|
|
const btnSmall: CSSProperties = {
|
|
padding: '2px 6px', fontSize: 11, background: '#27272a', color: '#a1a1aa',
|
|
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
|
|
};
|
|
const btnActive: CSSProperties = {
|
|
...btnSmall, background: '#3b82f6', color: '#fff', borderColor: '#3b82f6',
|
|
};
|
|
|
|
return (
|
|
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
|
{/* Type toggle */}
|
|
<div>
|
|
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Logo Type</label>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
<button
|
|
onClick={() => setProp((p: LogoProps) => { p.type = 'text'; })}
|
|
style={logoType === 'text' ? btnActive : btnSmall}
|
|
>
|
|
<i className="fa fa-font" style={{ marginRight: 3 }} />Text
|
|
</button>
|
|
<button
|
|
onClick={() => setProp((p: LogoProps) => { p.type = 'image'; })}
|
|
style={logoType === 'image' ? btnActive : btnSmall}
|
|
>
|
|
<i className="fa fa-image" style={{ marginRight: 3 }} />Image
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{logoType === 'text' ? (
|
|
<>
|
|
<div>
|
|
<label style={labelStyle}>Logo Text</label>
|
|
<input
|
|
type="text"
|
|
value={props.text || ''}
|
|
onChange={(e) => setProp((p: LogoProps) => { p.text = e.target.value; })}
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label style={labelStyle}>Font Family</label>
|
|
<select
|
|
value={props.fontFamily || 'Inter, sans-serif'}
|
|
onChange={(e) => setProp((p: LogoProps) => { p.fontFamily = e.target.value; })}
|
|
style={{ ...inputStyle, cursor: 'pointer' }}
|
|
>
|
|
{fontFamilies.map((f) => (
|
|
<option key={f.value} value={f.value}>{f.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
<div style={{ flex: 1 }}>
|
|
<label style={labelStyle}>Size</label>
|
|
<input
|
|
type="text"
|
|
value={props.fontSize || '20px'}
|
|
onChange={(e) => setProp((p: LogoProps) => { p.fontSize = e.target.value; })}
|
|
placeholder="20px"
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
<div style={{ flex: 1 }}>
|
|
<label style={labelStyle}>Weight</label>
|
|
<select
|
|
value={props.fontWeight || '700'}
|
|
onChange={(e) => setProp((p: LogoProps) => { p.fontWeight = e.target.value; })}
|
|
style={{ ...inputStyle, cursor: 'pointer' }}
|
|
>
|
|
<option value="300">Light</option>
|
|
<option value="400">Normal</option>
|
|
<option value="500">Medium</option>
|
|
<option value="600">Semi</option>
|
|
<option value="700">Bold</option>
|
|
<option value="800">Extra Bold</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label style={labelStyle}>Color</label>
|
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
|
<input
|
|
type="color"
|
|
value={props.color || design.textColor}
|
|
onChange={(e) => setProp((p: LogoProps) => { p.color = e.target.value; })}
|
|
style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
|
/>
|
|
<span style={{ fontSize: 10, color: '#71717a' }}>{props.color || 'Auto'}</span>
|
|
<button
|
|
onClick={() => setProp((p: LogoProps) => { p.color = undefined; })}
|
|
style={{ ...btnSmall, fontSize: 9, padding: '2px 4px' }}
|
|
title="Reset to auto"
|
|
>Auto</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* Image logo controls */}
|
|
{props.imageSrc ? (
|
|
<div style={{ borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
|
<img src={props.imageSrc} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 80, objectFit: 'contain', background: '#18181b' }} />
|
|
<button
|
|
onClick={() => setProp((p: LogoProps) => { p.imageSrc = ''; })}
|
|
style={{ position: 'absolute', top: 4, right: 4, width: 20, height: 20, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
title="Remove image"
|
|
>
|
|
<i className="fa fa-times" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div
|
|
style={{ padding: '14px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 11, cursor: 'pointer' }}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
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 handleLogoUpload(file);
|
|
}}
|
|
>
|
|
<i className="fa fa-cloud-upload" style={{ fontSize: 18, display: 'block', marginBottom: 4, color: '#3b82f6' }} />
|
|
Drop logo or click to upload
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
style={{ flex: 1, padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff', fontWeight: 500 }}
|
|
>
|
|
<i className="fa fa-upload" style={{ marginRight: 3 }} /> Upload
|
|
</button>
|
|
<button
|
|
onClick={handleBrowse}
|
|
style={{ flex: 1, padding: '6px 8px', fontSize: 11, 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: 3 }} /> Browse
|
|
</button>
|
|
</div>
|
|
|
|
{/* Browse grid */}
|
|
{showBrowser && (
|
|
<div style={{ maxHeight: 150, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, background: '#18181b', borderRadius: 6, padding: 4 }}>
|
|
{browserAssets.map(asset => (
|
|
<div
|
|
key={asset.name}
|
|
onClick={() => { setProp((p: LogoProps) => { p.imageSrc = asset.url; }); setShowBrowser(false); }}
|
|
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1' }}
|
|
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: '8px 0', margin: 0 }}>No images uploaded yet.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
|
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleLogoUpload(file); e.target.value = ''; }} />
|
|
|
|
{/* URL paste input */}
|
|
<div>
|
|
<input
|
|
type="text"
|
|
value={props.imageSrc || ''}
|
|
onChange={(e) => setProp((p: LogoProps) => { p.imageSrc = e.target.value; })}
|
|
placeholder="Or paste image URL..."
|
|
style={{ ...inputStyle, fontSize: 10, color: '#71717a' }}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label style={labelStyle}>Logo Width</label>
|
|
<input
|
|
type="text"
|
|
value={props.imageWidth || '120px'}
|
|
onChange={(e) => setProp((p: LogoProps) => { p.imageWidth = e.target.value; })}
|
|
placeholder="120px"
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Link URL */}
|
|
<div>
|
|
<label style={labelStyle}>Link URL</label>
|
|
<input
|
|
type="text"
|
|
value={props.href || '/'}
|
|
onChange={(e) => setProp((p: LogoProps) => { p.href = e.target.value; })}
|
|
placeholder="/"
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/* ---------- Craft config ---------- */
|
|
|
|
Logo.craft = {
|
|
displayName: 'Logo',
|
|
props: {
|
|
type: 'text',
|
|
text: 'MySite',
|
|
imageSrc: '',
|
|
imageWidth: '120px',
|
|
href: '/',
|
|
fontFamily: 'Inter, sans-serif',
|
|
fontSize: '20px',
|
|
fontWeight: '700',
|
|
color: undefined,
|
|
style: {},
|
|
} as LogoProps,
|
|
rules: {
|
|
canDrag: () => true,
|
|
canMoveIn: () => false,
|
|
canMoveOut: () => true,
|
|
},
|
|
related: {
|
|
settings: LogoSettings,
|
|
},
|
|
};
|
|
|
|
/* ---------- HTML export ---------- */
|
|
|
|
(Logo as any).toHtml = (props: LogoProps, _childrenHtml: string) => {
|
|
const href = props.href || '/';
|
|
|
|
let innerHtml: string;
|
|
if (props.type === 'image' && props.imageSrc) {
|
|
const imgStyle = cssPropsToString({ width: props.imageWidth || '120px', height: 'auto', display: 'block' });
|
|
innerHtml = `<img src="${escapeAttr(safeUrl(props.imageSrc))}" alt="${escapeAttr(props.text || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} />`;
|
|
} else {
|
|
const spanStyle = cssPropsToString({
|
|
fontWeight: props.fontWeight || '700',
|
|
fontSize: props.fontSize || '20px',
|
|
fontFamily: props.fontFamily || 'Inter, sans-serif',
|
|
color: props.color || '#1f2937',
|
|
});
|
|
innerHtml = `<span${spanStyle ? ` style="${spanStyle}"` : ''}>${escapeHtml(props.text || 'MySite')}</span>`;
|
|
}
|
|
|
|
const aStyle = cssPropsToString({
|
|
textDecoration: 'none',
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
flexShrink: '0',
|
|
...props.style,
|
|
});
|
|
|
|
return {
|
|
html: `<a href="${escapeAttr(safeUrl(href))}"${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`,
|
|
};
|
|
};
|