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:
+73
-10
@@ -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 } }),
|
||||
]);
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
Reference in New Issue
Block a user