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:
bbe
2026-06-23 05:54:34 +02:00
co-authored by Cursor
parent 78271ea110
commit 3bdd01dedf
19 changed files with 896 additions and 132 deletions
@@ -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";
@@ -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");
@@ -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.
+13 -1
View File
@@ -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
+48 -30
View File
@@ -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' });
+16
View File
@@ -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,
+137
View File
@@ -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<string>();
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;
+2
View File
@@ -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' });
+101 -56
View File
@@ -11,7 +11,62 @@ async function getRelayUrls(): Promise<string[]> {
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) {