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:
+48
-30
@@ -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' });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user