feat: resolve live Nostr references in blog posts and add embeds

Support naddr/nevent/note slugs for unindexed posts, cache naddr lookups,
render Nostr embeds in markdown, and add a consistency check for events mirrors.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
bbe
2026-06-29 03:57:32 +02:00
co-authored by Cursor
parent 6023991f5c
commit 2ef68222bf
23 changed files with 1066 additions and 264 deletions
+73 -10
View File
@@ -35,6 +35,55 @@ async function canViewHiddenPosts(req: Request): Promise<boolean> {
return !!access && (access.isSuperAdmin || access.permissions.has('blog.draft'));
}
// Resolves a blog slug to the Nostr event id used for reactions/replies,
// whether it's an indexed post or a live NIP-19 reference. Returns null if
// neither resolves.
async function resolveEventIdForSlug(slug: string): Promise<string | null> {
const post = await prisma.post.findUnique({ where: { slug } });
if (post) return post.nostrEventId;
const event = await nostrService.resolveEventByIdentifier(slug);
return event?.id ?? null;
}
function tagValue(event: { tags?: string[][] }, name: string): string | undefined {
return event.tags?.find((t) => t[0] === name)?.[1];
}
// Shapes a live Nostr longform event into the same JSON the frontend expects
// from an indexed Post, so naddr/nevent/note links render through the regular
// post template instead of 404ing. `identifier` is the original URL segment and
// becomes the slug so canonical URLs and reaction/reply lookups stay stable.
function buildPostShapeFromEvent(
event: { id: string; pubkey: string; content: string; created_at: number; tags?: string[][] },
identifier: string
) {
const title = tagValue(event, 'title') || 'Untitled';
const summary = tagValue(event, 'summary') || null;
const image = tagValue(event, 'image') || null;
const publishedAtSec = Number(tagValue(event, 'published_at')) || event.created_at;
const categories = (event.tags || [])
.filter((t) => t[0] === 't' && t[1])
.map((t) => ({ category: { id: t[1], name: t[1], slug: t[1] } }));
return {
id: event.id,
nostrEventId: event.id,
naddr: identifier.startsWith('naddr') ? identifier : null,
title,
slug: identifier,
content: event.content || '',
excerpt: summary,
image,
authorPubkey: event.pubkey,
authorName: null,
featured: false,
visible: true,
publishedAt: new Date(publishedAtSec * 1000).toISOString(),
createdAt: new Date(event.created_at * 1000).toISOString(),
categories,
};
}
router.get('/', async (req: Request, res: Response) => {
try {
const page = parseInt(req.query.page as string) || 1;
@@ -78,19 +127,31 @@ router.get('/', async (req: Request, res: Response) => {
router.get('/:slug', async (req: Request, res: Response) => {
try {
const slug = req.params.slug as string;
const post = await prisma.post.findUnique({
where: { slug: req.params.slug as string },
where: { slug },
include: {
categories: { include: { category: true } },
},
});
if (!post) {
res.status(404).json({ error: 'Post not found' });
if (post) {
// Indexed posts store an empty body (the canonical copy lives on Nostr);
// the frontend hydrates the body live from relays. Return as-is.
res.json(post);
return;
}
res.json(post);
// Not indexed: resolve the slug as a live NIP-19 reference so the page has
// real metadata (title, summary, image) for SEO instead of "Post Not
// Found". The frontend still fetches the body from relays for display.
const event = await nostrService.resolveEventByIdentifier(slug);
if (event) {
res.json(buildPostShapeFromEvent(event, slug));
return;
}
res.status(404).json({ error: 'Post not found' });
} catch (err) {
console.error('Get post error:', err);
res.status(500).json({ error: 'Internal server error' });
@@ -181,13 +242,14 @@ router.patch(
router.get('/:slug/reactions', async (req: Request, res: Response) => {
try {
const post = await prisma.post.findUnique({ where: { slug: req.params.slug as string } });
if (!post) {
const slug = req.params.slug as string;
const eventId = await resolveEventIdForSlug(slug);
if (!eventId) {
res.status(404).json({ error: 'Post not found' });
return;
}
const reactions = await nostrService.fetchReactions(post.nostrEventId);
const reactions = await nostrService.fetchReactions(eventId);
res.json({ count: reactions.length, reactions });
} catch (err) {
console.error('Get reactions error:', err);
@@ -197,14 +259,15 @@ router.get('/:slug/reactions', async (req: Request, res: Response) => {
router.get('/:slug/replies', async (req: Request, res: Response) => {
try {
const post = await prisma.post.findUnique({ where: { slug: req.params.slug as string } });
if (!post) {
const slug = req.params.slug as string;
const eventId = await resolveEventIdForSlug(slug);
if (!eventId) {
res.status(404).json({ error: 'Post not found' });
return;
}
const [replies, hiddenContent, blockedPubkeys] = await Promise.all([
nostrService.fetchReplies(post.nostrEventId),
nostrService.fetchReplies(eventId),
prisma.hiddenContent.findMany({ select: { nostrEventId: true } }),
prisma.blockedPubkey.findMany({ select: { pubkey: true } }),
]);
+55
View File
@@ -118,6 +118,32 @@ export const nostrService = {
return null;
}
// Cache-first: addressable events are keyed by kind+pubkey+d-tag, not by a
// stable event id, so look them up among cached events of the same author.
// Avoids re-querying relays on every repeat visit to the same naddr.
const cached = await prisma.nostrEventCache.findMany({
where: { kind: decoded.kind, pubkey: decoded.pubkey },
});
for (const c of cached) {
let tags: string[][] = [];
try {
tags = JSON.parse(c.tags);
} catch {
continue;
}
const dTag = tags.find((t) => t[0] === 'd');
if (dTag?.[1] === decoded.identifier) {
return {
id: c.eventId,
kind: c.kind,
pubkey: c.pubkey,
content: c.content,
tags,
created_at: c.createdAt,
};
}
}
const siteRelays = await getRelayUrls();
const naddrRelays = decoded.relays || [];
const filter = {
@@ -158,6 +184,35 @@ export const nostrService = {
return null;
},
// Resolves any NIP-19 reference (naddr / nevent / note) or a raw 64-char hex
// event id to the underlying Nostr event, reusing the cache-aware fetchers.
// Returns null if the string can't be decoded or the event isn't found.
async resolveEventByIdentifier(identifier: string) {
const trimmed = identifier.trim();
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return nostrService.fetchEvent(trimmed.toLowerCase());
}
let decoded;
try {
decoded = nip19.decode(trimmed);
} catch {
return null;
}
switch (decoded.type) {
case 'naddr':
return nostrService.fetchLongformEvent(trimmed);
case 'nevent':
return nostrService.fetchEvent((decoded.data as nip19.EventPointer).id);
case 'note':
return nostrService.fetchEvent(decoded.data as string);
default:
return null;
}
},
async fetchReactions(eventId: string) {
const relays = await getRelayUrls();
if (relays.length === 0) return [];