From acfd7d2369c724870960534bc53c81af5805f85c Mon Sep 17 00:00:00 2001 From: limina1 Date: Tue, 22 Jul 2025 13:43:36 -0400 Subject: [PATCH] validate nostr identifiers for id and coord --- src/lib/components/cards/BlogHeader.svelte | 1 - src/lib/utils/displayLimits.ts | 92 +++++++++++++----- src/lib/utils/nostr_identifiers.ts | 88 +++++++++++++++++ src/routes/visualize/+page.svelte | 12 ++- tests/unit/nostr_identifiers.test.ts | 106 +++++++++++++++++++++ 5 files changed, 269 insertions(+), 30 deletions(-) create mode 100644 src/lib/utils/nostr_identifiers.ts create mode 100644 tests/unit/nostr_identifiers.test.ts diff --git a/src/lib/components/cards/BlogHeader.svelte b/src/lib/components/cards/BlogHeader.svelte index cd9e4e3..f6f10f5 100644 --- a/src/lib/components/cards/BlogHeader.svelte +++ b/src/lib/components/cards/BlogHeader.svelte @@ -6,7 +6,6 @@ import Interactions from "$components/util/Interactions.svelte"; import { quintOut } from "svelte/easing"; import CardActions from "$components/util/CardActions.svelte"; -<<<<<<< HEAD import { getMatchingTags } from "$lib/utils/nostrUtils"; import LazyImage from "$components/util/LazyImage.svelte"; import { generateDarkPastelColor } from "$lib/utils/image_utils"; diff --git a/src/lib/utils/displayLimits.ts b/src/lib/utils/displayLimits.ts index e41b64f..77c6161 100644 --- a/src/lib/utils/displayLimits.ts +++ b/src/lib/utils/displayLimits.ts @@ -1,5 +1,6 @@ import type { NDKEvent } from '@nostr-dev-kit/ndk'; import type { VisualizationConfig } from '$lib/stores/visualizationConfig'; +import { isEventId, isCoordinate, parseCoordinate } from './nostr_identifiers'; /** * Filters events based on visualization configuration @@ -51,51 +52,90 @@ export function filterByDisplayLimits(events: NDKEvent[], config: VisualizationC /** * Detects events that are referenced but not present in the current set * @param events - Current events - * @param existingIds - Set of all known event IDs + * @param existingIds - Set of all known event IDs (hex format) + * @param existingCoordinates - Optional map of existing coordinates for NIP-33 detection * @returns Set of missing event identifiers */ -export function detectMissingEvents(events: NDKEvent[], existingIds: Set): Set { +export function detectMissingEvents( + events: NDKEvent[], + existingIds: Set, + existingCoordinates?: Map +): Set { const missing = new Set(); for (const event of events) { + // Check 'e' tags for direct event references (hex IDs) + const eTags = event.getMatchingTags('e'); + for (const eTag of eTags) { + if (eTag.length < 2) continue; + + const eventId = eTag[1]; + + // Type check: ensure it's a valid hex event ID + if (!isEventId(eventId)) { + console.warn('Invalid event ID in e tag:', eventId); + continue; + } + + if (!existingIds.has(eventId)) { + missing.add(eventId); + } + } + // Check 'a' tags for NIP-33 references (kind:pubkey:d-tag) const aTags = event.getMatchingTags('a'); for (const aTag of aTags) { if (aTag.length < 2) continue; const identifier = aTag[1]; - const parts = identifier.split(':'); - if (parts.length >= 3) { - const [kind, pubkey, dTag] = parts; - // Create a synthetic ID for checking - const syntheticId = `${kind}:${pubkey}:${dTag}`; - - // Check if we have an event matching this reference - const hasEvent = Array.from(existingIds).some(id => { - // This is a simplified check - in practice, you'd need to - // check the actual event's d-tag value - return id === dTag || id === syntheticId; - }); - - if (!hasEvent) { - missing.add(dTag); + // Type check: ensure it's a valid coordinate + if (!isCoordinate(identifier)) { + console.warn('Invalid coordinate in a tag:', identifier); + continue; + } + + // Parse the coordinate + const parsed = parseCoordinate(identifier); + if (!parsed) continue; + + // If we have existing coordinates, check if this one exists + if (existingCoordinates) { + if (!existingCoordinates.has(identifier)) { + missing.add(identifier); } + } else { + // Without coordinate map, we can't detect missing NIP-33 events + // This is a limitation when we only have hex IDs + console.debug('Cannot detect missing NIP-33 events without coordinate map:', identifier); } } + } - // Check 'e' tags for direct event references - const eTags = event.getMatchingTags('e'); - for (const eTag of eTags) { - if (eTag.length < 2) continue; + return missing; +} + +/** + * Builds a map of coordinates to events for NIP-33 detection + * @param events - Array of events to build coordinate map from + * @returns Map of coordinate strings to events + */ +export function buildCoordinateMap(events: NDKEvent[]): Map { + const coordinateMap = new Map(); + + for (const event of events) { + // Only process replaceable events (kinds 30000-39999) + if (event.kind && event.kind >= 30000 && event.kind < 40000) { + const dTag = event.tagValue('d'); + const author = event.pubkey; - const eventId = eTag[1]; - if (!existingIds.has(eventId)) { - missing.add(eventId); + if (dTag && author) { + const coordinate = `${event.kind}:${author}:${dTag}`; + coordinateMap.set(coordinate, event); } } } - - return missing; + + return coordinateMap; } diff --git a/src/lib/utils/nostr_identifiers.ts b/src/lib/utils/nostr_identifiers.ts new file mode 100644 index 0000000..8e789d7 --- /dev/null +++ b/src/lib/utils/nostr_identifiers.ts @@ -0,0 +1,88 @@ +import { VALIDATION } from './search_constants'; + +/** + * Nostr identifier types + */ +export type NostrEventId = string; // 64-character hex string +export type NostrCoordinate = string; // kind:pubkey:d-tag format +export type NostrIdentifier = NostrEventId | NostrCoordinate; + +/** + * Interface for parsed Nostr coordinate + */ +export interface ParsedCoordinate { + kind: number; + pubkey: string; + dTag: string; +} + +/** + * Check if a string is a valid hex event ID + * @param id The string to check + * @returns True if it's a valid hex event ID + */ +export function isEventId(id: string): id is NostrEventId { + return new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, 'i').test(id); +} + +/** + * Check if a string is a valid Nostr coordinate (kind:pubkey:d-tag) + * @param coordinate The string to check + * @returns True if it's a valid coordinate + */ +export function isCoordinate(coordinate: string): coordinate is NostrCoordinate { + const parts = coordinate.split(':'); + if (parts.length < 3) return false; + + const [kindStr, pubkey, ...dTagParts] = parts; + + // Check if kind is a valid number + const kind = parseInt(kindStr, 10); + if (isNaN(kind) || kind < 0) return false; + + // Check if pubkey is a valid hex string + if (!isEventId(pubkey)) return false; + + // Check if d-tag exists (can contain colons) + if (dTagParts.length === 0) return false; + + return true; +} + +/** + * Parse a Nostr coordinate into its components + * @param coordinate The coordinate string to parse + * @returns Parsed coordinate or null if invalid + */ +export function parseCoordinate(coordinate: string): ParsedCoordinate | null { + if (!isCoordinate(coordinate)) return null; + + const parts = coordinate.split(':'); + const [kindStr, pubkey, ...dTagParts] = parts; + + return { + kind: parseInt(kindStr, 10), + pubkey, + dTag: dTagParts.join(':') // Rejoin in case d-tag contains colons + }; +} + +/** + * Create a coordinate string from components + * @param kind The event kind + * @param pubkey The author's public key + * @param dTag The d-tag value + * @returns The coordinate string + */ +export function createCoordinate(kind: number, pubkey: string, dTag: string): NostrCoordinate { + return `${kind}:${pubkey}:${dTag}`; +} + +/** + * Check if a string is any valid Nostr identifier + * @param identifier The string to check + * @returns True if it's a valid Nostr identifier + */ +export function isNostrIdentifier(identifier: string): identifier is NostrIdentifier { + return isEventId(identifier) || isCoordinate(identifier); +} \ No newline at end of file diff --git a/src/routes/visualize/+page.svelte b/src/routes/visualize/+page.svelte index 6123dcf..8dc16d7 100644 --- a/src/routes/visualize/+page.svelte +++ b/src/routes/visualize/+page.svelte @@ -13,7 +13,7 @@ import { filterValidIndexEvents } from "$lib/utils"; import { networkFetchLimit } from "$lib/state"; import { visualizationConfig, type EventKindConfig } from "$lib/stores/visualizationConfig"; - import { filterByDisplayLimits, detectMissingEvents } from "$lib/utils/displayLimits"; + import { filterByDisplayLimits, detectMissingEvents, buildCoordinateMap } from "$lib/utils/displayLimits"; import type { PageData } from './$types'; import { getEventKindColor, getEventKindName } from "$lib/utils/eventColors"; import { extractPubkeysFromEvents, batchFetchProfiles } from "$lib/utils/profileCache"; @@ -62,8 +62,14 @@ let missingEventIds = $derived.by(() => { if (allEvents.length > 0) { const eventIds = new Set(allEvents.map(e => e.id)); - const missing = detectMissingEvents(events, eventIds); - debug("Derived missingEventIds update:", { allEvents: allEvents.length, events: events.length, missing: missing.size }); + const coordinateMap = buildCoordinateMap(allEvents); + const missing = detectMissingEvents(events, eventIds, coordinateMap); + debug("Derived missingEventIds update:", { + allEvents: allEvents.length, + events: events.length, + missing: missing.size, + coordinates: coordinateMap.size + }); return missing; } return new Set(); diff --git a/tests/unit/nostr_identifiers.test.ts b/tests/unit/nostr_identifiers.test.ts new file mode 100644 index 0000000..d4c2d1f --- /dev/null +++ b/tests/unit/nostr_identifiers.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { + isEventId, + isCoordinate, + parseCoordinate, + createCoordinate, + isNostrIdentifier +} from '../../src/lib/utils/nostr_identifiers'; + +describe('Nostr Identifier Validation', () => { + describe('isEventId', () => { + it('should validate correct hex event IDs', () => { + const validId = 'a'.repeat(64); + expect(isEventId(validId)).toBe(true); + + const validIdWithMixedCase = 'A'.repeat(32) + 'f'.repeat(32); + expect(isEventId(validIdWithMixedCase)).toBe(true); + }); + + it('should reject invalid event IDs', () => { + expect(isEventId('')).toBe(false); + expect(isEventId('abc')).toBe(false); + expect(isEventId('a'.repeat(63))).toBe(false); // too short + expect(isEventId('a'.repeat(65))).toBe(false); // too long + expect(isEventId('g'.repeat(64))).toBe(false); // invalid hex char + }); + }); + + describe('isCoordinate', () => { + it('should validate correct coordinates', () => { + const validCoordinate = `30040:${'a'.repeat(64)}:chapter-1`; + expect(isCoordinate(validCoordinate)).toBe(true); + + const coordinateWithColonsInDTag = `30041:${'b'.repeat(64)}:chapter:with:colons`; + expect(isCoordinate(coordinateWithColonsInDTag)).toBe(true); + }); + + it('should reject invalid coordinates', () => { + expect(isCoordinate('')).toBe(false); + expect(isCoordinate('abc')).toBe(false); + expect(isCoordinate('30040:abc:chapter-1')).toBe(false); // invalid pubkey + expect(isCoordinate('30040:abc')).toBe(false); // missing d-tag + expect(isCoordinate('abc:def:ghi')).toBe(false); // invalid kind + expect(isCoordinate('-1:abc:def')).toBe(false); // negative kind + }); + }); + + describe('parseCoordinate', () => { + it('should parse valid coordinates correctly', () => { + const coordinate = `30040:${'a'.repeat(64)}:chapter-1`; + const parsed = parseCoordinate(coordinate); + + expect(parsed).toEqual({ + kind: 30040, + pubkey: 'a'.repeat(64), + dTag: 'chapter-1' + }); + }); + + it('should handle d-tags with colons', () => { + const coordinate = `30041:${'b'.repeat(64)}:chapter:with:colons`; + const parsed = parseCoordinate(coordinate); + + expect(parsed).toEqual({ + kind: 30041, + pubkey: 'b'.repeat(64), + dTag: 'chapter:with:colons' + }); + }); + + it('should return null for invalid coordinates', () => { + expect(parseCoordinate('')).toBeNull(); + expect(parseCoordinate('abc')).toBeNull(); + expect(parseCoordinate('30040:abc:chapter-1')).toBeNull(); + }); + }); + + describe('createCoordinate', () => { + it('should create valid coordinates', () => { + const coordinate = createCoordinate(30040, 'a'.repeat(64), 'chapter-1'); + expect(coordinate).toBe(`30040:${'a'.repeat(64)}:chapter-1`); + }); + + it('should handle d-tags with colons', () => { + const coordinate = createCoordinate(30041, 'b'.repeat(64), 'chapter:with:colons'); + expect(coordinate).toBe(`30041:${'b'.repeat(64)}:chapter:with:colons`); + }); + }); + + describe('isNostrIdentifier', () => { + it('should accept valid event IDs', () => { + expect(isNostrIdentifier('a'.repeat(64))).toBe(true); + }); + + it('should accept valid coordinates', () => { + const coordinate = `30040:${'a'.repeat(64)}:chapter-1`; + expect(isNostrIdentifier(coordinate)).toBe(true); + }); + + it('should reject invalid identifiers', () => { + expect(isNostrIdentifier('')).toBe(false); + expect(isNostrIdentifier('abc')).toBe(false); + expect(isNostrIdentifier('30040:abc:chapter-1')).toBe(false); + }); + }); +}); \ No newline at end of file