260 lines
6.8 KiB
TypeScript
260 lines
6.8 KiB
TypeScript
/**
|
|
* Nostr Profile Import
|
|
*
|
|
* Fetches and verifies kind:0 profile events from relays.
|
|
* Used to import existing profiles before editing.
|
|
*/
|
|
|
|
import { SimplePool, verifyEvent, type Event } from "nostr-tools";
|
|
|
|
import { contentToProfile, type ProfileContent } from "./nostr-profile.js";
|
|
import type { NostrProfile } from "./config-schema.js";
|
|
import { validateUrlSafety } from "./nostr-profile-http.js";
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
|
|
export interface ProfileImportResult {
|
|
/** Whether the import was successful */
|
|
ok: boolean;
|
|
/** The imported profile (if found and valid) */
|
|
profile?: NostrProfile;
|
|
/** The raw event (for advanced users) */
|
|
event?: {
|
|
id: string;
|
|
pubkey: string;
|
|
created_at: number;
|
|
};
|
|
/** Error message if import failed */
|
|
error?: string;
|
|
/** Which relays responded */
|
|
relaysQueried: string[];
|
|
/** Which relay provided the winning event */
|
|
sourceRelay?: string;
|
|
}
|
|
|
|
export interface ProfileImportOptions {
|
|
/** The public key to fetch profile for */
|
|
pubkey: string;
|
|
/** Relay URLs to query */
|
|
relays: string[];
|
|
/** Timeout per relay in milliseconds (default: 5000) */
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Constants
|
|
// ============================================================================
|
|
|
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
|
|
// ============================================================================
|
|
// Profile Import
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Sanitize URLs in an imported profile to prevent SSRF attacks.
|
|
* Removes any URLs that don't pass SSRF validation.
|
|
*/
|
|
function sanitizeProfileUrls(profile: NostrProfile): NostrProfile {
|
|
const result = { ...profile };
|
|
const urlFields = ["picture", "banner", "website"] as const;
|
|
|
|
for (const field of urlFields) {
|
|
const value = result[field];
|
|
if (value && typeof value === "string") {
|
|
const validation = validateUrlSafety(value);
|
|
if (!validation.ok) {
|
|
// Remove unsafe URL
|
|
delete result[field];
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Fetch the latest kind:0 profile event for a pubkey from relays.
|
|
*
|
|
* - Queries all relays in parallel
|
|
* - Takes the event with the highest created_at
|
|
* - Verifies the event signature
|
|
* - Parses and returns the profile
|
|
*/
|
|
export async function importProfileFromRelays(
|
|
opts: ProfileImportOptions
|
|
): Promise<ProfileImportResult> {
|
|
const { pubkey, relays, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
|
|
|
|
if (!pubkey || !/^[0-9a-fA-F]{64}$/.test(pubkey)) {
|
|
return {
|
|
ok: false,
|
|
error: "Invalid pubkey format (must be 64 hex characters)",
|
|
relaysQueried: [],
|
|
};
|
|
}
|
|
|
|
if (relays.length === 0) {
|
|
return {
|
|
ok: false,
|
|
error: "No relays configured",
|
|
relaysQueried: [],
|
|
};
|
|
}
|
|
|
|
const pool = new SimplePool();
|
|
const relaysQueried: string[] = [];
|
|
|
|
try {
|
|
// Query all relays for kind:0 events from this pubkey
|
|
const events: Array<{ event: Event; relay: string }> = [];
|
|
|
|
// Create timeout promise
|
|
const timeoutPromise = new Promise<void>((resolve) => {
|
|
setTimeout(resolve, timeoutMs);
|
|
});
|
|
|
|
// Create subscription promise
|
|
const subscriptionPromise = new Promise<void>((resolve) => {
|
|
let completed = 0;
|
|
|
|
for (const relay of relays) {
|
|
relaysQueried.push(relay);
|
|
|
|
const sub = pool.subscribeMany(
|
|
[relay],
|
|
[
|
|
{
|
|
kinds: [0],
|
|
authors: [pubkey],
|
|
limit: 1,
|
|
},
|
|
],
|
|
{
|
|
onevent(event) {
|
|
events.push({ event, relay });
|
|
},
|
|
oneose() {
|
|
completed++;
|
|
if (completed >= relays.length) {
|
|
resolve();
|
|
}
|
|
},
|
|
onclose() {
|
|
completed++;
|
|
if (completed >= relays.length) {
|
|
resolve();
|
|
}
|
|
},
|
|
}
|
|
);
|
|
|
|
// Clean up subscription after timeout
|
|
setTimeout(() => {
|
|
sub.close();
|
|
}, timeoutMs);
|
|
}
|
|
});
|
|
|
|
// Wait for either all relays to respond or timeout
|
|
await Promise.race([subscriptionPromise, timeoutPromise]);
|
|
|
|
// No events found
|
|
if (events.length === 0) {
|
|
return {
|
|
ok: false,
|
|
error: "No profile found on any relay",
|
|
relaysQueried,
|
|
};
|
|
}
|
|
|
|
// Find the event with the highest created_at (newest wins for replaceable events)
|
|
let bestEvent: { event: Event; relay: string } | null = null;
|
|
for (const item of events) {
|
|
if (!bestEvent || item.event.created_at > bestEvent.event.created_at) {
|
|
bestEvent = item;
|
|
}
|
|
}
|
|
|
|
if (!bestEvent) {
|
|
return {
|
|
ok: false,
|
|
error: "No valid profile event found",
|
|
relaysQueried,
|
|
};
|
|
}
|
|
|
|
// Verify the event signature
|
|
const isValid = verifyEvent(bestEvent.event);
|
|
if (!isValid) {
|
|
return {
|
|
ok: false,
|
|
error: "Profile event has invalid signature",
|
|
relaysQueried,
|
|
sourceRelay: bestEvent.relay,
|
|
};
|
|
}
|
|
|
|
// Parse the profile content
|
|
let content: ProfileContent;
|
|
try {
|
|
content = JSON.parse(bestEvent.event.content) as ProfileContent;
|
|
} catch {
|
|
return {
|
|
ok: false,
|
|
error: "Profile event has invalid JSON content",
|
|
relaysQueried,
|
|
sourceRelay: bestEvent.relay,
|
|
};
|
|
}
|
|
|
|
// Convert to our profile format
|
|
const profile = contentToProfile(content);
|
|
|
|
// Sanitize URLs from imported profile to prevent SSRF when auto-merging
|
|
const sanitizedProfile = sanitizeProfileUrls(profile);
|
|
|
|
return {
|
|
ok: true,
|
|
profile: sanitizedProfile,
|
|
event: {
|
|
id: bestEvent.event.id,
|
|
pubkey: bestEvent.event.pubkey,
|
|
created_at: bestEvent.event.created_at,
|
|
},
|
|
relaysQueried,
|
|
sourceRelay: bestEvent.relay,
|
|
};
|
|
} finally {
|
|
pool.close(relays);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Merge imported profile with local profile.
|
|
*
|
|
* Strategy:
|
|
* - For each field, prefer local if set, otherwise use imported
|
|
* - This preserves user customizations while filling in missing data
|
|
*/
|
|
export function mergeProfiles(
|
|
local: NostrProfile | undefined,
|
|
imported: NostrProfile | undefined
|
|
): NostrProfile {
|
|
if (!imported) return local ?? {};
|
|
if (!local) return imported;
|
|
|
|
return {
|
|
name: local.name ?? imported.name,
|
|
displayName: local.displayName ?? imported.displayName,
|
|
about: local.about ?? imported.about,
|
|
picture: local.picture ?? imported.picture,
|
|
banner: local.banner ?? imported.banner,
|
|
website: local.website ?? imported.website,
|
|
nip05: local.nip05 ?? imported.nip05,
|
|
lud16: local.lud16 ?? imported.lud16,
|
|
};
|
|
}
|