diff --git a/index.html b/index.html index 332624c..8329cc6 100644 --- a/index.html +++ b/index.html @@ -48,55 +48,10 @@
- + diff --git a/public/_headers b/public/_headers new file mode 100644 index 0000000..63cff23 --- /dev/null +++ b/public/_headers @@ -0,0 +1,6 @@ +/* + Content-Security-Policy: default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: blob: https:; font-src 'self' https://fonts.gstatic.com data:; connect-src 'self' https: wss:; media-src 'self' blob: https:; frame-src 'self' https:; worker-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests + X-Content-Type-Options: nosniff + X-Frame-Options: DENY + Referrer-Policy: strict-origin-when-cross-origin + Permissions-Policy: geolocation=(self), camera=(self), microphone=() diff --git a/public/boot-guard.js b/public/boot-guard.js new file mode 100644 index 0000000..8682db8 --- /dev/null +++ b/public/boot-guard.js @@ -0,0 +1,58 @@ +/* + BOOT CRASH GUARD (native-safe). + Loaded as an external script so the web build can ship a strict + Content-Security-Policy (script-src 'self') with no 'unsafe-inline'. + Paints any uncaught boot error directly to the screen using inline styles + only — so a failure that would otherwise be a silent white screen becomes a + readable message on device. If the app boots normally it clears itself. +*/ +(function () { + var shown = false; + function paint(kind, err) { + if (shown) return; + shown = true; + try { + var msg = (err && (err.stack || err.message)) ? (err.stack || err.message) : String(err); + var el = document.createElement('div'); + el.setAttribute('style', [ + 'position:fixed', 'inset:0', 'z-index:2147483647', + 'background:#000', 'color:#fff', 'padding:20px', + 'font:13px/1.5 -apple-system,system-ui,monospace', + 'overflow:auto', '-webkit-overflow-scrolling:touch' + ].join(';')); + var head = document.createElement('div'); + head.setAttribute('style', 'color:#ff0040;font-weight:700;font-size:16px;margin-bottom:8px'); + head.textContent = 'SkateSpots failed to start'; + var sub = document.createElement('div'); + sub.setAttribute('style', 'color:#9ca3af;margin-bottom:12px'); + sub.textContent = kind; + var pre = document.createElement('pre'); + pre.setAttribute('style', 'white-space:pre-wrap;word-break:break-word;color:#f3f4f6;margin:0'); + pre.textContent = msg || '(no message)'; + var btn = document.createElement('button'); + btn.setAttribute('style', 'margin-top:16px;padding:10px 16px;background:#ff0040;color:#000;border:0;border-radius:8px;font-weight:700'); + btn.textContent = 'Reload'; + btn.addEventListener('click', function () { location.reload(); }); + el.appendChild(head); + el.appendChild(sub); + el.appendChild(pre); + el.appendChild(btn); + document.body.appendChild(el); + } catch (e) { /* last resort: nothing we can do */ } + } + window.addEventListener('error', function (e) { + paint('Uncaught error', e.error || e.message); + }); + window.addEventListener('unhandledrejection', function (e) { + paint('Unhandled promise rejection', e.reason); + }); + // If nothing has rendered into #root after 12s, surface a hint. + setTimeout(function () { + var root = document.getElementById('root'); + if (root && root.childElementCount === 0) { + paint('App did not render (timeout)', 'The bundle loaded but nothing mounted within 12s. This usually means a boot-time exception was swallowed.'); + } + }, 12000); + // Let the app signal a clean boot to cancel the timeout guard. + window.__SKATESPOTS_BOOTED__ = function () { shown = true; }; +})(); diff --git a/src/components/SpotMap.tsx b/src/components/SpotMap.tsx index 4584199..9a062c4 100644 --- a/src/components/SpotMap.tsx +++ b/src/components/SpotMap.tsx @@ -11,6 +11,30 @@ import { SPOT_TYPE_LABELS, formatPrice } from '@/lib/types'; import type { SpotWeatherData } from '@/hooks/useSpotWeather'; import type { BtcMapPlace } from '@/hooks/useBtcMapPlaces'; import { isRecentlyVerified, isBoosted } from '@/hooks/useBtcMapPlaces'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; + +/** + * SECURITY: Leaflet popups are built as raw DOM, outside React's auto-escaping. + * Every value that originates from a Nostr event (spot/tour/crew/event fields) + * or a third-party API (BTC Map places) is UNTRUSTED and must never be placed + * into `innerHTML`. These helpers build popup rows with `textContent` (which + * cannot execute markup) and validate URLs with `sanitizeUrl` before they touch + * an `href`/`src`. See .agents/skills/nostr-security. + */ + +/** A popup detail row: an emoji/icon prefix + untrusted text set via textContent. */ +function popupRow(prefix: string, text: string): HTMLElement { + const row = document.createElement('div'); + row.className = 'flex items-center gap-2'; + const label = document.createElement('span'); + // Prefix is always a hardcoded emoji/label — safe as textContent too. + label.textContent = prefix; + const value = document.createElement('span'); + value.textContent = text; // untrusted — textContent neutralizes any markup + row.appendChild(label); + row.appendChild(value); + return row; +} // Fix default marker icon issues with webpack/vite const defaultIcon = L.icon({ @@ -277,7 +301,12 @@ const createEventIcon = () => L.divIcon({ }); // Custom crew marker icon (teal/cyan — crew home turf) -const createCrewIcon = (logo?: string) => L.divIcon({ +const createCrewIcon = (logo?: string) => { + // SECURITY: crew.image is untrusted. Only embed it when it's a valid http(s) + // URL; sanitizeUrl returns a parsed href with quotes percent-encoded, so it + // cannot break out of the double-quoted src="" attribute below. + const safeLogo = sanitizeUrl(logo); + return L.divIcon({ className: 'crew-marker', html: `
- ${logo ? ` - + " /> ` : ` @@ -313,7 +342,8 @@ const createCrewIcon = (logo?: string) => L.divIcon({ iconSize: [42, 42], iconAnchor: [21, 42], popupAnchor: [0, -42], -}); + }); +}; // Custom BTC Map marker icon (for Bitcoin-accepting places) const createBtcMapIcon = (isBoosted?: boolean) => L.divIcon({ @@ -450,11 +480,29 @@ function createPopupContent( title.textContent = spot.title; container.appendChild(title); - // Location + // Location — SVG is static markup, but spot.location is UNTRUSTED, so it is + // written via textContent (never innerHTML) to neutralize any injected markup. if (spot.location) { const location = document.createElement('p'); location.className = 'text-sm text-gray-500 mb-2 flex items-center gap-1'; - location.innerHTML = ` ${spot.location}`; + const pin = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + pin.setAttribute('class', 'h-3 w-3'); + pin.setAttribute('viewBox', '0 0 24 24'); + pin.setAttribute('fill', 'none'); + pin.setAttribute('stroke', 'currentColor'); + pin.setAttribute('stroke-width', '2'); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', 'M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z'); + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + circle.setAttribute('cx', '12'); + circle.setAttribute('cy', '10'); + circle.setAttribute('r', '3'); + pin.appendChild(path); + pin.appendChild(circle); + const text = document.createElement('span'); + text.textContent = spot.location; + location.appendChild(pin); + location.appendChild(text); container.appendChild(location); } @@ -1104,39 +1152,45 @@ export function SpotMap({ const container = document.createElement('div'); container.className = 'p-3 min-w-[250px] max-w-[300px]'; - // Tour header + // Tour header — icon block is static; tour.title is UNTRUSTED (textContent). const header = document.createElement('div'); header.className = 'flex items-start gap-2 mb-3'; - header.innerHTML = ` -
- 🛹 -
-
-

${tour.title}

-

LOCAL TOUR

-
- `; + const tourIconBox = document.createElement('div'); + tourIconBox.className = 'w-10 h-10 rounded-lg bg-cyan-500/20 flex items-center justify-center flex-shrink-0'; + tourIconBox.innerHTML = '🛹'; + const tourHeadText = document.createElement('div'); + tourHeadText.className = 'flex-1 min-w-0'; + const tourTitle = document.createElement('h3'); + tourTitle.className = 'font-bold text-base text-white leading-tight'; + tourTitle.textContent = tour.title; + const tourKicker = document.createElement('p'); + tourKicker.className = 'text-xs text-cyan-400 font-medium'; + tourKicker.textContent = 'LOCAL TOUR'; + tourHeadText.appendChild(tourTitle); + tourHeadText.appendChild(tourKicker); + header.appendChild(tourIconBox); + header.appendChild(tourHeadText); container.appendChild(header); - // Tour details + // Tour details — untrusted strings go through popupRow (textContent). const details = document.createElement('div'); details.className = 'space-y-1.5 mb-3 text-xs text-gray-300'; - + if (tour.location) { - details.innerHTML += `
📍 ${tour.location}
`; + details.appendChild(popupRow('📍', tour.location)); } if (tour.meetingPoint) { - details.innerHTML += `
🚩 Meet at: ${tour.meetingPoint}
`; + details.appendChild(popupRow('🚩', `Meet at: ${tour.meetingPoint}`)); } if (tour.duration) { - details.innerHTML += `
⏱️ ${tour.duration} hours
`; + details.appendChild(popupRow('⏱️', `${tour.duration} hours`)); } if (tour.skillLevel) { const skillEmoji = tour.skillLevel === 'beginner' ? '🌱' : tour.skillLevel === 'intermediate' ? '🔥' : tour.skillLevel === 'advanced' ? '💀' : '🛹'; - details.innerHTML += `
${skillEmoji} ${tour.skillLevel.charAt(0).toUpperCase() + tour.skillLevel.slice(1)} level
`; + details.appendChild(popupRow(skillEmoji, `${tour.skillLevel.charAt(0).toUpperCase() + tour.skillLevel.slice(1)} level`)); } if (tour.price) { - details.innerHTML += `
⚡ ${tour.price.toLocaleString()} sats
`; + details.appendChild(popupRow('⚡', `${tour.price.toLocaleString()} sats`)); } container.appendChild(details); @@ -1194,29 +1248,35 @@ export function SpotMap({ const container = document.createElement('div'); container.className = 'p-3 min-w-[240px] max-w-[300px]'; - // Header + // Header — icon block is static; evt.title is UNTRUSTED (textContent). const header = document.createElement('div'); header.className = 'flex items-start gap-2 mb-3'; - header.innerHTML = ` -
- 📅 -
-
-

${evt.title}

-

UPCOMING EVENT

-
- `; + const evtIconBox = document.createElement('div'); + evtIconBox.className = 'w-10 h-10 rounded-lg bg-[#ccff00]/20 flex items-center justify-center flex-shrink-0'; + evtIconBox.innerHTML = '📅'; + const evtHeadText = document.createElement('div'); + evtHeadText.className = 'flex-1 min-w-0'; + const evtTitle = document.createElement('h3'); + evtTitle.className = 'font-bold text-base text-white leading-tight'; + evtTitle.textContent = evt.title; + const evtKicker = document.createElement('p'); + evtKicker.className = 'text-xs text-[#a3cc00] font-medium'; + evtKicker.textContent = 'UPCOMING EVENT'; + evtHeadText.appendChild(evtTitle); + evtHeadText.appendChild(evtKicker); + header.appendChild(evtIconBox); + header.appendChild(evtHeadText); container.appendChild(header); - // Details + // Details — date is app-generated; evt.location is UNTRUSTED (textContent). const details = document.createElement('div'); details.className = 'space-y-1.5 mb-3 text-xs text-gray-300'; const startDate = new Date(evt.start * 1000); const dateStr = startDate.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); const timeStr = startDate.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); - details.innerHTML += `
🗓️ ${dateStr} · ${timeStr}
`; + details.appendChild(popupRow('🗓️', `${dateStr} · ${timeStr}`)); if (evt.location) { - details.innerHTML += `
📍 ${evt.location}
`; + details.appendChild(popupRow('📍', evt.location)); } container.appendChild(details); @@ -1257,33 +1317,56 @@ export function SpotMap({ const container = document.createElement('div'); container.className = 'p-3 min-w-[240px] max-w-[300px]'; - // Header + // Header — crew.image + crew.name are UNTRUSTED. const header = document.createElement('div'); header.className = 'flex items-start gap-2 mb-3'; - header.innerHTML = ` -
- ${crew.image - ? `` - : `🤘`} -
-
-

${crew.name}

-

SKATE CREW

-
- `; + const crewIconBox = document.createElement('div'); + crewIconBox.className = 'w-10 h-10 rounded-lg bg-teal-500/20 flex items-center justify-center flex-shrink-0 overflow-hidden'; + const safeCrewImg = sanitizeUrl(crew.image); + if (safeCrewImg) { + const img = document.createElement('img'); + img.src = safeCrewImg; // validated http(s) URL + img.className = 'w-full h-full object-cover'; + // Fallback to emoji on load error — no inline handler / no innerHTML. + img.onerror = () => { + crewIconBox.textContent = ''; + const fallback = document.createElement('span'); + fallback.className = 'text-lg'; + fallback.textContent = '🤘'; + crewIconBox.appendChild(fallback); + }; + crewIconBox.appendChild(img); + } else { + const emoji = document.createElement('span'); + emoji.className = 'text-lg'; + emoji.textContent = '🤘'; + crewIconBox.appendChild(emoji); + } + const crewHeadText = document.createElement('div'); + crewHeadText.className = 'flex-1 min-w-0'; + const crewName = document.createElement('h3'); + crewName.className = 'font-bold text-base text-white leading-tight'; + crewName.textContent = crew.name; + const crewKicker = document.createElement('p'); + crewKicker.className = 'text-xs text-teal-400 font-medium'; + crewKicker.textContent = 'SKATE CREW'; + crewHeadText.appendChild(crewName); + crewHeadText.appendChild(crewKicker); + header.appendChild(crewIconBox); + header.appendChild(crewHeadText); container.appendChild(header); - // Details + // Details — crew.location is UNTRUSTED; counts/founded via popupRow too. const details = document.createElement('div'); details.className = 'space-y-1.5 mb-3 text-xs text-gray-300'; if (crew.location) { - details.innerHTML += `
📍 ${crew.location}
`; + details.appendChild(popupRow('📍', crew.location)); } if (crew.members.length > 0) { - details.innerHTML += `
👥 ${crew.members.length} member${crew.members.length === 1 ? '' : 's'}
`; + details.appendChild(popupRow('👥', `${crew.members.length} member${crew.members.length === 1 ? '' : 's'}`)); } if (crew.founded) { - details.innerHTML += `
📅 Since ${crew.founded}
`; + details.appendChild(popupRow('📅', `Since ${crew.founded}`)); } container.appendChild(details); @@ -1351,21 +1434,27 @@ export function SpotMap({ const container = document.createElement('div'); container.className = 'p-3 min-w-[220px] max-w-[300px]'; - // Header with Bitcoin icon + // Header with Bitcoin icon — place.name (from BTC Map API) is UNTRUSTED. const header = document.createElement('div'); header.className = 'flex items-start gap-2 mb-2'; - header.innerHTML = ` -
- ₿ -
-
-

${place.name}

-

Accepts Bitcoin

-
- `; + const btcIconBox = document.createElement('div'); + btcIconBox.className = 'w-8 h-8 rounded-lg bg-[#f7931a]/20 flex items-center justify-center flex-shrink-0'; + btcIconBox.innerHTML = '₿'; + const btcHeadText = document.createElement('div'); + btcHeadText.className = 'flex-1 min-w-0'; + const btcName = document.createElement('h3'); + btcName.className = 'font-bold text-sm text-white leading-tight'; + btcName.textContent = place.name; + const btcKicker = document.createElement('p'); + btcKicker.className = 'text-[10px] text-[#f7931a] font-medium uppercase'; + btcKicker.textContent = 'Accepts Bitcoin'; + btcHeadText.appendChild(btcName); + btcHeadText.appendChild(btcKicker); + header.appendChild(btcIconBox); + header.appendChild(btcHeadText); container.appendChild(header); - // Badges row + // Badges row — static markup only (no untrusted data). const badges = document.createElement('div'); badges.className = 'flex flex-wrap gap-1 mb-2'; if (boosted) { @@ -1378,20 +1467,35 @@ export function SpotMap({ container.appendChild(badges); } - // Details + // Details — every place field is UNTRUSTED (textContent / sanitized tel). const details = document.createElement('div'); details.className = 'space-y-1 mb-3 text-xs text-gray-300'; if (place.address) { - details.innerHTML += `
📍 ${place.address}
`; + details.appendChild(popupRow('📍', place.address)); } if (place.opening_hours) { - details.innerHTML += `
🕐 ${place.opening_hours}
`; + details.appendChild(popupRow('🕐', place.opening_hours)); } if (place.phone) { - details.innerHTML += `
📞 ${place.phone}
`; + const phoneRow = document.createElement('div'); + phoneRow.className = 'flex items-start gap-1.5'; + const phoneIcon = document.createElement('span'); + phoneIcon.textContent = '📞'; + const phoneLink = document.createElement('a'); + // tel: sanitized to digits/+/-/()/space only, then set via textContent. + const digits = place.phone.replace(/[^0-9+\-\s().]/g, ''); + phoneLink.href = `tel:${digits}`; + phoneLink.className = 'text-[#f7931a] hover:underline'; + phoneLink.textContent = place.phone; + phoneRow.appendChild(phoneIcon); + phoneRow.appendChild(phoneLink); + details.appendChild(phoneRow); } if (place.description) { - details.innerHTML += `
${place.description}
`; + const desc = document.createElement('div'); + desc.className = 'text-gray-400 text-[11px] mt-2 line-clamp-2'; + desc.textContent = place.description; + details.appendChild(desc); } container.appendChild(details); @@ -1399,9 +1503,11 @@ export function SpotMap({ const actions = document.createElement('div'); actions.className = 'flex gap-2'; - if (place.website) { + // place.website is UNTRUSTED — only render the link for valid http(s) URLs. + const safeWebsite = sanitizeUrl(place.website); + if (safeWebsite) { const websiteBtn = document.createElement('a'); - websiteBtn.href = place.website; + websiteBtn.href = safeWebsite; websiteBtn.target = '_blank'; websiteBtn.rel = 'noopener noreferrer'; websiteBtn.className = 'flex-1 inline-flex items-center justify-center rounded-md text-xs font-medium bg-[#f7931a] text-white hover:bg-[#f7931a]/90 h-7 px-2 transition-colors'; @@ -1413,7 +1519,7 @@ export function SpotMap({ btcMapBtn.href = `https://btcmap.org/merchant/${place.id}`; btcMapBtn.target = '_blank'; btcMapBtn.rel = 'noopener noreferrer'; - btcMapBtn.className = `${place.website ? '' : 'flex-1 '}inline-flex items-center justify-center rounded-md text-xs font-medium bg-gray-700 text-white hover:bg-gray-600 h-7 px-2 transition-colors`; + btcMapBtn.className = `${safeWebsite ? '' : 'flex-1 '}inline-flex items-center justify-center rounded-md text-xs font-medium bg-gray-700 text-white hover:bg-gray-600 h-7 px-2 transition-colors`; btcMapBtn.textContent = 'BTC Map →'; actions.appendChild(btcMapBtn); diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx index 7518fc6..4102086 100644 --- a/src/pages/CrewDetailPage.tsx +++ b/src/pages/CrewDetailPage.tsx @@ -363,11 +363,8 @@ export default function CrewDetailPage() { )} {/* Activity — one clean tabbed surface instead of three stacked cards */} - - - - Feed - + + Events {crewEvents && crewEvents.length > 0 && ( @@ -379,47 +376,6 @@ export default function CrewDetailPage() { - {/* Feed */} - - {feedLoading ? ( - Array.from({ length: 3 }).map((_, i) => ( -
- - - -
- )) - ) : (() => { - const visible = (feed ?? []).filter( - (ev) => canManage || !hiddenIds?.has(ev.id), - ); - if (visible.length === 0) { - return ( - - - No recent posts from the crew yet. - - - ); - } - return visible.map((ev) => ( -
- {hiddenIds?.has(ev.id) && ( -

- Hidden from feed -

- )} - -
- )); - })()} -
- {/* Events */} {canManage && (