Browse Source

validate nostr identifiers for id and coord

master
limina1 8 months ago
parent
commit
acfd7d2369
  1. 1
      src/lib/components/cards/BlogHeader.svelte
  2. 92
      src/lib/utils/displayLimits.ts
  3. 88
      src/lib/utils/nostr_identifiers.ts
  4. 12
      src/routes/visualize/+page.svelte
  5. 106
      tests/unit/nostr_identifiers.test.ts

1
src/lib/components/cards/BlogHeader.svelte

@ -6,7 +6,6 @@
import Interactions from "$components/util/Interactions.svelte"; import Interactions from "$components/util/Interactions.svelte";
import { quintOut } from "svelte/easing"; import { quintOut } from "svelte/easing";
import CardActions from "$components/util/CardActions.svelte"; import CardActions from "$components/util/CardActions.svelte";
<<<<<<< HEAD
import { getMatchingTags } from "$lib/utils/nostrUtils"; import { getMatchingTags } from "$lib/utils/nostrUtils";
import LazyImage from "$components/util/LazyImage.svelte"; import LazyImage from "$components/util/LazyImage.svelte";
import { generateDarkPastelColor } from "$lib/utils/image_utils"; import { generateDarkPastelColor } from "$lib/utils/image_utils";

92
src/lib/utils/displayLimits.ts

@ -1,5 +1,6 @@
import type { NDKEvent } from '@nostr-dev-kit/ndk'; import type { NDKEvent } from '@nostr-dev-kit/ndk';
import type { VisualizationConfig } from '$lib/stores/visualizationConfig'; import type { VisualizationConfig } from '$lib/stores/visualizationConfig';
import { isEventId, isCoordinate, parseCoordinate } from './nostr_identifiers';
/** /**
* Filters events based on visualization configuration * 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 * Detects events that are referenced but not present in the current set
* @param events - Current events * @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 * @returns Set of missing event identifiers
*/ */
export function detectMissingEvents(events: NDKEvent[], existingIds: Set<string>): Set<string> { export function detectMissingEvents(
events: NDKEvent[],
existingIds: Set<string>,
existingCoordinates?: Map<string, NDKEvent>
): Set<string> {
const missing = new Set<string>(); const missing = new Set<string>();
for (const event of events) { 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) // Check 'a' tags for NIP-33 references (kind:pubkey:d-tag)
const aTags = event.getMatchingTags('a'); const aTags = event.getMatchingTags('a');
for (const aTag of aTags) { for (const aTag of aTags) {
if (aTag.length < 2) continue; if (aTag.length < 2) continue;
const identifier = aTag[1]; const identifier = aTag[1];
const parts = identifier.split(':');
if (parts.length >= 3) { // Type check: ensure it's a valid coordinate
const [kind, pubkey, dTag] = parts; if (!isCoordinate(identifier)) {
// Create a synthetic ID for checking console.warn('Invalid coordinate in a tag:', identifier);
const syntheticId = `${kind}:${pubkey}:${dTag}`; continue;
}
// Check if we have an event matching this reference
const hasEvent = Array.from(existingIds).some(id => { // Parse the coordinate
// This is a simplified check - in practice, you'd need to const parsed = parseCoordinate(identifier);
// check the actual event's d-tag value if (!parsed) continue;
return id === dTag || id === syntheticId;
}); // If we have existing coordinates, check if this one exists
if (existingCoordinates) {
if (!hasEvent) { if (!existingCoordinates.has(identifier)) {
missing.add(dTag); 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 return missing;
const eTags = event.getMatchingTags('e'); }
for (const eTag of eTags) {
if (eTag.length < 2) continue; /**
* 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<string, NDKEvent> {
const coordinateMap = new Map<string, NDKEvent>();
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 (dTag && author) {
if (!existingIds.has(eventId)) { const coordinate = `${event.kind}:${author}:${dTag}`;
missing.add(eventId); coordinateMap.set(coordinate, event);
} }
} }
} }
return missing; return coordinateMap;
} }

88
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);
}

12
src/routes/visualize/+page.svelte

@ -13,7 +13,7 @@
import { filterValidIndexEvents } from "$lib/utils"; import { filterValidIndexEvents } from "$lib/utils";
import { networkFetchLimit } from "$lib/state"; import { networkFetchLimit } from "$lib/state";
import { visualizationConfig, type EventKindConfig } from "$lib/stores/visualizationConfig"; 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 type { PageData } from './$types';
import { getEventKindColor, getEventKindName } from "$lib/utils/eventColors"; import { getEventKindColor, getEventKindName } from "$lib/utils/eventColors";
import { extractPubkeysFromEvents, batchFetchProfiles } from "$lib/utils/profileCache"; import { extractPubkeysFromEvents, batchFetchProfiles } from "$lib/utils/profileCache";
@ -62,8 +62,14 @@
let missingEventIds = $derived.by(() => { let missingEventIds = $derived.by(() => {
if (allEvents.length > 0) { if (allEvents.length > 0) {
const eventIds = new Set(allEvents.map(e => e.id)); const eventIds = new Set(allEvents.map(e => e.id));
const missing = detectMissingEvents(events, eventIds); const coordinateMap = buildCoordinateMap(allEvents);
debug("Derived missingEventIds update:", { allEvents: allEvents.length, events: events.length, missing: missing.size }); 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 missing;
} }
return new Set<string>(); return new Set<string>();

106
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);
});
});
});
Loading…
Cancel
Save