mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-16 17:48:34 +02:00
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
230 lines
6.6 KiB
TypeScript
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;
|
|
}
|