diff --git a/backend/prisma/migrations/20260405073517_add_board_tables/migration.sql b/backend/prisma/migrations/20260405073517_add_board_tables/migration.sql new file mode 100644 index 0000000..f19d688 --- /dev/null +++ b/backend/prisma/migrations/20260405073517_add_board_tables/migration.sql @@ -0,0 +1,29 @@ +-- CreateTable +CREATE TABLE IF NOT EXISTS "BoardInvoicePending" ( + "paymentHash" TEXT NOT NULL PRIMARY KEY, + "content" TEXT NOT NULL, + "guestName" TEXT, + "pubkey" TEXT, + "profilePic" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE IF NOT EXISTS "BoardMessage" ( + "id" TEXT NOT NULL PRIMARY KEY, + "paymentHash" TEXT NOT NULL, + "content" TEXT NOT NULL, + "authorName" TEXT NOT NULL, + "pubkey" TEXT, + "profilePic" TEXT, + "satsPaid" INTEGER NOT NULL, + "status" TEXT NOT NULL DEFAULT 'active', + "likeCount" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "BoardMessage_paymentHash_key" ON "BoardMessage"("paymentHash"); + +-- DropIndex (cleanup from organizer migration) +DROP INDEX IF EXISTS "Meetup_organizerId_idx"; diff --git a/backend/prisma/migrations/20260405073752_add_user_relay/migration.sql b/backend/prisma/migrations/20260405073752_add_user_relay/migration.sql new file mode 100644 index 0000000..ef04a1f --- /dev/null +++ b/backend/prisma/migrations/20260405073752_add_user_relay/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "UserRelay" ( + "id" TEXT NOT NULL PRIMARY KEY, + "pubkey" TEXT NOT NULL, + "url" TEXT NOT NULL, + "read" BOOLEAN NOT NULL DEFAULT true, + "write" BOOLEAN NOT NULL DEFAULT true, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserRelay_pubkey_url_key" ON "UserRelay"("pubkey", "url"); diff --git a/backend/prisma/migrations/20260405081139_add_post_naddr/migration.sql b/backend/prisma/migrations/20260405081139_add_post_naddr/migration.sql new file mode 100644 index 0000000..48b9f5c --- /dev/null +++ b/backend/prisma/migrations/20260405081139_add_post_naddr/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable: add naddr column to Post +ALTER TABLE "Post" ADD COLUMN "naddr" TEXT; + +-- AlterTable: set default for content on Post (new rows only; existing rows keep their content) +-- SQLite doesn't support ALTER COLUMN DEFAULT, but Prisma handles this at the client level. diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d18b631..59e653a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -61,9 +61,10 @@ model Media { model Post { id String @id @default(uuid()) nostrEventId String @unique + naddr String? title String slug String @unique - content String + content String @default("") excerpt String? authorPubkey String authorName String? @@ -117,6 +118,17 @@ model Relay { createdAt DateTime @default(now()) } +model UserRelay { + id String @id @default(uuid()) + pubkey String + url String + read Boolean @default(true) + write Boolean @default(true) + createdAt DateTime @default(now()) + + @@unique([pubkey, url]) +} + model Setting { id String @id @default(uuid()) key String @unique diff --git a/backend/src/api/posts.ts b/backend/src/api/posts.ts index 8e738d8..b25d725 100644 --- a/backend/src/api/posts.ts +++ b/backend/src/api/posts.ts @@ -69,54 +69,72 @@ router.post( requireRole(['ADMIN', 'MODERATOR']), async (req: Request, res: Response) => { try { - const { eventId, naddr } = req.body; - if (!eventId && !naddr) { - res.status(400).json({ error: 'eventId or naddr is required' }); + const { nostrEventId, naddr, title, excerpt, authorPubkey, publishedAt, tags } = req.body; + + if (!nostrEventId || !title || !authorPubkey) { + res.status(400).json({ error: 'nostrEventId, title, and authorPubkey are required' }); return; } - let event: any = null; - if (naddr) { - event = await nostrService.fetchLongformEvent(naddr); - } else if (eventId) { - event = await nostrService.fetchEvent(eventId); - } - - if (!event) { - res.status(404).json({ error: 'Event not found on relays' }); - return; - } - - const titleTag = event.tags?.find((t: string[]) => t[0] === 'title'); - const title = titleTag?.[1] || 'Untitled'; - const slugBase = title .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, ''); - const slug = `${slugBase}-${event.id.slice(0, 8)}`; - - const excerpt = event.content.slice(0, 200).replace(/[#*_\n]/g, '').trim(); + const slug = `${slugBase}-${nostrEventId.slice(0, 8)}`; const post = await prisma.post.upsert({ - where: { nostrEventId: event.id }, + where: { nostrEventId }, update: { title, - content: event.content, - excerpt, + excerpt: excerpt || undefined, + naddr: naddr || undefined, }, create: { - nostrEventId: event.id, + nostrEventId, + naddr: naddr || null, title, slug, - content: event.content, - excerpt, - authorPubkey: event.pubkey, - publishedAt: new Date(event.created_at * 1000), + excerpt: excerpt || null, + authorPubkey, + publishedAt: publishedAt + ? new Date(typeof publishedAt === 'number' ? publishedAt * 1000 : publishedAt) + : new Date(), }, }); - res.json(post); + if (Array.isArray(tags) && tags.length > 0) { + const categoryIds: string[] = []; + for (const tag of tags as string[]) { + const catSlug = tag.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + if (!catSlug) continue; + const category = await prisma.category.upsert({ + where: { slug: catSlug }, + update: {}, + create: { + name: tag.charAt(0).toUpperCase() + tag.slice(1), + slug: catSlug, + }, + }); + categoryIds.push(category.id); + } + + await prisma.postCategory.deleteMany({ where: { postId: post.id } }); + if (categoryIds.length > 0) { + await prisma.postCategory.createMany({ + data: categoryIds.map((categoryId) => ({ + postId: post.id, + categoryId, + })), + }); + } + } + + const result = await prisma.post.findUnique({ + where: { id: post.id }, + include: { categories: { include: { category: true } } }, + }); + + res.json(result); } catch (err) { console.error('Import post error:', err); res.status(500).json({ error: 'Internal server error' }); diff --git a/backend/src/api/relays.ts b/backend/src/api/relays.ts index ad89488..cb2586d 100644 --- a/backend/src/api/relays.ts +++ b/backend/src/api/relays.ts @@ -5,6 +5,22 @@ import { requireAuth, requireRole } from '../middleware/auth'; const router = Router(); +router.get( + '/public', + async (_req: Request, res: Response) => { + try { + const relays = await prisma.relay.findMany({ + where: { active: true }, + orderBy: { priority: 'asc' }, + }); + res.json({ relays: relays.map((r) => r.url) }); + } catch (err) { + console.error('Public relays error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + router.get( '/', requireAuth, diff --git a/backend/src/api/userRelays.ts b/backend/src/api/userRelays.ts new file mode 100644 index 0000000..7d47673 --- /dev/null +++ b/backend/src/api/userRelays.ts @@ -0,0 +1,137 @@ +import { Router, Request, Response } from 'express'; +import { prisma } from '../db/prisma'; +import { requireAuth } from '../middleware/auth'; +import { nostrService } from '../services/nostr'; + +const router = Router(); + +router.get( + '/', + requireAuth, + async (req: Request, res: Response) => { + try { + const relays = await prisma.userRelay.findMany({ + where: { pubkey: req.user!.pubkey }, + orderBy: { createdAt: 'asc' }, + }); + res.json(relays); + } catch (err) { + console.error('List user relays error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.post( + '/', + requireAuth, + async (req: Request, res: Response) => { + try { + const { url, read, write } = req.body; + if (!url || typeof url !== 'string') { + res.status(400).json({ error: 'url is required' }); + return; + } + + const normalized = url.trim().replace(/\/+$/, ''); + if (!normalized.startsWith('wss://') && !normalized.startsWith('ws://')) { + res.status(400).json({ error: 'Relay URL must start with wss:// or ws://' }); + return; + } + + const relay = await prisma.userRelay.upsert({ + where: { + pubkey_url: { pubkey: req.user!.pubkey, url: normalized }, + }, + update: { + read: read !== undefined ? read : true, + write: write !== undefined ? write : true, + }, + create: { + pubkey: req.user!.pubkey, + url: normalized, + read: read !== undefined ? read : true, + write: write !== undefined ? write : true, + }, + }); + + res.status(201).json(relay); + } catch (err) { + console.error('Add user relay error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.delete( + '/:id', + requireAuth, + async (req: Request, res: Response) => { + try { + const relay = await prisma.userRelay.findUnique({ + where: { id: req.params.id as string }, + }); + if (!relay || relay.pubkey !== req.user!.pubkey) { + res.status(404).json({ error: 'Relay not found' }); + return; + } + + await prisma.userRelay.delete({ where: { id: req.params.id as string } }); + res.json({ success: true }); + } catch (err) { + console.error('Delete user relay error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.post( + '/import-nip65', + requireAuth, + async (req: Request, res: Response) => { + try { + const pubkey = req.user!.pubkey; + const nip65 = await nostrService.fetchNip65Relays(pubkey); + + const allUrls = new Set(); + const relayEntries: { url: string; read: boolean; write: boolean }[] = []; + + for (const url of nip65.write) { + if (allUrls.has(url)) continue; + allUrls.add(url); + relayEntries.push({ + url, + read: nip65.read.includes(url), + write: true, + }); + } + for (const url of nip65.read) { + if (allUrls.has(url)) continue; + allUrls.add(url); + relayEntries.push({ url, read: true, write: false }); + } + + if (relayEntries.length === 0) { + res.json({ imported: 0, message: 'No NIP-65 relay list found for your pubkey' }); + return; + } + + let imported = 0; + for (const entry of relayEntries) { + await prisma.userRelay.upsert({ + where: { pubkey_url: { pubkey, url: entry.url } }, + update: { read: entry.read, write: entry.write }, + create: { pubkey, url: entry.url, read: entry.read, write: entry.write }, + }); + imported++; + } + + res.json({ imported, total: relayEntries.length }); + } catch (err) { + console.error('Import NIP-65 error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +export default router; diff --git a/backend/src/index.ts b/backend/src/index.ts index 8f9a921..aaa9890 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -24,6 +24,7 @@ import calendarRouter from './api/calendar'; import nip05Router from './api/nip05'; import messagesRouter from './api/messages'; import adminMessagesRouter from './api/adminMessages'; +import userRelaysRouter from './api/userRelays'; const app = express(); const PORT = parseInt(process.env.BACKEND_PORT || '4000', 10); @@ -54,6 +55,7 @@ app.use('/api/calendar', calendarRouter); app.use('/api/nip05', nip05Router); app.use('/api/messages', messagesRouter); app.use('/api/admin/messages', adminMessagesRouter); +app.use('/api/user-relays', userRelaysRouter); app.get('/api/health', (_req, res) => { res.json({ status: 'ok' }); diff --git a/backend/src/services/nostr.ts b/backend/src/services/nostr.ts index 3907cef..7aede6e 100644 --- a/backend/src/services/nostr.ts +++ b/backend/src/services/nostr.ts @@ -11,7 +11,62 @@ async function getRelayUrls(): Promise { return relays.map((r) => r.url); } +function dedupeRelays(...lists: string[][]): string[] { + return [...new Set(lists.flat().filter(Boolean))]; +} + +async function cacheEvent(event: any) { + await prisma.nostrEventCache.upsert({ + where: { eventId: event.id }, + update: { + content: event.content, + tags: JSON.stringify(event.tags), + }, + create: { + eventId: event.id, + kind: event.kind, + pubkey: event.pubkey, + content: event.content, + tags: JSON.stringify(event.tags), + createdAt: event.created_at, + }, + }); +} + export const nostrService = { + async fetchNip65Relays(pubkey: string): Promise<{ write: string[]; read: string[] }> { + const siteRelays = await getRelayUrls(); + if (siteRelays.length === 0) return { write: [], read: [] }; + + try { + const event = await pool.get(siteRelays, { + kinds: [10002], + authors: [pubkey], + }); + if (!event) return { write: [], read: [] }; + + const write: string[] = []; + const read: string[] = []; + for (const tag of event.tags) { + if (tag[0] !== 'r' || !tag[1]) continue; + const url = tag[1]; + const marker = tag[2]; + if (marker === 'write') { + write.push(url); + } else if (marker === 'read') { + read.push(url); + } else { + write.push(url); + read.push(url); + } + } + return { write, read }; + } catch (err) { + console.error('Failed to fetch NIP-65 relays for', pubkey, err); + return { write: [], read: [] }; + } + }, + async fetchEvent(eventId: string, skipCache = false) { if (!skipCache) { const cached = await prisma.nostrEventCache.findUnique({ @@ -29,34 +84,20 @@ export const nostrService = { } } - const relays = await getRelayUrls(); - if (relays.length === 0) return null; + const siteRelays = await getRelayUrls(); + if (siteRelays.length === 0) return null; try { - const event = await pool.get(relays, { ids: [eventId] }); - if (!event) return null; - - await prisma.nostrEventCache.upsert({ - where: { eventId: event.id }, - update: { - content: event.content, - tags: JSON.stringify(event.tags), - }, - create: { - eventId: event.id, - kind: event.kind, - pubkey: event.pubkey, - content: event.content, - tags: JSON.stringify(event.tags), - createdAt: event.created_at, - }, - }); - - return event; + const event = await pool.get(siteRelays, { ids: [eventId] }); + if (event) { + await cacheEvent(event); + return event; + } } catch (err) { - console.error('Failed to fetch event:', err); - return null; + console.error('Failed to fetch event from site relays:', err); } + + return null; }, async fetchLongformEvent(naddrStr: string) { @@ -69,40 +110,44 @@ export const nostrService = { return null; } - const relays = decoded.relays?.length - ? decoded.relays - : await getRelayUrls(); - if (relays.length === 0) return null; + const siteRelays = await getRelayUrls(); + const naddrRelays = decoded.relays || []; + const filter = { + kinds: [decoded.kind], + authors: [decoded.pubkey], + '#d': [decoded.identifier], + }; - try { - const event = await pool.get(relays, { - kinds: [decoded.kind], - authors: [decoded.pubkey], - '#d': [decoded.identifier], - }); - if (!event) return null; - - await prisma.nostrEventCache.upsert({ - where: { eventId: event.id }, - update: { - content: event.content, - tags: JSON.stringify(event.tags), - }, - create: { - eventId: event.id, - kind: event.kind, - pubkey: event.pubkey, - content: event.content, - tags: JSON.stringify(event.tags), - createdAt: event.created_at, - }, - }); - - return event; - } catch (err) { - console.error('Failed to fetch longform event:', err); - return null; + // Try naddr-embedded relays first, then site relays + const firstTry = dedupeRelays(naddrRelays, siteRelays); + if (firstTry.length > 0) { + try { + const event = await pool.get(firstTry, filter); + if (event) { + await cacheEvent(event); + return event; + } + } catch (err) { + console.error('Failed to fetch longform event (first pass):', err); + } } + + // Fallback: try author's NIP-65 write relays + const nip65 = await nostrService.fetchNip65Relays(decoded.pubkey); + if (nip65.write.length > 0) { + const nip65Relays = dedupeRelays(nip65.write); + try { + const event = await pool.get(nip65Relays, filter); + if (event) { + await cacheEvent(event); + return event; + } + } catch (err) { + console.error('Failed to fetch longform event (NIP-65 fallback):', err); + } + } + + return null; }, async fetchReactions(eventId: string) { diff --git a/frontend/app/admin/blog/page.tsx b/frontend/app/admin/blog/page.tsx index e29710a..c05bba0 100644 --- a/frontend/app/admin/blog/page.tsx +++ b/frontend/app/admin/blog/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { api } from "@/lib/api"; import { cn } from "@/lib/utils"; +import { fetchEventFromRelays, fetchLongformFromRelays } from "@/lib/nostr"; import { Pencil, Trash2, @@ -56,13 +57,24 @@ export default function BlogPage() { setFetching(true); setError(""); try { - const isNaddr = importInput.startsWith("naddr"); - const data = await api.fetchNostrEvent( - isNaddr ? { naddr: importInput } : { eventId: importInput } - ); - setImportPreview(data); + const isNaddr = importInput.trim().startsWith("naddr"); + const event = isNaddr + ? await fetchLongformFromRelays(importInput.trim()) + : await fetchEventFromRelays(importInput.trim()); + + if (!event) { + setError("Event not found on relays"); + setImportPreview(null); + return; + } + + const titleTag = event.tags?.find((t: string[]) => t[0] === "title"); + setImportPreview({ + ...event, + title: titleTag?.[1] || "Untitled", + }); } catch (err: any) { - setError(err.message); + setError(err.message || "Failed to fetch event"); setImportPreview(null); } finally { setFetching(false); @@ -70,14 +82,29 @@ export default function BlogPage() { }; const handleImport = async () => { - if (!importInput.trim()) return; + if (!importPreview) return; setImporting(true); setError(""); try { - const isNaddr = importInput.startsWith("naddr"); - await api.importPost( - isNaddr ? { naddr: importInput } : { eventId: importInput } - ); + const isNaddr = importInput.trim().startsWith("naddr"); + const excerpt = (importPreview.content || "") + .slice(0, 200) + .replace(/[#*_\n]/g, "") + .trim(); + + const tags: string[] = (importPreview.tags || []) + .filter((t: string[]) => t[0] === "t" && t[1]) + .map((t: string[]) => t[1].toLowerCase()); + + await api.importPost({ + nostrEventId: importPreview.id, + naddr: isNaddr ? importInput.trim() : undefined, + title: importPreview.title || "Untitled", + excerpt: excerpt || undefined, + authorPubkey: importPreview.pubkey, + publishedAt: importPreview.created_at, + tags: tags.length > 0 ? tags : undefined, + }); setImportInput(""); setImportPreview(null); setImportOpen(false); @@ -95,7 +122,7 @@ export default function BlogPage() { title: post.title || "", slug: post.slug || "", excerpt: post.excerpt || "", - categories: post.categories?.map((c: any) => c.id || c) || [], + categories: post.categories?.map((c: any) => c.categoryId || c.category?.id || c) || [], featured: post.featured || false, visible: post.visible !== false, }); @@ -184,6 +211,20 @@ export default function BlogPage() {

{importPreview.content?.slice(0, 300)}...

+ {(() => { + const previewTags = (importPreview.tags || []) + .filter((t: string[]) => t[0] === "t" && t[1]) + .map((t: string[]) => t[1]); + return previewTags.length > 0 ? ( +
+ {previewTags.map((tag: string) => ( + + {tag} + + ))} +
+ ) : null; + })()} + {/* Submissions tab */} @@ -516,6 +613,121 @@ export default function DashboardPage() { )} + + {/* Relays tab */} + {activeTab === "relays" && ( +
+

Your Relays

+

+ Manage the Nostr relays used for your interactions on this site. + These relays are used when publishing events and fetching your content. + You can also import your relay list from your Nostr profile (NIP-65). +

+ + {relaySuccess && ( +
+ {relaySuccess} +
+ )} + {relayError && ( +
+ {relayError} +
+ )} + +
+
+ setNewRelayUrl(e.target.value)} + placeholder="wss://relay.example.com" + className="flex-1 bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 font-mono text-sm placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40" + /> + +
+ +
+ + {loadingRelays ? ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+ ))} +
+ ) : userRelays.length === 0 ? ( +
+ +

+ No relays configured. Add a relay manually or import from your NIP-65 profile. +

+
+ ) : ( +
+ {userRelays.map((relay) => ( +
+
+

+ {relay.url} +

+
+ {relay.read && ( + + + Read + + )} + {relay.write && ( + + + Write + + )} +
+
+ +
+ ))} +
+ )} +
+ )} ); } diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index f93a1f2..60fcf37 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -23,7 +23,7 @@ export const metadata: Metadata = { template: "%s | Belgian Bitcoin Embassy", }, description: - "Belgium's sovereign Bitcoin community. Monthly meetups in Antwerp, Bitcoin education, and curated Nostr content. No hype, just signal.", + "Discover Bitcoin meetups across Belgium. Real conversations, education, and a strong local community.", keywords: [ "Bitcoin", "Belgium", @@ -44,7 +44,7 @@ export const metadata: Metadata = { siteName: "Belgian Bitcoin Embassy", title: "Belgian Bitcoin Embassy | Bitcoin Meetups & Education in Belgium", description: - "Belgium's sovereign Bitcoin community. Monthly meetups, education, and curated Nostr content.", + "Discover Bitcoin meetups across Belgium. Real conversations, education, and a strong local community.", images: [ { url: "/og-default.png", @@ -58,7 +58,7 @@ export const metadata: Metadata = { card: "summary_large_image", title: "Belgian Bitcoin Embassy", description: - "Belgium's sovereign Bitcoin community. Monthly meetups, education, and curated Nostr content.", + "Discover Bitcoin meetups across Belgium. Real conversations, education, and a strong local community.", images: ["/og-default.png"], }, robots: { diff --git a/frontend/components/public/JsonLd.tsx b/frontend/components/public/JsonLd.tsx index cceb275..2505079 100644 --- a/frontend/components/public/JsonLd.tsx +++ b/frontend/components/public/JsonLd.tsx @@ -24,7 +24,7 @@ export function OrganizationJsonLd() { url: siteUrl, logo: `${siteUrl}/og-default.png`, description: - "Belgium's sovereign Bitcoin community. Monthly meetups, education, and curated Nostr content.", + "Discover Bitcoin meetups across Belgium. Real conversations, education, and a strong local community.", sameAs: ["https://t.me/belgianbitcoinembassy"], address: { "@type": "PostalAddress", @@ -45,7 +45,7 @@ export function WebSiteJsonLd() { name: "Belgian Bitcoin Embassy", url: siteUrl, description: - "Belgium's sovereign Bitcoin community. Monthly meetups, education, and curated Nostr content.", + "Discover Bitcoin meetups across Belgium. Real conversations, education, and a strong local community.", publisher: { "@type": "Organization", name: "Belgian Bitcoin Embassy", diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index e58b680..1ddbff8 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -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("/posts/import", { method: "POST", body: JSON.stringify(data) }), updatePost: (id: string, data: any) => request(`/posts/${id}`, { method: "PATCH", body: JSON.stringify(data) }), @@ -116,6 +124,7 @@ export const api = { request(`/organizers/${id}`, { method: "DELETE" }), // Relays + getPublicRelays: () => request<{ relays: string[] }>("/relays/public"), getRelays: () => request("/relays"), addRelay: (data: { url: string; priority?: number }) => request("/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("/user-relays"), + addUserRelay: (data: { url: string; read?: boolean; write?: boolean }) => + request("/user-relays", { method: "POST", body: JSON.stringify(data) }), + removeUserRelay: (id: string) => + request(`/user-relays/${id}`, { method: "DELETE" }), + importNip65Relays: () => + request<{ imported: number; total?: number; message?: string }>("/user-relays/import-nip65", { method: "POST" }), + // Settings getSettings: () => request>("/settings"), getPublicSettings: () => request>("/settings/public"), diff --git a/frontend/lib/nostr.ts b/frontend/lib/nostr.ts index 2387d3c..3fe1ee0 100644 --- a/frontend/lib/nostr.ts +++ b/frontend/lib/nostr.ts @@ -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 { + 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(); +const NIP65_TTL = 10 * 60 * 1000; // 10 minutes + +export async function fetchNip65RelayList(pubkey: string): Promise { + 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 { const { SimplePool } = await import("nostr-tools/pool"); - let relayUrls: string[] = DEFAULT_RELAYS; + const siteRelays = await getSiteRelays(); + const allRelays = new Set(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 { export async function fetchNostrProfile( pubkey: string, - relayUrls: string[] = DEFAULT_RELAYS + relayUrls?: string[] ): Promise { const { SimplePool } = await import("nostr-tools/pool"); + const allRelays = new Set(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 { + 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 { + 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([...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); diff --git a/frontend/public/og-default-old.png b/frontend/public/og-default-old.png new file mode 100644 index 0000000..0f2d48d Binary files /dev/null and b/frontend/public/og-default-old.png differ diff --git a/frontend/public/og-default.png b/frontend/public/og-default.png index 0f2d48d..df3cb66 100644 Binary files a/frontend/public/og-default.png and b/frontend/public/og-default.png differ