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:
parent
84f80d8330
commit
2f6c6230f3
11 changed files with 49 additions and 26 deletions
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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>();
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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[]> => {
|
||||||
|
|
|
||||||
|
|
@ -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[]> => {
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue