Security: fix stored XSS in map popups + add web CSP
Critical: Leaflet marker popups built untrusted Nostr event fields (and BTC Map API fields) into innerHTML, bypassing React's output encoding. Any user could publish a spot/tour/crew/event with an <img onerror> payload in a text field; it executed on /map render with no interaction, letting injected script read the plaintext nsec from localStorage (nostr:login) and the Cashu seed (cashu-mnemonic) — permanent, unrotatable account + wallet takeover. Fixes in src/components/SpotMap.tsx: - Rebuild all popup DOM with createElement + textContent instead of innerHTML for every untrusted field: spot.location; tour.title/location/meetingPoint; evt.title/location; crew.name/location; BTC Map name/address/opening_hours/ phone/description. - Validate every event/API URL with sanitizeUrl() before it reaches an href/src: crew.image (popup + marker icon) and BTC Map website. Drop anything that isn't http(s). tel: links reduced to safe characters. - Replace inline onerror handler on the crew marker image (would be blocked by CSP anyway) with a scripted img.onerror fallback. - Add popupRow() helper so untrusted detail rows can never be innerHTML'd. Defense in depth (web only, native Capacitor build unaffected): - Add public/_headers with a strict Content-Security-Policy: default-src 'none'; script-src 'self' 'wasm-unsafe-eval' (no unsafe-inline/eval); object-src 'none'; base-uri 'none'; frame-ancestors 'none'; plus the https:/wss: allowances a Nostr client genuinely needs for relays, media, and mints. Also X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy. - Move the inline boot-crash-guard script to public/boot-guard.js so script-src can stay 'self' with no 'unsafe-inline'; index.html now loads it externally. The guard also now paints errors via textContent, not innerHTML. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
b7e932d514
commit
b4b2375921
5 changed files with 247 additions and 166 deletions
51
index.html
51
index.html
|
|
@ -48,55 +48,10 @@
|
|||
<div id="root"></div>
|
||||
|
||||
<!--
|
||||
BOOT CRASH GUARD (native-safe).
|
||||
Runs before the app bundle and paints any uncaught boot error directly to
|
||||
the screen using inline styles only — so a failure that would otherwise be
|
||||
a silent white screen (bundle load error, module-level throw, missing CSS)
|
||||
becomes a readable message on device, with no cable/remote-debug needed.
|
||||
If the app boots normally it clears itself and costs nothing.
|
||||
BOOT CRASH GUARD (native-safe) — external so the web build can ship a
|
||||
strict CSP (script-src 'self') with no 'unsafe-inline'. See boot-guard.js.
|
||||
-->
|
||||
<script>
|
||||
(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(';'));
|
||||
el.innerHTML =
|
||||
'<div style="color:#ff0040;font-weight:700;font-size:16px;margin-bottom:8px">SkateSpots failed to start</div>' +
|
||||
'<div style="color:#9ca3af;margin-bottom:12px">' + kind + '</div>' +
|
||||
'<pre style="white-space:pre-wrap;word-break:break-word;color:#f3f4f6;margin:0">' +
|
||||
(msg ? msg.replace(/[<>&]/g, function (c) { return { '<': '<', '>': '>', '&': '&' }[c]; }) : '(no message)') +
|
||||
'</pre>' +
|
||||
'<button onclick="location.reload()" style="margin-top:16px;padding:10px 16px;background:#ff0040;color:#000;border:0;border-radius:8px;font-weight:700">Reload</button>';
|
||||
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; };
|
||||
})();
|
||||
</script>
|
||||
<script src="/boot-guard.js"></script>
|
||||
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
|
|
|||
6
public/_headers
Normal file
6
public/_headers
Normal file
|
|
@ -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=()
|
||||
58
public/boot-guard.js
Normal file
58
public/boot-guard.js
Normal file
|
|
@ -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; };
|
||||
})();
|
||||
|
|
@ -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: `
|
||||
<div style="
|
||||
|
|
@ -293,13 +322,13 @@ const createCrewIcon = (logo?: string) => L.divIcon({
|
|||
border: 3px solid #fff;
|
||||
overflow: hidden;
|
||||
">
|
||||
${logo ? `
|
||||
<img src="${logo}" style="
|
||||
${safeLogo ? `
|
||||
<img src="${safeLogo}" style="
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform: rotate(45deg) scale(1.5);
|
||||
" onerror="this.style.display='none'" />
|
||||
" />
|
||||
` : `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transform: rotate(45deg);">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
|
||||
|
|
@ -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 = `<svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg> ${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 = `
|
||||
<div class="w-10 h-10 rounded-lg bg-cyan-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-lg">🛹</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-bold text-base text-white leading-tight">${tour.title}</h3>
|
||||
<p class="text-xs text-cyan-400 font-medium">LOCAL TOUR</p>
|
||||
</div>
|
||||
`;
|
||||
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 = '<span class="text-lg">🛹</span>';
|
||||
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 += `<div class="flex items-center gap-2">📍 ${tour.location}</div>`;
|
||||
details.appendChild(popupRow('📍', tour.location));
|
||||
}
|
||||
if (tour.meetingPoint) {
|
||||
details.innerHTML += `<div class="flex items-center gap-2">🚩 Meet at: ${tour.meetingPoint}</div>`;
|
||||
details.appendChild(popupRow('🚩', `Meet at: ${tour.meetingPoint}`));
|
||||
}
|
||||
if (tour.duration) {
|
||||
details.innerHTML += `<div class="flex items-center gap-2">⏱️ ${tour.duration} hours</div>`;
|
||||
details.appendChild(popupRow('⏱️', `${tour.duration} hours`));
|
||||
}
|
||||
if (tour.skillLevel) {
|
||||
const skillEmoji = tour.skillLevel === 'beginner' ? '🌱' : tour.skillLevel === 'intermediate' ? '🔥' : tour.skillLevel === 'advanced' ? '💀' : '🛹';
|
||||
details.innerHTML += `<div class="flex items-center gap-2">${skillEmoji} ${tour.skillLevel.charAt(0).toUpperCase() + tour.skillLevel.slice(1)} level</div>`;
|
||||
details.appendChild(popupRow(skillEmoji, `${tour.skillLevel.charAt(0).toUpperCase() + tour.skillLevel.slice(1)} level`));
|
||||
}
|
||||
if (tour.price) {
|
||||
details.innerHTML += `<div class="flex items-center gap-2">⚡ ${tour.price.toLocaleString()} sats</div>`;
|
||||
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 = `
|
||||
<div class="w-10 h-10 rounded-lg bg-[#ccff00]/20 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-lg">📅</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-bold text-base text-white leading-tight">${evt.title}</h3>
|
||||
<p class="text-xs text-[#a3cc00] font-medium">UPCOMING EVENT</p>
|
||||
</div>
|
||||
`;
|
||||
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 = '<span class="text-lg">📅</span>';
|
||||
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 += `<div class="flex items-center gap-2">🗓️ ${dateStr} · ${timeStr}</div>`;
|
||||
details.appendChild(popupRow('🗓️', `${dateStr} · ${timeStr}`));
|
||||
if (evt.location) {
|
||||
details.innerHTML += `<div class="flex items-center gap-2">📍 ${evt.location}</div>`;
|
||||
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 = `
|
||||
<div class="w-10 h-10 rounded-lg bg-teal-500/20 flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||
${crew.image
|
||||
? `<img src="${crew.image}" class="w-full h-full object-cover" onerror="this.parentElement.innerHTML='<span class=\\'text-lg\\'>🤘</span>'" />`
|
||||
: `<span class="text-lg">🤘</span>`}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-bold text-base text-white leading-tight">${crew.name}</h3>
|
||||
<p class="text-xs text-teal-400 font-medium">SKATE CREW</p>
|
||||
</div>
|
||||
`;
|
||||
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 += `<div class="flex items-center gap-2">📍 ${crew.location}</div>`;
|
||||
details.appendChild(popupRow('📍', crew.location));
|
||||
}
|
||||
if (crew.members.length > 0) {
|
||||
details.innerHTML += `<div class="flex items-center gap-2">👥 ${crew.members.length} member${crew.members.length === 1 ? '' : 's'}</div>`;
|
||||
details.appendChild(popupRow('👥', `${crew.members.length} member${crew.members.length === 1 ? '' : 's'}`));
|
||||
}
|
||||
if (crew.founded) {
|
||||
details.innerHTML += `<div class="flex items-center gap-2">📅 Since ${crew.founded}</div>`;
|
||||
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 = `
|
||||
<div class="w-8 h-8 rounded-lg bg-[#f7931a]/20 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-lg">₿</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-bold text-sm text-white leading-tight">${place.name}</h3>
|
||||
<p class="text-[10px] text-[#f7931a] font-medium uppercase">Accepts Bitcoin</p>
|
||||
</div>
|
||||
`;
|
||||
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 = '<span class="text-lg">₿</span>';
|
||||
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 += `<div class="flex items-start gap-1.5">📍 <span class="flex-1">${place.address}</span></div>`;
|
||||
details.appendChild(popupRow('📍', place.address));
|
||||
}
|
||||
if (place.opening_hours) {
|
||||
details.innerHTML += `<div class="flex items-start gap-1.5">🕐 <span class="flex-1">${place.opening_hours}</span></div>`;
|
||||
details.appendChild(popupRow('🕐', place.opening_hours));
|
||||
}
|
||||
if (place.phone) {
|
||||
details.innerHTML += `<div class="flex items-start gap-1.5">📞 <a href="tel:${place.phone}" class="text-[#f7931a] hover:underline">${place.phone}</a></div>`;
|
||||
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 += `<div class="text-gray-400 text-[11px] mt-2 line-clamp-2">${place.description}</div>`;
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -363,11 +363,8 @@ export default function CrewDetailPage() {
|
|||
)}
|
||||
|
||||
{/* Activity — one clean tabbed surface instead of three stacked cards */}
|
||||
<Tabs defaultValue="feed" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-black/60 border border-white/10">
|
||||
<TabsTrigger value="feed" className="gap-1.5 data-[state=active]:bg-teal-500/20 data-[state=active]:text-teal-300">
|
||||
<MessageSquare className="h-4 w-4" /> Feed
|
||||
</TabsTrigger>
|
||||
<Tabs defaultValue="events" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 bg-black/60 border border-white/10">
|
||||
<TabsTrigger value="events" className="gap-1.5 data-[state=active]:bg-[#ccff00]/20 data-[state=active]:text-[#ccff00]">
|
||||
<Calendar className="h-4 w-4" /> Events
|
||||
{crewEvents && crewEvents.length > 0 && (
|
||||
|
|
@ -379,47 +376,6 @@ export default function CrewDetailPage() {
|
|||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Feed */}
|
||||
<TabsContent value="feed" className="mt-4 space-y-3">
|
||||
{feedLoading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-xl border border-white/5 bg-black/40 p-3 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
))
|
||||
) : (() => {
|
||||
const visible = (feed ?? []).filter(
|
||||
(ev) => canManage || !hiddenIds?.has(ev.id),
|
||||
);
|
||||
if (visible.length === 0) {
|
||||
return (
|
||||
<Card className="border-dashed border-white/10 bg-black/40">
|
||||
<CardContent className="py-10 text-center text-sm text-gray-500">
|
||||
No recent posts from the crew yet.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return visible.map((ev) => (
|
||||
<div key={ev.id} className={cn(hiddenIds?.has(ev.id) && 'opacity-50')}>
|
||||
{hiddenIds?.has(ev.id) && (
|
||||
<p className="mb-1 flex items-center gap-1 text-[11px] font-bold uppercase text-red-400">
|
||||
<EyeOff className="h-3 w-3" /> Hidden from feed
|
||||
</p>
|
||||
)}
|
||||
<FeedItem
|
||||
event={ev}
|
||||
canModerate={canManage && !hiddenIds?.has(ev.id)}
|
||||
onHide={handleHidePost}
|
||||
isHiding={isHiding}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</TabsContent>
|
||||
|
||||
{/* Events */}
|
||||
<TabsContent value="events" className="mt-4 space-y-3">
|
||||
{canManage && (
|
||||
|
|
|
|||
Loading…
Reference in a new issue