From e016ee20bd1996306de55b56be5b5ebf72e15d0d Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 2 Oct 2026 22:26:46 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20tier=203=20=E2=80=94=20medium=20bugs=20&?= =?UTF-8?q?=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tour sponsor URL/logo sanitized before href/src (SponsorSlot) — same XSS guard EventSponsors already uses. - Official SkateSpots badge only shows when the NIP-05 actually verifies (NIP19Page), not on a self-asserted handle. - Community stats reads are null-guarded on persisted-cache fields (monthly/cumulativeSpots/topMappers) — no more crash on an old cache. - Overpass response no longer assumes .elements (food layer). - user-listings query key is memoized (was Date.now() → refetch storm). - Pro grant requires the l=pro label (was accepted with none). - Verify card RSVP maps accepted/tentative (was going/interested → showed 'No RSVP' on a valid ticket). - Expired listings can't be bought via a stale/shared URL. - Batch spot photo/status hooks include the relay suffix and use querySkateContent (relay-policy parity with useSpotReports). - Offline queue requeues rows stuck in 'publishing' after a crash. - querySkateContent/queryCanonical dedupe replaceable events by author+kind (not id) so a stale profile/relay list can't win. - Route offline cache key includes the origin (was destination+mode only). - Internal links use (MarketplacePage); EventCountdown reacts to language changes. --- src/components/EventCountdown.tsx | 4 +- src/components/ListingTradeActions.tsx | 2 + src/components/RouteDialog.tsx | 4 +- src/components/SponsorSlot.tsx | 15 ++++--- src/hooks/useFoodSpots.ts | 10 +++-- src/hooks/useListingRestrictions.ts | 7 ++- src/hooks/useOfflineCache.ts | 2 + src/hooks/useSasquatchPro.ts | 2 +- src/hooks/useSkateRelays.ts | 59 ++++++++++++-------------- src/hooks/useSpotContributions.ts | 7 +-- src/hooks/useSpotStatus.ts | 7 +-- src/lib/offlineCache.ts | 12 ++++++ src/lib/routeCache.ts | 12 ++++-- src/pages/CommunityStatsPage.tsx | 13 +++--- src/pages/MarketplacePage.tsx | 7 +-- src/pages/NIP19Page.tsx | 2 +- src/pages/VerifyCardPage.tsx | 4 +- 17 files changed, 101 insertions(+), 68 deletions(-) diff --git a/src/components/EventCountdown.tsx b/src/components/EventCountdown.tsx index b7f8bca..fcece49 100644 --- a/src/components/EventCountdown.tsx +++ b/src/components/EventCountdown.tsx @@ -61,9 +61,9 @@ export function EventCountdown({ return () => clearInterval(id); }, []); - // `tick` is a dependency so the label actually recomputes on each interval. + // `tick` forces a periodic recompute; `t` so a language switch updates it. // eslint-disable-next-line react-hooks/exhaustive-deps - const info = useMemo(() => computeStatus(start, end, t), [start, end, tick]); + const info = useMemo(() => computeStatus(start, end, t), [start, end, tick, t]); const styles: Record = { live: 'bg-[#ff0040] text-white border-[#ff0040] animate-pulse', diff --git a/src/components/ListingTradeActions.tsx b/src/components/ListingTradeActions.tsx index 8b27483..581af2d 100644 --- a/src/components/ListingTradeActions.tsx +++ b/src/components/ListingTradeActions.tsx @@ -86,11 +86,13 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) { const iConfirmedTrade = !!trade?.iConfirmed; const iRequestedConfirmation = !!requestState?.iRequested; + const isExpired = !!item.expiration && item.expiration < Math.floor(Date.now() / 1000); const canBuy = !!user && !isOwnListing && !isWTB && !isSold && + !isExpired && !iConfirmedTrade && item.priceType === 'fixed' && typeof buyAmountSats === 'number' && diff --git a/src/components/RouteDialog.tsx b/src/components/RouteDialog.tsx index 24ae71f..ce59be1 100644 --- a/src/components/RouteDialog.tsx +++ b/src/components/RouteDialog.tsx @@ -706,7 +706,7 @@ export function RouteDialog({ setCachedAge(null); setRoute(result); setTransitIndex(0); - if (mode !== 'transit') saveRoute(dest, mode, result); + if (mode !== 'transit') saveRoute(effectiveOrigin, dest, mode, result); const map = mapRef.current; if (map) { @@ -754,7 +754,7 @@ export function RouteDialog({ // Offline / server down: fall back to the last route we saved for this // destination + mode, clearly labelled, instead of a dead dialog. if (mode !== 'transit') { - const hit = loadRoute(dest, mode); + const hit = loadRoute(effectiveOrigin, dest, mode); if (hit) { setRoute(hit.route); setCachedAge(hit.ageMs); diff --git a/src/components/SponsorSlot.tsx b/src/components/SponsorSlot.tsx index 33a09cc..e99abe4 100644 --- a/src/components/SponsorSlot.tsx +++ b/src/components/SponsorSlot.tsx @@ -15,6 +15,7 @@ */ import { cn } from '@/lib/utils'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; export interface Sponsor { name: string; @@ -106,6 +107,10 @@ export function SponsorSlot({ // Individual sponsor display function SponsorItem({ sponsor, size }: { sponsor: Sponsor; size: 'banner' | 'card' | 'inline' }) { + // SECURITY: sponsor data comes from event tags (a tour author controls it), + // so sanitize before use as an href/src — same as EventSponsors. + const safeUrl = sponsor.url ? sanitizeUrl(sponsor.url) : null; + const safeLogo = sponsor.logo ? sanitizeUrl(sponsor.logo) : null; const content = (
- {sponsor.logo && ( + {safeLogo && ( {sponsor.name} {sponsor.name} @@ -140,10 +145,10 @@ function SponsorItem({ sponsor, size }: { sponsor: Sponsor; size: 'banner' | 'ca
); - if (sponsor.url) { + if (safeUrl) { return ( { + // Overpass returns an error/status payload without `elements` on failure; + // never assume it exists (a bare `.map` crashed the food layer). + const elements: OverpassElement[] = Array.isArray(data?.elements) ? data.elements : []; + + const foodSpots: FoodSpot[] = elements + .map((element: OverpassElement): FoodSpot | null => { const lat = element.lat ?? element.center?.lat; const lon = element.lon ?? element.center?.lon; const tags = element.tags ?? {}; @@ -96,7 +100,7 @@ export function useFoodSpots(geohash: string | undefined, radiusKm = 1) { distance, }; }) - .filter(Boolean) + .filter((s): s is FoodSpot => s !== null) .sort((a: FoodSpot, b: FoodSpot) => (a.distance ?? Infinity) - (b.distance ?? Infinity)); return foodSpots as FoodSpot[]; diff --git a/src/hooks/useListingRestrictions.ts b/src/hooks/useListingRestrictions.ts index f16c34d..59915d6 100644 --- a/src/hooks/useListingRestrictions.ts +++ b/src/hooks/useListingRestrictions.ts @@ -108,7 +108,12 @@ export function useListingRestrictions(linkedSpotPubkey?: string): ListingRestri }); // Query user's listings (last 30 days) - const thirtyDaysAgo = Math.floor((Date.now() - 30 * 24 * 60 * 60 * 1000) / 1000); + // Day-granular + memoized: computing this from Date.now() each render changed + // the query key continuously and caused a refetch storm. + const thirtyDaysAgo = useMemo( + () => Math.floor((Date.now() - 30 * 24 * 60 * 60 * 1000) / 1000), + [], + ); const { data: userListings, isLoading: listingsLoading } = useQuery({ queryKey: ['user-listings', user?.pubkey, thirtyDaysAgo], queryFn: async (c) => { diff --git a/src/hooks/useOfflineCache.ts b/src/hooks/useOfflineCache.ts index ab5ea93..d5e1fd4 100644 --- a/src/hooks/useOfflineCache.ts +++ b/src/hooks/useOfflineCache.ts @@ -16,6 +16,7 @@ import { getPendingPosts, getPendingPostsCount, prunePendingPosts, + reconcileStalePendingPosts, removePendingPost, updatePendingPostStatus, } from '@/lib/offlineCache'; @@ -201,6 +202,7 @@ export function useSyncPendingPosts() { if (cancelled || syncingRef.current || globalSyncing) return; await prunePendingPosts().catch(() => {}); + await reconcileStalePendingPosts().catch(() => {}); const pending = await getPendingPosts(); if (pending.length === 0) return; if (cancelled) return; diff --git a/src/hooks/useSasquatchPro.ts b/src/hooks/useSasquatchPro.ts index 353ccec..e7b113e 100644 --- a/src/hooks/useSasquatchPro.ts +++ b/src/hooks/useSasquatchPro.ts @@ -134,7 +134,7 @@ function grantExpiry(grant: NostrEvent, userPubkey: string): number | null { const namespace = grant.tags.filter(([n]) => n === 'L').map(([, v]) => v); if (!namespace.includes(PRO_LABEL_NAMESPACE)) return null; const value = grant.tags.filter(([n]) => n === 'l').map(([, v]) => v); - if (value.length && !value.includes(PRO_LABEL_VALUE)) return null; + if (!value.includes(PRO_LABEL_VALUE)) return null; const taggedUser = grant.tags.some(([n, v]) => n === 'p' && v === userPubkey); if (!taggedUser) return null; const expiry = Number(tagVal(grant.tags, 'expiry')); diff --git a/src/hooks/useSkateRelays.ts b/src/hooks/useSkateRelays.ts index 62de3bc..48e5b01 100644 --- a/src/hooks/useSkateRelays.ts +++ b/src/hooks/useSkateRelays.ts @@ -6,6 +6,30 @@ import { useCitrine } from '@/hooks/useCitrine'; import { SKATE_RELAY } from '@/lib/types'; import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; +/** + * Deduplicate a batch of events from several relays, keeping the newest per + * identity: + * - addressable (has `d`): `pubkey:kind:d-tag`; + * - replaceable (0, 3, 10000–19999): `pubkey:kind` (NIP-01 keeps only the + * latest per author+kind — keying by id would surface stale profiles/relay + * lists when relays return different versions); + * - otherwise: the event id. + */ +function dedupeEvents(events: NostrEvent[]): NostrEvent[] { + const map = new Map(); + for (const event of events) { + const dTag = event.tags.find(([name]) => name === 'd')?.[1]; + let key: string; + if (dTag) key = `${event.pubkey}:${event.kind}:${dTag}`; + else if (event.kind === 0 || event.kind === 3 || (event.kind >= 10000 && event.kind < 20000)) { + key = `${event.pubkey}:${event.kind}`; + } else key = event.id; + const existing = map.get(key); + if (!existing || event.created_at > existing.created_at) map.set(key, event); + } + return Array.from(map.values()); +} + /** * Hook that provides relay-aware query functions for skate-specific content. * @@ -120,26 +144,7 @@ export function useSkateRelays() { const allEvents = results.flat(); - // Deduplicate addressable events by pubkey+kind+d-tag - const eventMap = new Map(); - for (const event of allEvents) { - const dTag = event.tags.find(([name]) => name === 'd')?.[1]; - if (dTag) { - // Addressable event - dedupe by coordinate - const key = `${event.pubkey}:${event.kind}:${dTag}`; - const existing = eventMap.get(key); - if (!existing || event.created_at > existing.created_at) { - eventMap.set(key, event); - } - } else { - // Regular event - dedupe by id - eventMap.set(event.id, event); - } - } - - const uniqueEvents = Array.from(eventMap.values()); - - return uniqueEvents; + return dedupeEvents(allEvents); }; /** @@ -159,19 +164,7 @@ export function useSkateRelays() { const relay = nostr.relay(SKATE_RELAY); const events = await relay.query(filters, { signal: options.signal }); - // Dedupe addressable events by pubkey+kind+d-tag (keep newest). - const eventMap = new Map(); - for (const event of events) { - const dTag = event.tags.find(([name]) => name === 'd')?.[1]; - if (dTag) { - const key = `${event.pubkey}:${event.kind}:${dTag}`; - const existing = eventMap.get(key); - if (!existing || event.created_at > existing.created_at) eventMap.set(key, event); - } else { - eventMap.set(event.id, event); - } - } - return Array.from(eventMap.values()); + return dedupeEvents(events); } catch (err) { // See querySkateContent: an outage must not look like "empty". options.onError?.(); diff --git a/src/hooks/useSpotContributions.ts b/src/hooks/useSpotContributions.ts index 6328a12..be0fc2a 100644 --- a/src/hooks/useSpotContributions.ts +++ b/src/hooks/useSpotContributions.ts @@ -3,6 +3,7 @@ import { useNostr } from '@nostrify/react'; import { useQuery } from '@tanstack/react-query'; import type { NostrEvent } from '@nostrify/nostrify'; import { SKATE_SPOT_KIND } from '@/lib/types'; +import { useSkateRelays } from '@/hooks/useSkateRelays'; // Kind 1063 = File Metadata (NIP-94) for community photos export const PHOTO_CONTRIBUTION_KIND = 1063; @@ -197,7 +198,7 @@ export function useAggregatedSpotTags(spotPubkey: string, spotIdentifier: string * own picture is expected, not a community signal. */ export function useBatchSpotPhotos(spots: Array<{ pubkey: string; identifier: string }>) { - const { nostr } = useNostr(); + const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const refs = useMemo( () => spots.map((s) => `${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`), @@ -205,12 +206,12 @@ export function useBatchSpotPhotos(spots: Array<{ pubkey: string; identifier: st ); const query = useQuery({ - queryKey: ['batch-spot-photos', refs.slice().sort().join(',')], + queryKey: ['batch-spot-photos', getQueryKeySuffix(), refs.slice().sort().join(',')], enabled: refs.length > 0, staleTime: 60_000, queryFn: async (c) => { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]); - const events = await nostr.query( + const events = await querySkateContent( [{ kinds: [PHOTO_CONTRIBUTION_KIND], '#a': refs, limit: 1000 }], { signal }, ); diff --git a/src/hooks/useSpotStatus.ts b/src/hooks/useSpotStatus.ts index f7d08ad..913a1a5 100644 --- a/src/hooks/useSpotStatus.ts +++ b/src/hooks/useSpotStatus.ts @@ -12,6 +12,7 @@ import { import { useBatchTrustScores } from '@/hooks/useBatchTrustScores'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useSkateRelays } from '@/hooks/useSkateRelays'; /** Reports older than this are ignored entirely (spots do change back). */ const REPORT_MAX_AGE_SECONDS = 180 * 24 * 60 * 60; // 180 days @@ -173,7 +174,7 @@ export function useSpotStatus(spotPubkey: string, spotIdentifier: string) { * Returns a Map keyed by spot coordinate (`30818:pubkey:dtag`). */ export function useBatchSpotStatus(spots: Array<{ pubkey: string; identifier: string }>) { - const { nostr } = useNostr(); + const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const spotRefs = useMemo( () => spots.map((s) => spotRefFor(s.pubkey, s.identifier)), @@ -181,12 +182,12 @@ export function useBatchSpotStatus(spots: Array<{ pubkey: string; identifier: st ); const reportsQuery = useQuery({ - queryKey: ['batch-spot-status', spotRefs.slice().sort().join(',')], + queryKey: ['batch-spot-status', getQueryKeySuffix(), spotRefs.slice().sort().join(',')], queryFn: async (c) => { if (spotRefs.length === 0) return [] as NostrEvent[]; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]); // Relays index single-letter tags, so '#a' with many refs is one query. - const events = await nostr.query( + const events = await querySkateContent( [{ kinds: [SPOT_STATUS_KIND], '#a': spotRefs, limit: 1000 }], { signal }, ); diff --git a/src/lib/offlineCache.ts b/src/lib/offlineCache.ts index 03fa160..a742ee1 100644 --- a/src/lib/offlineCache.ts +++ b/src/lib/offlineCache.ts @@ -350,6 +350,18 @@ export async function getPendingPostsCount(): Promise { return db.pendingPosts.count(); } +/** + * Requeue posts left in `publishing` by a crash/reload. Without this they are + * never retried (`getPendingPosts` skips `publishing`) yet still counted, so the + * action silently disappears while the badge claims it is still pending. + */ +export async function reconcileStalePendingPosts(): Promise { + const db = getDB(); + const all = await db.pendingPosts.toArray(); + const stuck = all.filter((p) => p.status === 'publishing'); + await Promise.all(stuck.map((p) => db.pendingPosts.update(p.id, { status: 'pending' }))); +} + // ============ Encrypted Stash Cache (offline readability) ============ /** diff --git a/src/lib/routeCache.ts b/src/lib/routeCache.ts index f4e596d..94ce5a1 100644 --- a/src/lib/routeCache.ts +++ b/src/lib/routeCache.ts @@ -26,8 +26,10 @@ interface StoredRoute { type Store = Record; -function keyFor(to: { lat: number; lng: number }, mode: string): string { - return `${to.lat.toFixed(4)},${to.lng.toFixed(4)}:${mode}`; +function keyFor(from: { lat: number; lng: number }, to: { lat: number; lng: number }, mode: string): string { + // Origin is part of the key: a route saved from a different start point is not + // a valid fallback for the one the user chose (the "Saved route" label would lie). + return `${from.lat.toFixed(4)},${from.lng.toFixed(4)}>${to.lat.toFixed(4)},${to.lng.toFixed(4)}:${mode}`; } function read(): Store { @@ -60,13 +62,14 @@ function downsample(coords: Array<[number, number]>, max = MAX_POINTS): Array<[n /** Remember a freshly-fetched route (best effort). */ export function saveRoute( + from: { lat: number; lng: number }, to: { lat: number; lng: number }, mode: string, route: RouteResult, ): void { if (!route.coordinates?.length) return; const store = read(); - store[keyFor(to, mode)] = { + store[keyFor(from, to, mode)] = { at: Date.now(), distanceM: route.distanceM, durationS: route.durationS, @@ -82,10 +85,11 @@ export function saveRoute( /** Read a cached route for this destination+mode, with how old it is. */ export function loadRoute( + from: { lat: number; lng: number }, to: { lat: number; lng: number }, mode: string, ): { route: RouteResult; ageMs: number } | null { - const stored = read()[keyFor(to, mode)]; + const stored = read()[keyFor(from, to, mode)]; if (!stored || !Array.isArray(stored.coordinates) || stored.coordinates.length < 2) return null; return { route: { diff --git a/src/pages/CommunityStatsPage.tsx b/src/pages/CommunityStatsPage.tsx index 8d67df5..8d3c989 100644 --- a/src/pages/CommunityStatsPage.tsx +++ b/src/pages/CommunityStatsPage.tsx @@ -205,11 +205,14 @@ export default function CommunityStatsPage() { }, [stats, t]); // Only show last 12 months on charts to keep them readable - const recentMonthly = useMemo(() => stats?.monthly.slice(-12) ?? [], [stats]); - const recentCumulative = useMemo(() => stats?.cumulativeSpots.slice(-12) ?? [], [stats]); + const recentMonthly = useMemo(() => stats?.monthly?.slice(-12) ?? [], [stats]); + const recentCumulative = useMemo(() => stats?.cumulativeSpots?.slice(-12) ?? [], [stats]); // Current month delta — how many items were added this month - const thisMonth = useMemo(() => stats?.monthly[stats.monthly.length - 1], [stats]); + const thisMonth = useMemo( + () => (stats?.monthly?.length ? stats.monthly[stats.monthly.length - 1] : undefined), + [stats], + ); // Total across ALL Lab creator/service crafts (matches what /lab lists) const labTotal = useMemo(() => (stats ? labCraftTotal(stats) : 0), [stats]); @@ -630,11 +633,11 @@ export default function CommunityStatsPage() { - ) : stats && stats.topMappers.length > 0 ? ( + ) : stats && (stats.topMappers ?? []).length > 0 ? ( <> - {stats.topMappers.slice(0, 5).map((m, i) => ( + {(stats.topMappers ?? []).slice(0, 5).map((m, i) => ( ))} diff --git a/src/pages/MarketplacePage.tsx b/src/pages/MarketplacePage.tsx index d6739a4..e04560b 100644 --- a/src/pages/MarketplacePage.tsx +++ b/src/pages/MarketplacePage.tsx @@ -1,4 +1,5 @@ import { useSeoMeta } from '@unhead/react'; +import { Link } from 'react-router-dom'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -192,7 +193,7 @@ export default function MarketplacePage() {

{t('marketUi.marketplace.needBitcoinTitle')}

- {t('marketUi.marketplace.needBitcoinBefore')} {t('marketUi.marketplace.onboardingGuide')} {t('marketUi.marketplace.needBitcoinAfter')} + {t('marketUi.marketplace.needBitcoinBefore')} {t('marketUi.marketplace.onboardingGuide')} {t('marketUi.marketplace.needBitcoinAfter')}

@@ -209,12 +210,12 @@ export default function MarketplacePage() { {t('marketUi.marketplace.ownShopBody')}

- + - +