fix(audit P2 data): NaN guards (events/tours/zaps), query-key fixes, no array mutation, pure preference updater, zap limit

This commit is contained in:
SkateSpots 2026-10-05 21:37:01 +07:00
parent 84f80d8330
commit 2f6c6230f3
11 changed files with 49 additions and 26 deletions

View file

@ -36,7 +36,7 @@ const blankSignals = (): AccountSignals => ({
export function useBatchSpotCommunityScores( export function useBatchSpotCommunityScores(
spots: Array<{ id: string; pubkey: string; identifier: string }>, spots: Array<{ id: string; pubkey: string; identifier: string }>,
) { ) {
const { querySkateContent } = useSkateRelays(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const refs = useMemo( const refs = useMemo(
() => spots.map((s) => `${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`), () => spots.map((s) => `${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`),
@ -49,7 +49,7 @@ export function useBatchSpotCommunityScores(
}, [spots]); }, [spots]);
return useQuery<Map<string, SpotCommunityMini>>({ return useQuery<Map<string, SpotCommunityMini>>({
queryKey: ['batch-spot-community', refs.slice().sort().join(',')], queryKey: ['batch-spot-community', refs.slice().sort().join(','), getQueryKeySuffix()],
enabled: refs.length > 0, enabled: refs.length > 0,
staleTime: 2 * 60 * 1000, staleTime: 2 * 60 * 1000,
queryFn: async (c) => { queryFn: async (c) => {

View file

@ -15,7 +15,7 @@ export function useBatchTrustScores(pubkeys: string[]) {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
return useQuery({ return useQuery({
queryKey: ['batch-trust-scores', pubkeys.sort().join(','), user?.pubkey], queryKey: ['batch-trust-scores', [...pubkeys].sort().join(','), user?.pubkey],
queryFn: async (c) => { queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]); const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
const scores = new Map<string, number>(); const scores = new Map<string, number>();

View file

@ -174,8 +174,14 @@ function fillMonthGaps(keys: string[]): string[] {
// ─── Hook ──────────────────────────────────────────────────────────────────── // ─── Hook ────────────────────────────────────────────────────────────────────
export function useGrowthStats() { /** Stable, short hash of a string — keeps a query key small with many hidden items. */
// Community stats read from the CANONICAL community relay (goskatespots) only, function tinyHash(input: string): number {
let h = 0;
for (let i = 0; i < input.length; i++) h = (h * 31 + input.charCodeAt(i)) | 0;
return h;
}
export function useGrowthStats() { // Community stats read from the CANONICAL community relay (goskatespots) only,
// so the numbers always match the authoritative community dataset — not a // so the numbers always match the authoritative community dataset — not a
// blend of the user's personal relays that would drift per-device. // blend of the user's personal relays that would drift per-device.
const { queryCanonical } = useSkateRelays(); const { queryCanonical } = useSkateRelays();
@ -183,7 +189,11 @@ export function useGrowthStats() {
// it, so a spot the community removed can't inflate the numbers. The moderation // it, so a spot the community removed can't inflate the numbers. The moderation
// set is part of the query key so the stats recompute once the labels load. // set is part of the query key so the stats recompute once the labels load.
const hidden = useAdminModeration(); const hidden = useAdminModeration();
const hiddenSig = `${hidden.coords.size}:${hidden.ids.size}`; // Sig must change when the SET changes, not just its size — hiding one item
// while unhiding another (same count) must still recompute the stats.
const hiddenSig = `${hidden.coords.size}:${hidden.ids.size}:${tinyHash(
`${[...hidden.coords].sort().join(',')}#${[...hidden.ids].sort().join(',')}`,
)}`;
return useQuery<GrowthStats>({ return useQuery<GrowthStats>({
queryKey: ['growth-stats', 'canonical', hiddenSig], queryKey: ['growth-stats', 'canonical', hiddenSig],

View file

@ -26,7 +26,7 @@ function useMyRsvpCoords(): Set<string> {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const query = useQuery({ const query = useQuery({
queryKey: ['my-rsvp-coords', user?.pubkey, getQueryKeySuffix()], queryKey: ['my-rsvp-coords', 'reminders', user?.pubkey, getQueryKeySuffix()],
enabled: !!user?.pubkey, enabled: !!user?.pubkey,
staleTime: 60_000, staleTime: 60_000,
queryFn: async (c): Promise<string[]> => { queryFn: async (c): Promise<string[]> => {

View file

@ -22,7 +22,7 @@ export function useNewsletterIssues(limit = 30) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({ return useQuery({
queryKey: ['newsletter-issues', getQueryKeySuffix()], queryKey: ['newsletter-issues', limit, getQueryKeySuffix()],
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000, gcTime: 30 * 60 * 1000,
queryFn: async (c): Promise<SkateNewsletterIssue[]> => { queryFn: async (c): Promise<SkateNewsletterIssue[]> => {

View file

@ -59,13 +59,13 @@ export function usePreferences() {
const setPreferences = useCallback( const setPreferences = useCallback(
(patch: Partial<Omit<Preferences, 'updatedAt'>>) => { (patch: Partial<Omit<Preferences, 'updatedAt'>>) => {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
setLocal((prev) => { // Compute OUTSIDE the updater: a state updater must be pure (React may run
const next: Preferences = { ...prev, ...patch, updatedAt: now }; // it twice), and `synced.patch` publishes an encrypted event.
void synced.patch(next); const next: Preferences = { ...local, ...patch, updatedAt: now };
return next; setLocal(next);
}); void synced.patch(next);
}, },
[setLocal, synced], [local, setLocal, synced],
); );
return { preferences: local, setPreferences }; return { preferences: local, setPreferences };

View file

@ -86,7 +86,7 @@ export function useBatchSellerSales(pubkeys: string[]) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({ return useQuery({
queryKey: ['batch-seller-sales', pubkeys.sort().join(','), getQueryKeySuffix()], queryKey: ['batch-seller-sales', [...pubkeys].sort().join(','), getQueryKeySuffix()],
queryFn: async () => { queryFn: async () => {
if (pubkeys.length === 0) return new Map<string, number>(); if (pubkeys.length === 0) return new Map<string, number>();

View file

@ -179,7 +179,7 @@ export function useSkateRelays() {
*/ */
const getQueryKeySuffix = (): string => { const getQueryKeySuffix = (): string => {
const relays = getReadRelays(); const relays = getReadRelays();
return relays.sort().join(','); return [...relays].sort().join(',');
}; };
return { return {

View file

@ -124,11 +124,11 @@ export function communityLevel(breakdown: SpotCommunityBreakdown, score: number)
export function useSpotCommunityScore( export function useSpotCommunityScore(
spot: Pick<{ pubkey: string; identifier: string; id: string }, 'pubkey' | 'identifier' | 'id'> | null | undefined, spot: Pick<{ pubkey: string; identifier: string; id: string }, 'pubkey' | 'identifier' | 'id'> | null | undefined,
) { ) {
const { querySkateContent } = useSkateRelays(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const coord = spot ? `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}` : undefined; const coord = spot ? `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}` : undefined;
const query = useQuery<NostrEvent[]>({ const query = useQuery<NostrEvent[]>({
queryKey: ['spot-community-score', coord, spot?.id], queryKey: ['spot-community-score', coord, spot?.id, getQueryKeySuffix()],
enabled: !!spot && !!coord, enabled: !!spot && !!coord,
staleTime: 2 * 60 * 1000, staleTime: 2 * 60 * 1000,
queryFn: async () => { queryFn: async () => {

View file

@ -61,6 +61,7 @@ export function useZaps(
const events = await nostr.query([{ const events = await nostr.query([{
kinds: [9735], kinds: [9735],
'#a': [`${actualTarget.kind}:${actualTarget.pubkey}:${identifier}`], '#a': [`${actualTarget.kind}:${actualTarget.pubkey}:${identifier}`],
limit: 500,
}], { signal }); }], { signal });
return events; return events;
} else { } else {
@ -68,6 +69,7 @@ export function useZaps(
const events = await nostr.query([{ const events = await nostr.query([{
kinds: [9735], kinds: [9735],
'#e': [actualTarget.id], '#e': [actualTarget.id],
limit: 500,
}], { signal }); }], { signal });
return events; return events;
} }
@ -92,9 +94,12 @@ export function useZaps(
// Method 1: amount tag (from zap request, sometimes copied to receipt) // Method 1: amount tag (from zap request, sometimes copied to receipt)
const amountTag = zap.tags.find(([name]) => name === 'amount')?.[1]; const amountTag = zap.tags.find(([name]) => name === 'amount')?.[1];
if (amountTag) { if (amountTag) {
const millisats = parseInt(amountTag); const millisats = Number(amountTag);
sats += Math.floor(millisats / 1000); if (Number.isFinite(millisats) && millisats > 0) {
return; sats += Math.floor(millisats / 1000);
return;
}
// Malformed amount — fall through to the bolt11 method.
} }
// Method 2: Extract from bolt11 invoice // Method 2: Extract from bolt11 invoice

View file

@ -1020,6 +1020,11 @@ export function parseSkateEvent(event: NostrEvent): SkateEvent | null {
if (!d || !title || !start || !hasSkateboardingTag) return null; if (!d || !title || !start || !hasSkateboardingTag) return null;
// Reject a malformed timestamp — NaN in `start` poisons sorting and date
// rendering for the whole feed.
const startNum = Number(start);
if (!Number.isFinite(startNum) || startNum <= 0) return null;
// Validate title length // Validate title length
if (title.trim().length < MIN_TITLE_LEN || title.length > MAX_TITLE_LEN) return null; if (title.trim().length < MIN_TITLE_LEN || title.length > MAX_TITLE_LEN) return null;
@ -1039,8 +1044,8 @@ export function parseSkateEvent(event: NostrEvent): SkateEvent | null {
identifier: d, identifier: d,
title: title.slice(0, MAX_TITLE_LEN), title: title.slice(0, MAX_TITLE_LEN),
description: event.content.slice(0, MAX_DESCRIPTION_LEN), description: event.content.slice(0, MAX_DESCRIPTION_LEN),
start: parseInt(start), start: startNum,
end: end ? parseInt(end) : undefined, end: end && Number.isFinite(Number(end)) ? Number(end) : undefined,
geohash, geohash,
location, location,
image, image,
@ -3252,13 +3257,16 @@ export function parseSkateTour(event: NostrEvent): SkateTour | null {
const geohash = event.tags.find(([name]) => name === 'g')?.[1]; const geohash = event.tags.find(([name]) => name === 'g')?.[1];
const meetingPoint = event.tags.find(([name]) => name === 'meeting-point')?.[1]; const meetingPoint = event.tags.find(([name]) => name === 'meeting-point')?.[1];
const meetingCoordsTag = event.tags.find(([name]) => name === 'meeting-point-coords'); const meetingCoordsTag = event.tags.find(([name]) => name === 'meeting-point-coords');
const meetingPointCoords = meetingCoordsTag const mpLat = Number(meetingCoordsTag?.[1]);
? { lat: parseFloat(meetingCoordsTag[1]), lon: parseFloat(meetingCoordsTag[2]) } const mpLon = Number(meetingCoordsTag?.[2]);
const meetingPointCoords = meetingCoordsTag && Number.isFinite(mpLat) && Number.isFinite(mpLon)
? { lat: mpLat, lon: mpLon }
: undefined; : undefined;
const image = event.tags.find(([name]) => name === 'image')?.[1]; const image = event.tags.find(([name]) => name === 'image')?.[1];
const durationTag = event.tags.find(([name]) => name === 'duration'); const durationTag = event.tags.find(([name]) => name === 'duration');
const duration = durationTag ? parseFloat(durationTag[1]) : undefined; const durationNum = durationTag ? Number(durationTag[1]) : undefined;
const duration = durationNum !== undefined && Number.isFinite(durationNum) ? durationNum : undefined;
const durationUnit = durationTag?.[2] as SkateTour['durationUnit'] || 'hours'; const durationUnit = durationTag?.[2] as SkateTour['durationUnit'] || 'hours';
const skillLevel = event.tags.find(([name]) => name === 'skill-level')?.[1] as SkateTour['skillLevel']; const skillLevel = event.tags.find(([name]) => name === 'skill-level')?.[1] as SkateTour['skillLevel'];