fix: tier 3 — medium bugs & hardening

- 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 <Link> (MarketplacePage); EventCountdown reacts to
  language changes.
This commit is contained in:
SkateSpots 2026-10-02 22:26:46 +07:00
parent 582089c1e3
commit e016ee20bd
17 changed files with 101 additions and 68 deletions

View file

@ -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<EventStatus, string> = {
live: 'bg-[#ff0040] text-white border-[#ff0040] animate-pulse',

View file

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

View file

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

View file

@ -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 = (
<div className={cn(
'flex items-center gap-2 transition-opacity hover:opacity-80',
@ -113,9 +118,9 @@ function SponsorItem({ sponsor, size }: { sponsor: Sponsor; size: 'banner' | 'ca
size === 'card' && 'flex-row',
size === 'inline' && 'inline-flex',
)}>
{sponsor.logo && (
{safeLogo && (
<img
src={sponsor.logo}
src={safeLogo}
alt={sponsor.name}
className={cn(
'object-contain',
@ -130,7 +135,7 @@ function SponsorItem({ sponsor, size }: { sponsor: Sponsor; size: 'banner' | 'ca
size === 'banner' && 'text-sm text-gray-400',
size === 'card' && 'text-sm text-gray-300',
size === 'inline' && 'text-xs text-gray-400',
!sponsor.logo && 'text-gray-300', // More visible if no logo
!safeLogo && 'text-gray-300', // More visible if no logo
)}>
{sponsor.name}
</span>
@ -140,10 +145,10 @@ function SponsorItem({ sponsor, size }: { sponsor: Sponsor; size: 'banner' | 'ca
</div>
);
if (sponsor.url) {
if (safeUrl) {
return (
<a
href={sponsor.url}
href={safeUrl}
target="_blank"
rel="noopener noreferrer"
className="block"

View file

@ -70,8 +70,12 @@ export function useFoodSpots(geohash: string | undefined, radiusKm = 1) {
const data = await response.json();
const foodSpots: FoodSpot[] = data.elements
.map((element: OverpassElement) => {
// 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[];

View file

@ -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) => {

View file

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

View file

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

View file

@ -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<string, NostrEvent>();
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<string, NostrEvent>();
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<string, NostrEvent>();
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?.();

View file

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

View file

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

View file

@ -350,6 +350,18 @@ export async function getPendingPostsCount(): Promise<number> {
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<void> {
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) ============
/**

View file

@ -26,8 +26,10 @@ interface StoredRoute {
type Store = Record<string, StoredRoute>;
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: {

View file

@ -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() {
</CardContent>
</Card>
</>
) : stats && stats.topMappers.length > 0 ? (
) : stats && (stats.topMappers ?? []).length > 0 ? (
<>
<Card className="bg-black/80 border border-white/8">
<CardContent className="pt-5">
{stats.topMappers.slice(0, 5).map((m, i) => (
{(stats.topMappers ?? []).slice(0, 5).map((m, i) => (
<MapperRow key={m.pubkey} pubkey={m.pubkey} rank={i + 1} spotCount={m.spotCount} />
))}
</CardContent>

View file

@ -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() {
<div>
<h4 className="font-bold text-[#ff0040] mb-1">{t('marketUi.marketplace.needBitcoinTitle')}</h4>
<p className="text-sm text-gray-300">
{t('marketUi.marketplace.needBitcoinBefore')} <a href="/onboarding" className="text-[#ccff00] underline font-bold">{t('marketUi.marketplace.onboardingGuide')}</a> {t('marketUi.marketplace.needBitcoinAfter')}
{t('marketUi.marketplace.needBitcoinBefore')} <Link to="/onboarding" className="text-[#ccff00] underline font-bold">{t('marketUi.marketplace.onboardingGuide')}</Link> {t('marketUi.marketplace.needBitcoinAfter')}
</p>
</div>
</div>
@ -209,12 +210,12 @@ export default function MarketplacePage() {
{t('marketUi.marketplace.ownShopBody')}
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/create-shop">
<Link to="/create-shop">
<Button className="gap-2 bg-[#ff0040] hover:bg-[#ff0040]/90 text-white border-2 border-[#ff0040] shadow-[0_0_20px_rgba(255,0,64,0.5)] font-bold uppercase px-8">
<Store className="h-5 w-5" />
{t('marketUi.marketplace.addShop')}
</Button>
</a>
</Link>
<a href="https://plebeian.market/sell" target="_blank" rel="noopener noreferrer">
<Button variant="outline" className="gap-2 border-2 border-[#ccff00]/50 hover:bg-[#ccff00]/10 text-[#ccff00] font-bold uppercase px-8">
<Package className="h-5 w-5" />

View file

@ -290,7 +290,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
)}
{/* SkateSpots member badge — the app logo, linking to the site.
Shown for any handle issued by our own domain (name@skate-spots.org). */}
{isSkateSpotsNip05(nip05) && (
{nip05Verified && isSkateSpotsNip05(nip05) && (
<ProfileBadges
badges={[SKATESPOTS_BADGE]}
size="sm"

View file

@ -124,7 +124,7 @@ export default function VerifyCardPage() {
);
const ev = [...events].sort((a, b) => b.created_at - a.created_at)[0];
if (!ev) return { status: 'none' };
return { status: ev.tags.find(([n]) => n === 'status')?.[1] ?? 'going' };
return { status: ev.tags.find(([n]) => n === 'status')?.[1] ?? 'accepted' };
},
});
@ -240,7 +240,7 @@ export default function VerifyCardPage() {
{isTicket && ticket.data && (
<Row
label={t('cardUi.verify.rsvp')}
value={ticket.data.status === 'going' ? t('cardUi.verify.rsvpGoing') : ticket.data.status === 'interested' ? t('cardUi.verify.rsvpInterested') : t('cardUi.verify.rsvpNone')}
value={(ticket.data.status === 'accepted' || ticket.data.status === 'going') ? t('cardUi.verify.rsvpGoing') : (ticket.data.status === 'tentative' || ticket.data.status === 'interested') ? t('cardUi.verify.rsvpInterested') : t('cardUi.verify.rsvpNone')}
/>
)}
</div>