Files
grimoire/src/lib/nostr-utils.ts
Claude 4dc55295f6 refactor: use applesauce isSafeRelayURL for relay URL validation
Refactor relay URL validation to use applesauce's isSafeRelayURL helper
which provides a fast regex-based check for valid websocket URLs.

Changes:
- Update isValidRelayURL in relay-url.ts to use isSafeRelayURL as fast
  path, with URL constructor fallback for IP addresses
- Re-export isSafeRelayURL from relay-url.ts for convenience
- Update loaders.ts to use isSafeRelayURL directly from applesauce
- Add relay URL validation to:
  - nostr-utils.ts: getEventPointerFromQTag (q-tag relay hints)
  - zapstore-helpers.ts: getAppReferences (a-tag relay hints)
  - nip89-helpers.ts: getHandlerReferences (a-tag relay hints)
  - PublicChatsRenderer.tsx: extractGroups (group relay URLs)

This ensures consistent validation across all relay URL extraction points
using applesauce's battle-tested validation.

https://claude.ai/code/session_01Ca2fKD2r4wHKRD8rcRohj9
2026-01-27 09:46:39 +00:00

230 lines
6.6 KiB
TypeScript

import type { ProfileContent } from "applesauce-core/helpers";
import type { NostrEvent } from "nostr-tools";
import type { NostrFilter } from "@/types/nostr";
import { getNip10References } from "applesauce-common/helpers/threading";
import { getCommentReplyPointer } from "applesauce-common/helpers/comment";
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
import { isSafeRelayURL } from "applesauce-core/helpers/relays";
export function derivePlaceholderName(pubkey: string): string {
return `${pubkey.slice(0, 4)}:${pubkey.slice(-4)}`;
}
/**
* Get a reply pointer for an event, abstracting the differences between NIP-10 and NIP-22 (comments).
*/
export function getEventReply(
event: NostrEvent,
):
| { type: "root"; pointer: EventPointer | AddressPointer }
| { type: "reply"; pointer: EventPointer | AddressPointer }
| { type: "comment"; pointer: any }
| null {
// Handle Kind 1 (Text Note) - NIP-10
if (event.kind === 1) {
const references = getNip10References(event);
if (references.reply) {
const pointer = references.reply.e || references.reply.a;
if (pointer) return { type: "reply", pointer };
}
if (references.root) {
const pointer = references.root.e || references.root.a;
if (pointer) return { type: "root", pointer };
}
}
// Handle Kind 1111 (Comment) - NIP-22
if (event.kind === 1111) {
const pointer = getCommentReplyPointer(event);
if (pointer) {
return { type: "comment", pointer };
}
}
// Fallback for generic replies (using NIP-10 logic for other kinds usually works)
if (event.kind !== 1111) {
const references = getNip10References(event);
if (references.reply) {
const pointer = references.reply.e || references.reply.a;
if (pointer) return { type: "reply", pointer };
}
if (references.root) {
const pointer = references.root.e || references.root.a;
if (pointer) return { type: "root", pointer };
}
}
return null;
}
export function getTagValues(event: NostrEvent, tagName: string): string[] {
return event.tags
.filter((tag) => tag[0] === tagName && tag[1])
.map((tag) => tag[1]);
}
/**
* Parse a q-tag (quote tag) into an EventPointer with relay hints
* Q-tag format per NIP-18: ["q", eventId, relayUrl?, pubkey?]
* - tag[0]: "q"
* - tag[1]: event ID (64-char hex) or address coordinate (kind:pubkey:d-tag)
* - tag[2]: relay URL hint (optional)
* - tag[3]: pubkey of quoted event author (optional)
*/
export function getEventPointerFromQTag(
tag: string[],
): EventPointer | AddressPointer | null {
if (!tag || tag[0] !== "q" || !tag[1]) {
return null;
}
const value = tag[1];
const relayHint = tag[2];
const authorHint = tag[3];
// Validate relay hint is a valid websocket URL before using
const validRelayHint =
relayHint && isSafeRelayURL(relayHint) ? relayHint : undefined;
// Check if it's an address coordinate (contains colons: kind:pubkey:d-tag)
if (value.includes(":")) {
const parts = value.split(":");
if (parts.length >= 2) {
const kind = parseInt(parts[0], 10);
const pubkey = parts[1];
const identifier = parts.slice(2).join(":") || "";
if (!isNaN(kind) && pubkey) {
const pointer: AddressPointer = { kind, pubkey, identifier };
if (validRelayHint) {
pointer.relays = [validRelayHint];
}
return pointer;
}
}
return null;
}
// Validate as 64-char hex event ID
if (!/^[0-9a-f]{64}$/i.test(value)) {
return null;
}
const pointer: EventPointer = { id: value };
if (validRelayHint) {
pointer.relays = [validRelayHint];
}
if (authorHint && /^[0-9a-f]{64}$/i.test(authorHint)) {
pointer.author = authorHint;
}
return pointer;
}
/**
* Find the first q-tag in an event and parse it into an EventPointer
* Returns null if no valid q-tag is found
*/
export function getQuotePointer(
event: NostrEvent,
): EventPointer | AddressPointer | null {
const qTag = event.tags.find((tag) => tag[0] === "q" && tag[1]);
if (!qTag) {
return null;
}
return getEventPointerFromQTag(qTag);
}
export function getDisplayName(
pubkey: string,
metadata?: ProfileContent,
): string {
if (metadata?.display_name) {
return metadata.display_name;
}
if (metadata?.name) {
return metadata.name;
}
return derivePlaceholderName(pubkey);
}
/**
* Resolve $me and $contacts aliases in a Nostr filter (case-insensitive)
* @param filter - Filter that may contain $me or $contacts aliases
* @param accountPubkey - Current user's pubkey (for $me resolution)
* @param contacts - Array of contact pubkeys (for $contacts resolution)
* @returns Resolved filter with aliases replaced by actual pubkeys
*/
export function resolveFilterAliases(
filter: NostrFilter,
accountPubkey: string | undefined,
contacts: string[],
): NostrFilter {
const resolved = { ...filter };
// Resolve aliases in authors array
if (resolved.authors && resolved.authors.length > 0) {
const resolvedAuthors: string[] = [];
for (const author of resolved.authors) {
const normalized = author.toLowerCase();
if (normalized === "$me") {
if (accountPubkey) {
resolvedAuthors.push(accountPubkey);
}
} else if (normalized === "$contacts") {
resolvedAuthors.push(...contacts);
} else {
resolvedAuthors.push(author);
}
}
// Deduplicate
resolved.authors = Array.from(new Set(resolvedAuthors));
}
// Resolve aliases in #p tags array
if (resolved["#p"] && resolved["#p"].length > 0) {
const resolvedPTags: string[] = [];
for (const pTag of resolved["#p"]) {
const normalized = pTag.toLowerCase();
if (normalized === "$me") {
if (accountPubkey) {
resolvedPTags.push(accountPubkey);
}
} else if (normalized === "$contacts") {
resolvedPTags.push(...contacts);
} else {
resolvedPTags.push(pTag);
}
}
// Deduplicate
resolved["#p"] = Array.from(new Set(resolvedPTags));
}
// Resolve aliases in #P tags array (uppercase P, e.g., zap senders)
if (resolved["#P"] && resolved["#P"].length > 0) {
const resolvedPTagsUppercase: string[] = [];
for (const pTag of resolved["#P"]) {
const normalized = pTag.toLowerCase();
if (normalized === "$me") {
if (accountPubkey) {
resolvedPTagsUppercase.push(accountPubkey);
}
} else if (normalized === "$contacts") {
resolvedPTagsUppercase.push(...contacts);
} else {
resolvedPTagsUppercase.push(pTag);
}
}
// Deduplicate
resolved["#P"] = Array.from(new Set(resolvedPTagsUppercase));
}
return resolved;
}