Files
site-builder/craft/src/components/basic/Navbar.tsx
T
shadowdaoandClaude Opus 4.8 3f3c6fb851 security: add safeImageUrl, un-break M-5's over-blocking of image-context SVG data URIs
M-5 made safeUrl() block data:image/svg+xml everywhere, including the
image-only sinks (<img src>, CSS url()) that Gallery's default images and
other SVG placeholders rely on. Loaded as an image, an SVG is rasterized
and never executes an inline <script>/onload= -- that only happens when
it's navigated to or loaded as an <iframe> document -- so M-5 over-blocked
the safe contexts and broke every published Gallery (and other components
using an SVG placeholder) using safeUrl's default images in prod.

Adds safeImageUrl(): identical javascript:/vbscript: handling to safeUrl,
but treats data: as an allowlist of image/* subtypes instead of a
blocklist -- allows all data:image/* (including svg+xml, with or without
base64), still blocks data:text/html and any other non-image data: type.

Swapped to safeImageUrl at IMAGE-src / CSS-image url() sinks only:
- Gallery.tsx img src + lightbox data-lb-src
- ImageBlock.tsx img src (toHtml)
- Logo.tsx / Navbar.tsx logo <img> src (their href/link targets keep safeUrl)
- style-helpers.ts sanitizeCssValue's url(...) handling (background-image
  for HeroSimple/BackgroundSection/Section/CallToAction)

Left on safeUrl (href/iframe/form-action/navigation sinks, where
data:image/svg+xml must stay blocked): ButtonLink, Icon link, SocialLinks,
Menu/Navbar link hrefs, PricingTable buttonHref, _cta-helpers,
ContentSlider buttonHref, FeaturesGrid buttonUrl, FormContainer action
(via form-relay-wiring), MapEmbed/VideoBlock iframe src.

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

335 lines
13 KiB
TypeScript

import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { useSiteDesign } from '../../state/SiteDesignContext';
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, cssValue, scopeId } from '../../utils/escape';
/* ---------- Types ---------- */
interface NavLink {
text: string;
href: string;
isExternal?: boolean;
isCta?: boolean;
}
interface NavbarProps {
logoType?: 'text' | 'image';
logoText?: string;
logoImage?: string;
logoWidth?: string;
logoUrl?: string;
logoFontFamily?: string;
logoFontSize?: string;
logoColor?: string;
links?: NavLink[];
backgroundColor?: string;
textColor?: string;
hoverColor?: string;
ctaColor?: string;
ctaTextColor?: string;
padding?: string;
navAlignment?: 'left' | 'center' | 'right' | 'space-between';
isSticky?: boolean;
showMobileMenu?: boolean;
style?: CSSProperties;
}
/* ---------- Defaults ---------- */
const defaultLinks: NavLink[] = [
{ text: 'Home', href: '/' },
{ text: 'About', href: '#about' },
{ text: 'Services', href: '#services' },
{ text: 'Contact', href: '#contact', isCta: true },
];
const PADDING_PRESETS = [
{ label: 'Compact', value: '8px 16px' },
{ label: 'Normal', value: '16px 24px' },
{ label: 'Relaxed', value: '20px 32px' },
{ label: 'Spacious', value: '24px 48px' },
];
/* ---------- Component ---------- */
export const Navbar: UserComponent<NavbarProps> = ({
logoType = 'text',
logoText = 'MySite',
logoImage = '',
logoWidth = '120px',
logoUrl = '/',
logoFontFamily = 'Inter, sans-serif',
logoFontSize = '20px',
logoColor,
links = defaultLinks,
backgroundColor = '#ffffff',
textColor = '#3f3f46',
hoverColor = '#3b82f6',
ctaColor = '#3b82f6',
ctaTextColor = '#ffffff',
padding = '16px 24px',
navAlignment = 'space-between',
isSticky = false,
showMobileMenu = false,
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const { design } = useSiteDesign();
const resolvedLogoColor = logoColor || (backgroundColor === '#ffffff' || backgroundColor === '#f8fafc' || backgroundColor === '#f9fafb' ? design.textColor : '#ffffff');
const resolvedTextColor = textColor || (backgroundColor === '#ffffff' || backgroundColor === '#f8fafc' || backgroundColor === '#f9fafb' ? '#3f3f46' : '#e4e4e7');
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
return (
<nav
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: navAlignment,
padding,
backgroundColor,
...(isSticky ? { position: 'sticky' as const, top: 0, zIndex: 1000 } : {}),
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
{/* Logo */}
<a
href={logoUrl}
onClick={(e) => e.preventDefault()}
style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}
>
{logoType === 'image' && logoImage ? (
<img
src={logoImage}
alt={logoText || 'Logo'}
style={{ width: logoWidth, height: 'auto', display: 'block' }}
/>
) : (
<span style={{
fontWeight: '700',
fontSize: logoFontSize,
fontFamily: logoFontFamily,
color: resolvedLogoColor,
}}>
{logoText}
</span>
)}
</a>
{/* Links */}
<div style={{ display: 'flex', alignItems: 'center', gap: '24px' }}>
{showMobileMenu && (
<div
style={{
display: 'none', /* Hidden in editor, shown via media query in export */
flexDirection: 'column',
gap: '4px',
cursor: 'pointer',
padding: '4px',
}}
className="navbar-hamburger"
>
<span style={{ display: 'block', width: '24px', height: '2px', backgroundColor: resolvedTextColor }} />
<span style={{ display: 'block', width: '24px', height: '2px', backgroundColor: resolvedTextColor }} />
<span style={{ display: 'block', width: '24px', height: '2px', backgroundColor: resolvedTextColor }} />
</div>
)}
{links.map((link, i) => (
<a
key={i}
href={link.href}
target={link.isExternal ? '_blank' : undefined}
rel={link.isExternal ? 'noopener noreferrer' : undefined}
onClick={(e) => e.preventDefault()}
onMouseEnter={() => setHoveredLink(i)}
onMouseLeave={() => setHoveredLink(null)}
style={{
textDecoration: 'none',
fontSize: '14px',
fontWeight: link.isCta ? '600' : '400',
color: link.isCta
? ctaTextColor
: (hoveredLink === i ? hoverColor : resolvedTextColor),
backgroundColor: link.isCta ? ctaColor : 'transparent',
padding: link.isCta ? '8px 20px' : '0',
borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s',
...(link.isCta && hoveredLink === i ? { filter: 'brightness(1.1)' } : {}),
}}
>
{link.text}
</a>
))}
</div>
</nav>
);
};
/* ---------- Craft config ---------- */
Navbar.craft = {
displayName: 'Navbar',
props: {
logoType: 'text',
logoText: 'MySite',
logoImage: '',
logoWidth: '120px',
logoUrl: '/',
logoFontFamily: 'Inter, sans-serif',
logoFontSize: '20px',
logoColor: undefined,
links: defaultLinks,
backgroundColor: '#ffffff',
textColor: '#3f3f46',
hoverColor: '#3b82f6',
ctaColor: '#3b82f6',
ctaTextColor: '#ffffff',
padding: '16px 24px',
navAlignment: 'space-between',
isSticky: false,
showMobileMenu: false,
style: {
borderBottom: '1px solid #e4e4e7',
},
} as NavbarProps,
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
};
/* ---------- HTML export ---------- */
(Navbar as any).toHtml = (props: NavbarProps, _childrenHtml: string, nodeId?: string) => {
// Sanitized once here -- these are raw string-interpolation sinks below
// (hoverCol/bgColor go into a <style> block, the worst case: </style>
// breakout -> arbitrary <script>), see task-cssxss-brief.md.
const bgColor = cssValue(props.backgroundColor) || '#ffffff';
const textCol = cssValue(props.textColor) || '#3f3f46';
const hoverCol = cssValue(props.hoverColor) || '#3b82f6';
const ctaCol = cssValue(props.ctaColor) || '#3b82f6';
const ctaTextCol = cssValue(props.ctaTextColor) || '#ffffff';
const pad = cssValue(props.padding) || '16px 24px';
const alignment = props.navAlignment || 'space-between';
const sticky = props.isSticky;
const mobile = props.showMobileMenu;
const logoUrl = props.logoUrl || '/';
const links0 = props.links || defaultLinks;
// M-1: deterministic AND unique per-instance scope, keyed on the Craft
// node id. Two Navbars on the same page previously emitted an identical
// fixed id="navbar-links" (invalid duplicate-id HTML, ambiguous
// aria-controls target) and unscoped `.navbar-link:hover`/`.navbar-cta:hover`
// rules in each instance's own <style> block -- since both blocks target
// the SAME global selector, the later one in the DOM silently overrides
// the earlier one's hover color/behavior for BOTH navbars. Scoping the
// links-container id and adding a per-instance class on the <nav> root
// (used to prefix the hover selectors) eliminates both collisions.
const scope = scopeId(nodeId, JSON.stringify(links0) + alignment + pad, 'nav');
const linksId = `${scope}_links`;
const navStyle = cssPropsToString({
display: 'flex',
alignItems: 'center',
justifyContent: alignment,
padding: pad,
backgroundColor: bgColor,
...(sticky ? { position: 'sticky', top: '0', zIndex: '1000' } : {}),
...props.style,
});
// Logo HTML
let logoHtml: string;
if (props.logoType === 'image' && props.logoImage) {
const imgStyle = cssPropsToString({ width: props.logoWidth || '120px', height: 'auto', display: 'block' });
logoHtml = `<a href="${escapeAttr(safeUrl(logoUrl))}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><img src="${escapeAttr(safeImageUrl(props.logoImage))}" alt="${escapeAttr(props.logoText || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} /></a>`;
} else {
const logoStyle = cssPropsToString({
fontWeight: '700',
fontSize: props.logoFontSize || '20px',
fontFamily: props.logoFontFamily || 'Inter, sans-serif',
color: props.logoColor || textCol,
});
logoHtml = `<a href="${escapeAttr(safeUrl(logoUrl))}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><span${logoStyle ? ` style="${logoStyle}"` : ''}>${escapeHtml(props.logoText || 'MySite')}</span></a>`;
}
// Links HTML
const links = props.links || defaultLinks;
const linksHtml = links.map((link) => {
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
const linkStyle = cssPropsToString({
textDecoration: 'none',
fontSize: '14px',
fontWeight: link.isCta ? '600' : '400',
color: link.isCta ? ctaTextCol : textCol,
backgroundColor: link.isCta ? ctaCol : 'transparent',
padding: link.isCta ? '8px 20px' : '0',
borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s',
});
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
}).join('\n ');
// Hamburger HTML for mobile. The toggle needs an accessible name (there's
// no visible text, just three bars) and must report its open/closed state
// via aria-expanded, kept in sync with the .navbar-open class by the
// inline onclick handler.
const hamburgerHtml = mobile
? `\n <button class="navbar-hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="${escapeAttr(linksId)}" onclick="var m=document.getElementById('${linksId}');var open=m.classList.toggle('navbar-open');this.setAttribute('aria-expanded', open ? 'true' : 'false');" style="display:none;background:none;border:none;cursor:pointer;padding:4px;flex-direction:column;gap:4px">
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
</button>`
: '';
// Hover CSS -- scoped under `.${scope}` (a class on the <nav> root, added
// below) so it can only ever match THIS instance's links/CTA, never bleed
// into or get overridden by another Navbar instance's rules.
const hoverCss = `<style>
.${scope} .navbar-link:hover { color: ${hoverCol} !important; }
.${scope} .navbar-cta:hover { filter: brightness(1.1); }${mobile ? `
@media (max-width: 768px) {
.${scope} .navbar-hamburger { display: flex !important; }
.${scope} .navbar-links { display: none !important; position: absolute; top: 100%; left: 0; right: 0; flex-direction: column !important; background-color: ${bgColor}; padding: 12px 24px; gap: 12px !important; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.${scope} .navbar-links.navbar-open { display: flex !important; }
}` : ''}
</style>`;
// Add CSS class to each link for hover
const linksHtmlWithClass = links.map((link) => {
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
const cls = link.isCta ? 'navbar-cta' : 'navbar-link';
const linkStyle = cssPropsToString({
textDecoration: 'none',
fontSize: '14px',
fontWeight: link.isCta ? '600' : '400',
color: link.isCta ? ctaTextCol : textCol,
backgroundColor: link.isCta ? ctaCol : 'transparent',
padding: link.isCta ? '8px 20px' : '0',
borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s',
});
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
}).join('\n ');
return {
html: `${hoverCss}
<nav class="${scope}"${navStyle ? ` style="${navStyle}${mobile ? ';position:relative' : ''}"` : ''}>
${logoHtml}${hamburgerHtml}
<div class="navbar-links" id="${linksId}" style="display:flex;align-items:center;gap:24px">
${linksHtmlWithClass}
</div>
</nav>`,
};
};