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;