Adds a single cssValue() sanitizer (src/utils/escape.ts) that strips
<>{};"'\ and neutralizes url(), safe for both style="..." attribute and
<style>...</style> element contexts. Applies it at every raw user-prop
CSS-value interpolation sink found via grep across src/components (colors,
sizes, gaps interpolated directly into style strings/<style> blocks),
including the highest-risk <style>-context sinks: ColumnLayout gap,
Menu/Navbar hover and background colors. Also Number()-coerces the
`columns` grid-template-columns sinks in Gallery/Testimonials/NumberCounter
as defense in depth. Regression tests assert </style><script> payloads are
neutralized and normal colors still render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
232 lines
7.4 KiB
TypeScript
232 lines
7.4 KiB
TypeScript
import React, { CSSProperties, useEffect, useState } from 'react';
|
|
import { useNode, UserComponent } from '@craftjs/core';
|
|
import { cssPropsToString } from '../../utils/style-helpers';
|
|
import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
|
|
|
|
interface CountdownProps {
|
|
targetDate?: string;
|
|
heading?: string;
|
|
style?: CSSProperties;
|
|
digitColor?: string;
|
|
labelColor?: string;
|
|
bgColor?: string;
|
|
anchorId?: string;
|
|
}
|
|
|
|
interface TimeLeft {
|
|
days: number;
|
|
hours: number;
|
|
minutes: number;
|
|
seconds: number;
|
|
}
|
|
|
|
function getDefaultTargetDate(): string {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() + 30);
|
|
return d.toISOString().split('T')[0];
|
|
}
|
|
|
|
function calcTimeLeft(target: string): TimeLeft {
|
|
const diff = new Date(target).getTime() - Date.now();
|
|
if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0 };
|
|
return {
|
|
days: Math.floor(diff / (1000 * 60 * 60 * 24)),
|
|
hours: Math.floor((diff / (1000 * 60 * 60)) % 24),
|
|
minutes: Math.floor((diff / (1000 * 60)) % 60),
|
|
seconds: Math.floor((diff / 1000) % 60),
|
|
};
|
|
}
|
|
|
|
const DEFAULT_TARGET = getDefaultTargetDate();
|
|
|
|
export const Countdown: UserComponent<CountdownProps> = ({
|
|
targetDate = DEFAULT_TARGET,
|
|
heading = 'Coming Soon',
|
|
style = {},
|
|
digitColor = '#ffffff',
|
|
labelColor = 'rgba(255,255,255,0.7)',
|
|
bgColor = '#18181b',
|
|
anchorId,
|
|
}) => {
|
|
const {
|
|
connectors: { connect, drag },
|
|
selected,
|
|
} = useNode((node) => ({
|
|
selected: node.events.selected,
|
|
}));
|
|
|
|
const [timeLeft, setTimeLeft] = useState<TimeLeft>(() => calcTimeLeft(targetDate));
|
|
|
|
useEffect(() => {
|
|
setTimeLeft(calcTimeLeft(targetDate));
|
|
const interval = setInterval(() => {
|
|
setTimeLeft(calcTimeLeft(targetDate));
|
|
}, 1000);
|
|
return () => clearInterval(interval);
|
|
}, [targetDate]);
|
|
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
|
|
const boxStyle: CSSProperties = {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
gap: '4px',
|
|
minWidth: '80px',
|
|
};
|
|
|
|
const digitStyle: CSSProperties = {
|
|
fontSize: '48px',
|
|
fontWeight: '700',
|
|
color: digitColor,
|
|
lineHeight: '1',
|
|
fontFamily: 'Inter, sans-serif',
|
|
};
|
|
|
|
const unitLabelStyle: CSSProperties = {
|
|
fontSize: '12px',
|
|
color: labelColor,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.1em',
|
|
fontFamily: 'Inter, sans-serif',
|
|
};
|
|
|
|
const units: Array<{ label: string; value: number }> = [
|
|
{ label: 'Days', value: timeLeft.days },
|
|
{ label: 'Hours', value: timeLeft.hours },
|
|
{ label: 'Minutes', value: timeLeft.minutes },
|
|
{ label: 'Seconds', value: timeLeft.seconds },
|
|
];
|
|
|
|
return (
|
|
<section
|
|
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
|
id={anchorId || undefined}
|
|
style={{
|
|
padding: '60px 20px',
|
|
textAlign: 'center',
|
|
backgroundColor: bgColor,
|
|
outline: selected ? '2px solid #3b82f6' : 'none',
|
|
...style,
|
|
}}
|
|
>
|
|
{heading && (
|
|
<h2 style={{ fontSize: '32px', fontWeight: '700', color: digitColor, marginBottom: '32px', fontFamily: 'Inter, sans-serif' }}>
|
|
{heading}
|
|
</h2>
|
|
)}
|
|
<div style={{ display: 'flex', justifyContent: 'center', gap: '24px', flexWrap: 'wrap' }}>
|
|
{units.map((u) => (
|
|
<div key={u.label} style={boxStyle}>
|
|
<span style={digitStyle}>{pad(u.value)}</span>
|
|
<span style={unitLabelStyle}>{u.label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
/* ---------- Craft config ---------- */
|
|
|
|
Countdown.craft = {
|
|
displayName: 'Countdown',
|
|
props: {
|
|
targetDate: DEFAULT_TARGET,
|
|
heading: 'Coming Soon',
|
|
style: {},
|
|
digitColor: '#ffffff',
|
|
labelColor: 'rgba(255,255,255,0.7)',
|
|
bgColor: '#18181b',
|
|
anchorId: '',
|
|
},
|
|
rules: {
|
|
canDrag: () => true,
|
|
canMoveIn: () => false,
|
|
canMoveOut: () => true,
|
|
},
|
|
};
|
|
|
|
/* ---------- HTML export ---------- */
|
|
|
|
(Countdown as any).toHtml = (props: CountdownProps, _childrenHtml: string, nodeId?: string) => {
|
|
const {
|
|
targetDate = DEFAULT_TARGET,
|
|
heading = 'Coming Soon',
|
|
style = {},
|
|
bgColor = '#18181b',
|
|
} = props;
|
|
// Sanitized -- raw string-interpolation sinks in the heading/digit/label
|
|
// style attributes below.
|
|
const digitColor = cssValue(props.digitColor) || '#ffffff';
|
|
const labelColor = cssValue(props.labelColor) || 'rgba(255,255,255,0.7)';
|
|
|
|
const sectionStyle = cssPropsToString({
|
|
padding: '60px 20px',
|
|
textAlign: 'center',
|
|
backgroundColor: bgColor,
|
|
...style,
|
|
});
|
|
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
|
|
|
const headingHtml = heading
|
|
? `<h2 style="font-size:32px;font-weight:700;color:${digitColor};margin-bottom:32px;font-family:Inter,sans-serif">${escapeHtml(heading)}</h2>`
|
|
: '';
|
|
|
|
const boxStyle = 'display:flex;flex-direction:column;align-items:center;gap:4px;min-width:80px';
|
|
const dStyle = `font-size:48px;font-weight:700;color:${digitColor};line-height:1;font-family:Inter,sans-serif`;
|
|
const lStyle = `font-size:12px;color:${labelColor};text-transform:uppercase;letter-spacing:0.1em;font-family:Inter,sans-serif`;
|
|
|
|
// Deterministic AND unique id for this countdown instance's span ids and
|
|
// getElementById() calls inside its inline script -- scoped on the Craft
|
|
// node id so two Countdown instances (e.g. both left at default props)
|
|
// don't collide and end up writing each other's digits.
|
|
const uid = scopeId(nodeId, targetDate + '::' + heading, 'cd');
|
|
|
|
// Only accept a strict date/datetime shape before it's embedded in the
|
|
// inline <script>; anything else falls back to "now" instead of letting
|
|
// arbitrary text (e.g. `");alert(1)//`) break out of the new Date(...) call.
|
|
const VALID_DATE_RE = /^\d{4}-\d{2}-\d{2}([T ][0-9:.\-+Z]*)?$/;
|
|
const dateExpr = typeof targetDate === 'string' && VALID_DATE_RE.test(targetDate)
|
|
? `new Date(${JSON.stringify(targetDate)})`
|
|
: 'new Date()';
|
|
|
|
return {
|
|
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
|
${headingHtml}
|
|
<div style="display:flex;justify-content:center;gap:24px;flex-wrap:wrap">
|
|
<div style="${boxStyle}"><span id="${uid}_d" style="${dStyle}">00</span><span style="${lStyle}">Days</span></div>
|
|
<div style="${boxStyle}"><span id="${uid}_h" style="${dStyle}">00</span><span style="${lStyle}">Hours</span></div>
|
|
<div style="${boxStyle}"><span id="${uid}_m" style="${dStyle}">00</span><span style="${lStyle}">Minutes</span></div>
|
|
<div style="${boxStyle}"><span id="${uid}_s" style="${dStyle}">00</span><span style="${lStyle}">Seconds</span></div>
|
|
</div>
|
|
<script>
|
|
(function(){
|
|
var target = ${dateExpr}.getTime();
|
|
var timer = null;
|
|
function pad(n){ return String(n).padStart(2,'0'); }
|
|
function update(){
|
|
var diff = target - Date.now();
|
|
if(diff<=0){
|
|
diff=0;
|
|
if(timer){ clearInterval(timer); timer=null; }
|
|
}
|
|
var d = Math.floor(diff/(1000*60*60*24));
|
|
var h = Math.floor((diff/(1000*60*60))%24);
|
|
var m = Math.floor((diff/(1000*60))%60);
|
|
var s = Math.floor((diff/1000)%60);
|
|
document.getElementById("${uid}_d").textContent = pad(d);
|
|
document.getElementById("${uid}_h").textContent = pad(h);
|
|
document.getElementById("${uid}_m").textContent = pad(m);
|
|
document.getElementById("${uid}_s").textContent = pad(s);
|
|
}
|
|
update();
|
|
if(target - Date.now() > 0){
|
|
timer = setInterval(update,1000);
|
|
}
|
|
})();
|
|
</script>
|
|
</section>`,
|
|
};
|
|
};
|