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>
|
<div id="root"></div>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
BOOT CRASH GUARD (native-safe).
|
BOOT CRASH GUARD (native-safe) — external so the web build can ship a
|
||||||
Runs before the app bundle and paints any uncaught boot error directly to
|
strict CSP (script-src 'self') with no 'unsafe-inline'. See boot-guard.js.
|
||||||
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.
|
|
||||||
-->
|
-->
|
||||||
<script>
|
<script src="/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 type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</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 { SpotWeatherData } from '@/hooks/useSpotWeather';
|
||||||
import type { BtcMapPlace } from '@/hooks/useBtcMapPlaces';
|
import type { BtcMapPlace } from '@/hooks/useBtcMapPlaces';
|
||||||
import { isRecentlyVerified, isBoosted } 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
|
// Fix default marker icon issues with webpack/vite
|
||||||
const defaultIcon = L.icon({
|
const defaultIcon = L.icon({
|
||||||
|
|
@ -277,7 +301,12 @@ const createEventIcon = () => L.divIcon({
|
||||||
});
|
});
|
||||||
|
|
||||||
// Custom crew marker icon (teal/cyan — crew home turf)
|
// 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',
|
className: 'crew-marker',
|
||||||
html: `
|
html: `
|
||||||
<div style="
|
<div style="
|
||||||
|
|
@ -293,13 +322,13 @@ const createCrewIcon = (logo?: string) => L.divIcon({
|
||||||
border: 3px solid #fff;
|
border: 3px solid #fff;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
">
|
">
|
||||||
${logo ? `
|
${safeLogo ? `
|
||||||
<img src="${logo}" style="
|
<img src="${safeLogo}" style="
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
transform: rotate(45deg) scale(1.5);
|
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);">
|
<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"/>
|
<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],
|
iconSize: [42, 42],
|
||||||
iconAnchor: [21, 42],
|
iconAnchor: [21, 42],
|
||||||
popupAnchor: [0, -42],
|
popupAnchor: [0, -42],
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Custom BTC Map marker icon (for Bitcoin-accepting places)
|
// Custom BTC Map marker icon (for Bitcoin-accepting places)
|
||||||
const createBtcMapIcon = (isBoosted?: boolean) => L.divIcon({
|
const createBtcMapIcon = (isBoosted?: boolean) => L.divIcon({
|
||||||
|
|
@ -450,11 +480,29 @@ function createPopupContent(
|
||||||
title.textContent = spot.title;
|
title.textContent = spot.title;
|
||||||
container.appendChild(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) {
|
if (spot.location) {
|
||||||
const location = document.createElement('p');
|
const location = document.createElement('p');
|
||||||
location.className = 'text-sm text-gray-500 mb-2 flex items-center gap-1';
|
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);
|
container.appendChild(location);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1104,39 +1152,45 @@ export function SpotMap({
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.className = 'p-3 min-w-[250px] max-w-[300px]';
|
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');
|
const header = document.createElement('div');
|
||||||
header.className = 'flex items-start gap-2 mb-3';
|
header.className = 'flex items-start gap-2 mb-3';
|
||||||
header.innerHTML = `
|
const tourIconBox = document.createElement('div');
|
||||||
<div class="w-10 h-10 rounded-lg bg-cyan-500/20 flex items-center justify-center flex-shrink-0">
|
tourIconBox.className = 'w-10 h-10 rounded-lg bg-cyan-500/20 flex items-center justify-center flex-shrink-0';
|
||||||
<span class="text-lg">🛹</span>
|
tourIconBox.innerHTML = '<span class="text-lg">🛹</span>';
|
||||||
</div>
|
const tourHeadText = document.createElement('div');
|
||||||
<div class="flex-1 min-w-0">
|
tourHeadText.className = 'flex-1 min-w-0';
|
||||||
<h3 class="font-bold text-base text-white leading-tight">${tour.title}</h3>
|
const tourTitle = document.createElement('h3');
|
||||||
<p class="text-xs text-cyan-400 font-medium">LOCAL TOUR</p>
|
tourTitle.className = 'font-bold text-base text-white leading-tight';
|
||||||
</div>
|
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);
|
container.appendChild(header);
|
||||||
|
|
||||||
// Tour details
|
// Tour details — untrusted strings go through popupRow (textContent).
|
||||||
const details = document.createElement('div');
|
const details = document.createElement('div');
|
||||||
details.className = 'space-y-1.5 mb-3 text-xs text-gray-300';
|
details.className = 'space-y-1.5 mb-3 text-xs text-gray-300';
|
||||||
|
|
||||||
if (tour.location) {
|
if (tour.location) {
|
||||||
details.innerHTML += `<div class="flex items-center gap-2">📍 ${tour.location}</div>`;
|
details.appendChild(popupRow('📍', tour.location));
|
||||||
}
|
}
|
||||||
if (tour.meetingPoint) {
|
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) {
|
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) {
|
if (tour.skillLevel) {
|
||||||
const skillEmoji = tour.skillLevel === 'beginner' ? '🌱' : tour.skillLevel === 'intermediate' ? '🔥' : tour.skillLevel === 'advanced' ? '💀' : '🛹';
|
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) {
|
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);
|
container.appendChild(details);
|
||||||
|
|
||||||
|
|
@ -1194,29 +1248,35 @@ export function SpotMap({
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.className = 'p-3 min-w-[240px] max-w-[300px]';
|
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');
|
const header = document.createElement('div');
|
||||||
header.className = 'flex items-start gap-2 mb-3';
|
header.className = 'flex items-start gap-2 mb-3';
|
||||||
header.innerHTML = `
|
const evtIconBox = document.createElement('div');
|
||||||
<div class="w-10 h-10 rounded-lg bg-[#ccff00]/20 flex items-center justify-center flex-shrink-0">
|
evtIconBox.className = 'w-10 h-10 rounded-lg bg-[#ccff00]/20 flex items-center justify-center flex-shrink-0';
|
||||||
<span class="text-lg">📅</span>
|
evtIconBox.innerHTML = '<span class="text-lg">📅</span>';
|
||||||
</div>
|
const evtHeadText = document.createElement('div');
|
||||||
<div class="flex-1 min-w-0">
|
evtHeadText.className = 'flex-1 min-w-0';
|
||||||
<h3 class="font-bold text-base text-white leading-tight">${evt.title}</h3>
|
const evtTitle = document.createElement('h3');
|
||||||
<p class="text-xs text-[#a3cc00] font-medium">UPCOMING EVENT</p>
|
evtTitle.className = 'font-bold text-base text-white leading-tight';
|
||||||
</div>
|
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);
|
container.appendChild(header);
|
||||||
|
|
||||||
// Details
|
// Details — date is app-generated; evt.location is UNTRUSTED (textContent).
|
||||||
const details = document.createElement('div');
|
const details = document.createElement('div');
|
||||||
details.className = 'space-y-1.5 mb-3 text-xs text-gray-300';
|
details.className = 'space-y-1.5 mb-3 text-xs text-gray-300';
|
||||||
const startDate = new Date(evt.start * 1000);
|
const startDate = new Date(evt.start * 1000);
|
||||||
const dateStr = startDate.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
const dateStr = startDate.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
||||||
const timeStr = startDate.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
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) {
|
if (evt.location) {
|
||||||
details.innerHTML += `<div class="flex items-center gap-2">📍 ${evt.location}</div>`;
|
details.appendChild(popupRow('📍', evt.location));
|
||||||
}
|
}
|
||||||
container.appendChild(details);
|
container.appendChild(details);
|
||||||
|
|
||||||
|
|
@ -1257,33 +1317,56 @@ export function SpotMap({
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.className = 'p-3 min-w-[240px] max-w-[300px]';
|
container.className = 'p-3 min-w-[240px] max-w-[300px]';
|
||||||
|
|
||||||
// Header
|
// Header — crew.image + crew.name are UNTRUSTED.
|
||||||
const header = document.createElement('div');
|
const header = document.createElement('div');
|
||||||
header.className = 'flex items-start gap-2 mb-3';
|
header.className = 'flex items-start gap-2 mb-3';
|
||||||
header.innerHTML = `
|
const crewIconBox = document.createElement('div');
|
||||||
<div class="w-10 h-10 rounded-lg bg-teal-500/20 flex items-center justify-center flex-shrink-0 overflow-hidden">
|
crewIconBox.className = 'w-10 h-10 rounded-lg bg-teal-500/20 flex items-center justify-center flex-shrink-0 overflow-hidden';
|
||||||
${crew.image
|
const safeCrewImg = sanitizeUrl(crew.image);
|
||||||
? `<img src="${crew.image}" class="w-full h-full object-cover" onerror="this.parentElement.innerHTML='<span class=\\'text-lg\\'>🤘</span>'" />`
|
if (safeCrewImg) {
|
||||||
: `<span class="text-lg">🤘</span>`}
|
const img = document.createElement('img');
|
||||||
</div>
|
img.src = safeCrewImg; // validated http(s) URL
|
||||||
<div class="flex-1 min-w-0">
|
img.className = 'w-full h-full object-cover';
|
||||||
<h3 class="font-bold text-base text-white leading-tight">${crew.name}</h3>
|
// Fallback to emoji on load error — no inline handler / no innerHTML.
|
||||||
<p class="text-xs text-teal-400 font-medium">SKATE CREW</p>
|
img.onerror = () => {
|
||||||
</div>
|
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);
|
container.appendChild(header);
|
||||||
|
|
||||||
// Details
|
// Details — crew.location is UNTRUSTED; counts/founded via popupRow too.
|
||||||
const details = document.createElement('div');
|
const details = document.createElement('div');
|
||||||
details.className = 'space-y-1.5 mb-3 text-xs text-gray-300';
|
details.className = 'space-y-1.5 mb-3 text-xs text-gray-300';
|
||||||
if (crew.location) {
|
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) {
|
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) {
|
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);
|
container.appendChild(details);
|
||||||
|
|
||||||
|
|
@ -1351,21 +1434,27 @@ export function SpotMap({
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.className = 'p-3 min-w-[220px] max-w-[300px]';
|
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');
|
const header = document.createElement('div');
|
||||||
header.className = 'flex items-start gap-2 mb-2';
|
header.className = 'flex items-start gap-2 mb-2';
|
||||||
header.innerHTML = `
|
const btcIconBox = document.createElement('div');
|
||||||
<div class="w-8 h-8 rounded-lg bg-[#f7931a]/20 flex items-center justify-center flex-shrink-0">
|
btcIconBox.className = 'w-8 h-8 rounded-lg bg-[#f7931a]/20 flex items-center justify-center flex-shrink-0';
|
||||||
<span class="text-lg">₿</span>
|
btcIconBox.innerHTML = '<span class="text-lg">₿</span>';
|
||||||
</div>
|
const btcHeadText = document.createElement('div');
|
||||||
<div class="flex-1 min-w-0">
|
btcHeadText.className = 'flex-1 min-w-0';
|
||||||
<h3 class="font-bold text-sm text-white leading-tight">${place.name}</h3>
|
const btcName = document.createElement('h3');
|
||||||
<p class="text-[10px] text-[#f7931a] font-medium uppercase">Accepts Bitcoin</p>
|
btcName.className = 'font-bold text-sm text-white leading-tight';
|
||||||
</div>
|
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);
|
container.appendChild(header);
|
||||||
|
|
||||||
// Badges row
|
// Badges row — static markup only (no untrusted data).
|
||||||
const badges = document.createElement('div');
|
const badges = document.createElement('div');
|
||||||
badges.className = 'flex flex-wrap gap-1 mb-2';
|
badges.className = 'flex flex-wrap gap-1 mb-2';
|
||||||
if (boosted) {
|
if (boosted) {
|
||||||
|
|
@ -1378,20 +1467,35 @@ export function SpotMap({
|
||||||
container.appendChild(badges);
|
container.appendChild(badges);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Details
|
// Details — every place field is UNTRUSTED (textContent / sanitized tel).
|
||||||
const details = document.createElement('div');
|
const details = document.createElement('div');
|
||||||
details.className = 'space-y-1 mb-3 text-xs text-gray-300';
|
details.className = 'space-y-1 mb-3 text-xs text-gray-300';
|
||||||
if (place.address) {
|
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) {
|
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) {
|
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) {
|
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);
|
container.appendChild(details);
|
||||||
|
|
||||||
|
|
@ -1399,9 +1503,11 @@ export function SpotMap({
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'flex gap-2';
|
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');
|
const websiteBtn = document.createElement('a');
|
||||||
websiteBtn.href = place.website;
|
websiteBtn.href = safeWebsite;
|
||||||
websiteBtn.target = '_blank';
|
websiteBtn.target = '_blank';
|
||||||
websiteBtn.rel = 'noopener noreferrer';
|
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';
|
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.href = `https://btcmap.org/merchant/${place.id}`;
|
||||||
btcMapBtn.target = '_blank';
|
btcMapBtn.target = '_blank';
|
||||||
btcMapBtn.rel = 'noopener noreferrer';
|
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 →';
|
btcMapBtn.textContent = 'BTC Map →';
|
||||||
actions.appendChild(btcMapBtn);
|
actions.appendChild(btcMapBtn);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -363,11 +363,8 @@ export default function CrewDetailPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Activity — one clean tabbed surface instead of three stacked cards */}
|
{/* Activity — one clean tabbed surface instead of three stacked cards */}
|
||||||
<Tabs defaultValue="feed" className="w-full">
|
<Tabs defaultValue="events" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-3 bg-black/60 border border-white/10">
|
<TabsList className="grid w-full grid-cols-2 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>
|
|
||||||
<TabsTrigger value="events" className="gap-1.5 data-[state=active]:bg-[#ccff00]/20 data-[state=active]:text-[#ccff00]">
|
<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
|
<Calendar className="h-4 w-4" /> Events
|
||||||
{crewEvents && crewEvents.length > 0 && (
|
{crewEvents && crewEvents.length > 0 && (
|
||||||
|
|
@ -379,47 +376,6 @@ export default function CrewDetailPage() {
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</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 */}
|
{/* Events */}
|
||||||
<TabsContent value="events" className="mt-4 space-y-3">
|
<TabsContent value="events" className="mt-4 space-y-3">
|
||||||
{canManage && (
|
{canManage && (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue