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
+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) {