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:
shakespeare.diy 2026-08-16 22:12:26 +07:00
parent b7e932d514
commit b4b2375921
5 changed files with 247 additions and 166 deletions

View file

@ -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 { '<': '&lt;', '>': '&gt;', '&': '&amp;' }[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
View 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
View 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; };
})();

View file

@ -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);

View file

@ -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 && (