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(
spots: Array<{ id: string; pubkey: string; identifier: string }>,
) {
const { querySkateContent } = useSkateRelays();
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const refs = useMemo(
() => spots.map((s) => `${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`),
@ -49,7 +49,7 @@ export function useBatchSpotCommunityScores(
}, [spots]);
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,
staleTime: 2 * 60 * 1000,
queryFn: async (c) => {

View file

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

View file

@ -174,8 +174,14 @@ function fillMonthGaps(keys: string[]): string[] {
// ─── Hook ────────────────────────────────────────────────────────────────────
export function useGrowthStats() {
// Community stats read from the CANONICAL community relay (goskatespots) only,
/** Stable, short hash of a string — keeps a query key small with many hidden items. */
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
// blend of the user's personal relays that would drift per-device.
const { queryCanonical } = useSkateRelays();
@ -183,7 +189,11 @@ export function useGrowthStats() {
// 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.
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>({
queryKey: ['growth-stats', 'canonical', hiddenSig],

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -124,11 +124,11 @@ export function communityLevel(breakdown: SpotCommunityBreakdown, score: number)
export function useSpotCommunityScore(
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 query = useQuery<NostrEvent[]>({
queryKey: ['spot-community-score', coord, spot?.id],
queryKey: ['spot-community-score', coord, spot?.id, getQueryKeySuffix()],
enabled: !!spot && !!coord,
staleTime: 2 * 60 * 1000,
queryFn: async () => {

View file

@ -61,6 +61,7 @@ export function useZaps(
const events = await nostr.query([{
kinds: [9735],
'#a': [`${actualTarget.kind}:${actualTarget.pubkey}:${identifier}`],
limit: 500,
}], { signal });
return events;
} else {
@ -68,6 +69,7 @@ export function useZaps(
const events = await nostr.query([{
kinds: [9735],
'#e': [actualTarget.id],
limit: 500,
}], { signal });
return events;
}
@ -92,9 +94,12 @@ export function useZaps(
// Method 1: amount tag (from zap request, sometimes copied to receipt)
const amountTag = zap.tags.find(([name]) => name === 'amount')?.[1];
if (amountTag) {
const millisats = parseInt(amountTag);
sats += Math.floor(millisats / 1000);
return;
const millisats = Number(amountTag);
if (Number.isFinite(millisats) && millisats > 0) {
sats += Math.floor(millisats / 1000);
return;
}
// Malformed amount — fall through to the bolt11 method.
}
// 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;
// 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
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,
title: title.slice(0, MAX_TITLE_LEN),
description: event.content.slice(0, MAX_DESCRIPTION_LEN),
start: parseInt(start),
end: end ? parseInt(end) : undefined,
start: startNum,
end: end && Number.isFinite(Number(end)) ? Number(end) : undefined,
geohash,
location,
image,
@ -3252,13 +3257,16 @@ export function parseSkateTour(event: NostrEvent): SkateTour | null {
const geohash = event.tags.find(([name]) => name === 'g')?.[1];
const meetingPoint = event.tags.find(([name]) => name === 'meeting-point')?.[1];
const meetingCoordsTag = event.tags.find(([name]) => name === 'meeting-point-coords');
const meetingPointCoords = meetingCoordsTag
? { lat: parseFloat(meetingCoordsTag[1]), lon: parseFloat(meetingCoordsTag[2]) }
const mpLat = Number(meetingCoordsTag?.[1]);
const mpLon = Number(meetingCoordsTag?.[2]);
const meetingPointCoords = meetingCoordsTag && Number.isFinite(mpLat) && Number.isFinite(mpLon)
? { lat: mpLat, lon: mpLon }
: undefined;
const image = event.tags.find(([name]) => name === 'image')?.[1];
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 skillLevel = event.tags.find(([name]) => name === 'skill-level')?.[1] as SkateTour['skillLevel'];