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:
@@ -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.
|
||||
@@ -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
@@ -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;
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user