feat: user relay management, blog naddr sync, and OG image update
Add UserRelay API and dashboard relays tab with NIP-65 import, store post naddr for Nostr articles, and ship Prisma migrations for board tables, user relays, and post metadata. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+20
-1
@@ -43,7 +43,15 @@ export const api = {
|
||||
request<{ count: number; reactions: any[] }>(`/posts/${slug}/reactions`),
|
||||
getPostReplies: (slug: string) =>
|
||||
request<{ count: number; replies: any[] }>(`/posts/${slug}/replies`),
|
||||
importPost: (data: { eventId?: string; naddr?: string }) =>
|
||||
importPost: (data: {
|
||||
nostrEventId: string;
|
||||
naddr?: string;
|
||||
title: string;
|
||||
excerpt?: string;
|
||||
authorPubkey: string;
|
||||
publishedAt?: number;
|
||||
tags?: string[];
|
||||
}) =>
|
||||
request<any>("/posts/import", { method: "POST", body: JSON.stringify(data) }),
|
||||
updatePost: (id: string, data: any) =>
|
||||
request<any>(`/posts/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
@@ -116,6 +124,7 @@ export const api = {
|
||||
request<void>(`/organizers/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Relays
|
||||
getPublicRelays: () => request<{ relays: string[] }>("/relays/public"),
|
||||
getRelays: () => request<any[]>("/relays"),
|
||||
addRelay: (data: { url: string; priority?: number }) =>
|
||||
request<any>("/relays", { method: "POST", body: JSON.stringify(data) }),
|
||||
@@ -126,6 +135,16 @@ export const api = {
|
||||
testRelay: (id: string) =>
|
||||
request<{ success: boolean }>(`/relays/${id}/test`, { method: "POST" }),
|
||||
|
||||
// User Relays
|
||||
getUserRelays: () =>
|
||||
request<any[]>("/user-relays"),
|
||||
addUserRelay: (data: { url: string; read?: boolean; write?: boolean }) =>
|
||||
request<any>("/user-relays", { method: "POST", body: JSON.stringify(data) }),
|
||||
removeUserRelay: (id: string) =>
|
||||
request<void>(`/user-relays/${id}`, { method: "DELETE" }),
|
||||
importNip65Relays: () =>
|
||||
request<{ imported: number; total?: number; message?: string }>("/user-relays/import-nip65", { method: "POST" }),
|
||||
|
||||
// Settings
|
||||
getSettings: () => request<Record<string, string>>("/settings"),
|
||||
getPublicSettings: () => request<Record<string, string>>("/settings/public"),
|
||||
|
||||
+191
-8
@@ -56,24 +56,143 @@ export interface NostrProfile {
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_RELAYS = [
|
||||
const FALLBACK_RELAYS = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.nostr.band",
|
||||
];
|
||||
|
||||
let _siteRelaysCache: { relays: string[]; fetchedAt: number } | null = null;
|
||||
const SITE_RELAY_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export async function getSiteRelays(): Promise<string[]> {
|
||||
if (_siteRelaysCache && Date.now() - _siteRelaysCache.fetchedAt < SITE_RELAY_TTL) {
|
||||
return _siteRelaysCache.relays;
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/api/relays/public");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data.relays) && data.relays.length > 0) {
|
||||
_siteRelaysCache = { relays: data.relays, fetchedAt: Date.now() };
|
||||
return data.relays;
|
||||
}
|
||||
} catch {}
|
||||
return FALLBACK_RELAYS;
|
||||
}
|
||||
|
||||
export interface Nip65RelayList {
|
||||
write: string[];
|
||||
read: string[];
|
||||
all: string[];
|
||||
}
|
||||
|
||||
const _nip65Cache = new Map<string, { data: Nip65RelayList; fetchedAt: number }>();
|
||||
const NIP65_TTL = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
export async function fetchNip65RelayList(pubkey: string): Promise<Nip65RelayList> {
|
||||
const cached = _nip65Cache.get(pubkey);
|
||||
if (cached && Date.now() - cached.fetchedAt < NIP65_TTL) return cached.data;
|
||||
|
||||
const { SimplePool } = await import("nostr-tools/pool");
|
||||
const siteRelays = await getSiteRelays();
|
||||
const pool = new SimplePool();
|
||||
|
||||
try {
|
||||
const event = await pool.get(siteRelays, {
|
||||
kinds: [10002],
|
||||
authors: [pubkey],
|
||||
});
|
||||
|
||||
const result: Nip65RelayList = { write: [], read: [], all: [] };
|
||||
if (!event) {
|
||||
_nip65Cache.set(pubkey, { data: result, fetchedAt: Date.now() });
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] !== "r" || !tag[1]) continue;
|
||||
const url = tag[1];
|
||||
const marker = tag[2];
|
||||
result.all.push(url);
|
||||
if (marker === "write") {
|
||||
result.write.push(url);
|
||||
} else if (marker === "read") {
|
||||
result.read.push(url);
|
||||
} else {
|
||||
result.write.push(url);
|
||||
result.read.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
_nip65Cache.set(pubkey, { data: result, fetchedAt: Date.now() });
|
||||
return result;
|
||||
} catch {
|
||||
return { write: [], read: [], all: [] };
|
||||
} finally {
|
||||
pool.close(siteRelays);
|
||||
}
|
||||
}
|
||||
|
||||
let _userRelaysCache: { relays: { url: string; read: boolean; write: boolean }[]; fetchedAt: number } | null = null;
|
||||
const USER_RELAY_TTL = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
export async function getUserStoredRelays(): Promise<{ url: string; read: boolean; write: boolean }[]> {
|
||||
if (_userRelaysCache && Date.now() - _userRelaysCache.fetchedAt < USER_RELAY_TTL) {
|
||||
return _userRelaysCache.relays;
|
||||
}
|
||||
try {
|
||||
const token = typeof window !== "undefined" ? localStorage.getItem("bbe_token") : null;
|
||||
if (!token) return [];
|
||||
const res = await fetch("/api/user-relays", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
_userRelaysCache = { relays: data, fetchedAt: Date.now() };
|
||||
return data;
|
||||
}
|
||||
} catch {}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function clearUserRelaysCache(): void {
|
||||
_userRelaysCache = null;
|
||||
}
|
||||
|
||||
export async function publishEvent(signedEvent: any): Promise<void> {
|
||||
const { SimplePool } = await import("nostr-tools/pool");
|
||||
let relayUrls: string[] = DEFAULT_RELAYS;
|
||||
const siteRelays = await getSiteRelays();
|
||||
const allRelays = new Set<string>(siteRelays);
|
||||
|
||||
// Merge user's stored write relays
|
||||
try {
|
||||
const stored = await getUserStoredRelays();
|
||||
stored
|
||||
.filter((r) => r.write)
|
||||
.forEach((r) => allRelays.add(r.url));
|
||||
} catch {}
|
||||
|
||||
// Merge NIP-65 write relays for the event author
|
||||
if (signedEvent.pubkey) {
|
||||
try {
|
||||
const nip65 = await fetchNip65RelayList(signedEvent.pubkey);
|
||||
nip65.write.forEach((url) => allRelays.add(url));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Merge extension relays
|
||||
try {
|
||||
if (window.nostr?.getRelays) {
|
||||
const ext = await window.nostr.getRelays();
|
||||
const write = Object.entries(ext)
|
||||
Object.entries(ext)
|
||||
.filter(([, p]) => (p as any).write)
|
||||
.map(([url]) => url);
|
||||
if (write.length > 0) relayUrls = write;
|
||||
.forEach(([url]) => allRelays.add(url));
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const relayUrls = [...allRelays];
|
||||
const pool = new SimplePool();
|
||||
try {
|
||||
await Promise.allSettled(pool.publish(relayUrls, signedEvent));
|
||||
@@ -84,13 +203,21 @@ export async function publishEvent(signedEvent: any): Promise<void> {
|
||||
|
||||
export async function fetchNostrProfile(
|
||||
pubkey: string,
|
||||
relayUrls: string[] = DEFAULT_RELAYS
|
||||
relayUrls?: string[]
|
||||
): Promise<NostrProfile> {
|
||||
const { SimplePool } = await import("nostr-tools/pool");
|
||||
const allRelays = new Set<string>(relayUrls || await getSiteRelays());
|
||||
|
||||
try {
|
||||
const nip65 = await fetchNip65RelayList(pubkey);
|
||||
nip65.write.forEach((url) => allRelays.add(url));
|
||||
} catch {}
|
||||
|
||||
const urls = [...allRelays];
|
||||
const pool = new SimplePool();
|
||||
|
||||
try {
|
||||
const event = await pool.get(relayUrls, {
|
||||
const event = await pool.get(urls, {
|
||||
kinds: [0],
|
||||
authors: [pubkey],
|
||||
});
|
||||
@@ -107,6 +234,61 @@ export async function fetchNostrProfile(
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
} finally {
|
||||
pool.close(urls);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchEventFromRelays(eventId: string): Promise<any | null> {
|
||||
const { SimplePool } = await import("nostr-tools/pool");
|
||||
const siteRelays = await getSiteRelays();
|
||||
const pool = new SimplePool();
|
||||
|
||||
try {
|
||||
const event = await pool.get(siteRelays, { ids: [eventId] });
|
||||
return event || null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
pool.close(siteRelays);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchLongformFromRelays(naddrStr: string): Promise<any | null> {
|
||||
const { SimplePool } = await import("nostr-tools/pool");
|
||||
const { nip19 } = await import("nostr-tools");
|
||||
|
||||
let decoded: { kind: number; pubkey: string; identifier: string; relays?: string[] };
|
||||
try {
|
||||
const result = nip19.decode(naddrStr);
|
||||
if (result.type !== "naddr") return null;
|
||||
decoded = result.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const siteRelays = await getSiteRelays();
|
||||
const naddrRelays = decoded.relays || [];
|
||||
const allRelays = new Set<string>([...naddrRelays, ...siteRelays]);
|
||||
|
||||
try {
|
||||
const nip65 = await fetchNip65RelayList(decoded.pubkey);
|
||||
nip65.write.forEach((url) => allRelays.add(url));
|
||||
} catch {}
|
||||
|
||||
const relayUrls = [...allRelays];
|
||||
const filter = {
|
||||
kinds: [decoded.kind],
|
||||
authors: [decoded.pubkey],
|
||||
"#d": [decoded.identifier],
|
||||
};
|
||||
|
||||
const pool = new SimplePool();
|
||||
try {
|
||||
const event = await pool.get(relayUrls, filter);
|
||||
return event || null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
pool.close(relayUrls);
|
||||
}
|
||||
@@ -128,8 +310,9 @@ export async function createBunkerSigner(
|
||||
|
||||
// NIP-46: Generate a nostrconnect:// URI for QR display
|
||||
export async function generateNostrConnectSetup(
|
||||
relayUrls: string[] = DEFAULT_RELAYS.slice(0, 2)
|
||||
relayUrls?: string[]
|
||||
): Promise<{ uri: string; clientSecretKey: Uint8Array }> {
|
||||
if (!relayUrls) relayUrls = (await getSiteRelays()).slice(0, 2);
|
||||
const { createNostrConnectURI } = await import("nostr-tools/nip46");
|
||||
const clientSecretKey = generateSecretKey();
|
||||
const clientPubkey = getPubKeyFromSecret(clientSecretKey);
|
||||
|
||||
Reference in New Issue
Block a user