diff --git a/backend/package-lock.json b/backend/package-lock.json index a9b1ee1..4d80fd4 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,7 +20,8 @@ "nostr-tools": "^2.10.0", "slugify": "^1.6.8", "ulid": "^3.0.2", - "uuid": "^11.0.0" + "uuid": "^11.0.0", + "ws": "^8.21.0" }, "devDependencies": { "@types/cors": "^2.8.17", @@ -30,6 +31,7 @@ "@types/morgan": "^1.9.10", "@types/multer": "^2.1.0", "@types/uuid": "^10.0.0", + "@types/ws": "^8.18.1", "prisma": "^6.0.0", "tsx": "^4.19.0", "typescript": "^5.6.0" @@ -807,6 +809,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -2553,6 +2565,27 @@ "engines": { "node": ">= 8" } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/backend/package.json b/backend/package.json index ad06e70..0b9787e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,7 +10,8 @@ "db:baseline-and-migrate": "bash scripts/baseline-and-migrate.sh", "db:seed": "dotenv -e ../.env -e .env -- prisma db seed", "db:studio": "dotenv -e ../.env -e .env -- prisma studio", - "migrate:deploy": "dotenv -e ../.env -e .env -- prisma migrate deploy" + "migrate:deploy": "dotenv -e ../.env -e .env -- prisma migrate deploy", + "backfill-submissions": "dotenv -e ../.env -e .env -- tsx scripts/backfill-approved-submissions.ts" }, "prisma": { "seed": "tsx prisma/seed.ts" @@ -28,7 +29,8 @@ "nostr-tools": "^2.10.0", "slugify": "^1.6.8", "ulid": "^3.0.2", - "uuid": "^11.0.0" + "uuid": "^11.0.0", + "ws": "^8.21.0" }, "devDependencies": { "@types/cors": "^2.8.17", @@ -38,6 +40,7 @@ "@types/morgan": "^1.9.10", "@types/multer": "^2.1.0", "@types/uuid": "^10.0.0", + "@types/ws": "^8.18.1", "prisma": "^6.0.0", "tsx": "^4.19.0", "typescript": "^5.6.0" diff --git a/backend/scripts/backfill-approved-submissions.ts b/backend/scripts/backfill-approved-submissions.ts new file mode 100644 index 0000000..3380cb7 --- /dev/null +++ b/backend/scripts/backfill-approved-submissions.ts @@ -0,0 +1,64 @@ +// One-time backfill: publish any APPROVED submissions that never got a blog +// Post (they predate auto-publish-on-approval). Idempotent — already-imported +// submissions are skipped, so it is safe to re-run. +// +// Usage (from backend/): +// npm run backfill-submissions +import { prisma } from '../src/db/prisma'; +import { importPostFromNostr, resolveSubmissionImport } from '../src/services/postImport'; + +async function main() { + const approved = await prisma.submission.findMany({ + where: { status: 'APPROVED' }, + orderBy: { createdAt: 'asc' }, + }); + + console.log(`Found ${approved.length} APPROVED submission(s).`); + + let imported = 0; + let skipped = 0; + let failed = 0; + + for (const submission of approved) { + const label = `"${submission.title}" (${submission.id})`; + try { + const importInput = await resolveSubmissionImport(submission); + if (!importInput) { + console.warn(` FAILED ${label}: could not resolve Nostr event from relays.`); + failed++; + continue; + } + + const existing = await prisma.post.findUnique({ + where: { nostrEventId: importInput.nostrEventId }, + }); + if (existing) { + console.log(` SKIP ${label}: already published (slug: ${existing.slug}).`); + skipped++; + continue; + } + + const post = await importPostFromNostr(importInput); + console.log(` IMPORT ${label}: published (slug: ${post?.slug}).`); + imported++; + } catch (err) { + console.error(` FAILED ${label}:`, err); + failed++; + } + } + + console.log(`\nDone. Imported: ${imported}, Skipped: ${skipped}, Failed: ${failed}.`); +} + +main() + .then(async () => { + await prisma.$disconnect(); + // The Nostr relay pool keeps websockets open, which would otherwise keep the + // process alive after the work is done. + process.exit(0); + }) + .catch(async (err) => { + console.error('Backfill error:', err); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/backend/src/api/posts.ts b/backend/src/api/posts.ts index 7d5250b..79cbef0 100644 --- a/backend/src/api/posts.ts +++ b/backend/src/api/posts.ts @@ -1,10 +1,89 @@ import { Router, Request, Response } from 'express'; +import jwt from 'jsonwebtoken'; import { prisma } from '../db/prisma'; import { requireAuth, requires } from '../middleware/auth'; +import { authService, type ResolvedAccess } from '../services/auth'; +import { looksLikeApiKey, resolveApiKey } from '../services/apiKeys'; import { nostrService } from '../services/nostr'; +import { importPostFromNostr } from '../services/postImport'; + +const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production'; const router = Router(); +// Resolves access for an optional Bearer token (JWT or API key) without +// rejecting unauthenticated callers, so listing endpoints can stay public while +// still recognizing staff who pass `?all=true`. +async function resolveOptionalAccess(req: Request): Promise { + const header = req.headers.authorization; + if (!header || !header.startsWith('Bearer ')) return null; + const token = header.slice(7); + try { + if (looksLikeApiKey(token)) { + return await resolveApiKey(token); + } + const payload = jwt.verify(token, JWT_SECRET) as { pubkey: string }; + return await authService.resolveAccess(payload.pubkey); + } catch { + return null; + } +} + +// Returns true when the requester is authenticated and may see hidden posts. +async function canViewHiddenPosts(req: Request): Promise { + const access = await resolveOptionalAccess(req); + 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 { + 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; @@ -12,7 +91,10 @@ router.get('/', async (req: Request, res: Response) => { const category = req.query.category as string | undefined; const skip = (page - 1) * limit; - const where: any = { visible: true }; + const includeHidden = req.query.all === 'true' && (await canViewHiddenPosts(req)); + + const where: any = {}; + if (!includeHidden) where.visible = true; if (category) { where.categories = { some: { category: { slug: category } }, @@ -34,6 +116,7 @@ router.get('/', async (req: Request, res: Response) => { res.json({ posts, + total, pagination: { page, limit, total, pages: Math.ceil(total / limit) }, }); } catch (err) { @@ -44,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' }); @@ -76,62 +171,14 @@ router.post( return; } - const slugBase = title - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, ''); - const slug = `${slugBase}-${nostrEventId.slice(0, 8)}`; - - const post = await prisma.post.upsert({ - where: { nostrEventId }, - update: { - title, - excerpt: excerpt || undefined, - naddr: naddr || undefined, - }, - create: { - nostrEventId, - naddr: naddr || null, - title, - slug, - excerpt: excerpt || null, - authorPubkey, - publishedAt: publishedAt - ? new Date(typeof publishedAt === 'number' ? publishedAt * 1000 : publishedAt) - : new Date(), - }, - }); - - 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 } } }, + const result = await importPostFromNostr({ + nostrEventId, + naddr, + title, + excerpt, + authorPubkey, + publishedAt, + tags, }); res.json(result); @@ -195,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); @@ -211,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 } }), ]); diff --git a/backend/src/api/submissions.ts b/backend/src/api/submissions.ts index 07f6a6d..26d001d 100644 --- a/backend/src/api/submissions.ts +++ b/backend/src/api/submissions.ts @@ -1,6 +1,7 @@ import { Router, Request, Response } from 'express'; import { prisma } from '../db/prisma'; import { requireAuth, requires } from '../middleware/auth'; +import { importPostFromNostr, resolveSubmissionImport } from '../services/postImport'; const router = Router(); @@ -94,6 +95,22 @@ router.patch( return; } + // Approval publishes the referenced Nostr article to the blog. Do this + // before flipping the status so a failed import doesn't leave a submission + // marked APPROVED without a corresponding live post. + let post = null; + if (status === 'APPROVED') { + const importInput = await resolveSubmissionImport(submission); + if (!importInput) { + res.status(422).json({ + error: + 'Could not resolve the submitted Nostr event from relays. The post was not published, so the submission was left unchanged.', + }); + return; + } + post = await importPostFromNostr(importInput); + } + const updated = await prisma.submission.update({ where: { id: req.params.id as string }, data: { @@ -103,7 +120,7 @@ router.patch( }, }); - res.json(updated); + res.json({ submission: updated, post }); } catch (err) { console.error('Review submission error:', err); res.status(500).json({ error: 'Internal server error' }); diff --git a/backend/src/services/nostr.ts b/backend/src/services/nostr.ts index 7aede6e..2f2d984 100644 --- a/backend/src/services/nostr.ts +++ b/backend/src/services/nostr.ts @@ -1,6 +1,14 @@ +import WebSocket from 'ws'; import { SimplePool, nip19 } from 'nostr-tools'; import { prisma } from '../db/prisma'; +// nostr-tools' SimplePool relies on a global WebSocket. Node only exposes one +// from v22 onward, so on older runtimes (this backend runs Node 20) every relay +// query silently fails without this polyfill. Set before any pool connects. +if (typeof (globalThis as { WebSocket?: unknown }).WebSocket === 'undefined') { + (globalThis as { WebSocket?: unknown }).WebSocket = WebSocket; +} + const pool = new SimplePool(); async function getRelayUrls(): Promise { @@ -110,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 = { @@ -150,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 []; diff --git a/backend/src/services/postImport.ts b/backend/src/services/postImport.ts new file mode 100644 index 0000000..9894b1f --- /dev/null +++ b/backend/src/services/postImport.ts @@ -0,0 +1,124 @@ +import { prisma } from '../db/prisma'; +import { nostrService } from './nostr'; + +export interface ImportPostInput { + nostrEventId: string; + naddr?: string | null; + title: string; + excerpt?: string | null; + authorPubkey: string; + publishedAt?: number | string | null; + tags?: string[]; + visible?: boolean; +} + +// Upserts a blog Post from a Nostr longform event. Shared by the manual import +// endpoint and the submission-approval flow so both produce identical results +// (auto slug, category links from `t` tags, visible by default). +export async function importPostFromNostr(input: ImportPostInput) { + const { nostrEventId, naddr, title, excerpt, authorPubkey, publishedAt, tags, visible } = input; + + const slugBase = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + const slug = `${slugBase}-${nostrEventId.slice(0, 8)}`; + + const post = await prisma.post.upsert({ + where: { nostrEventId }, + update: { + title, + excerpt: excerpt || undefined, + naddr: naddr || undefined, + }, + create: { + nostrEventId, + naddr: naddr || null, + title, + slug, + excerpt: excerpt || null, + authorPubkey, + visible: visible ?? true, + publishedAt: publishedAt + ? new Date(typeof publishedAt === 'number' ? publishedAt * 1000 : publishedAt) + : new Date(), + }, + }); + + if (Array.isArray(tags) && tags.length > 0) { + const categoryIds: string[] = []; + for (const tag of tags) { + 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, + })), + }); + } + } + + return prisma.post.findUnique({ + where: { id: post.id }, + include: { categories: { include: { category: true } } }, + }); +} + +export interface SubmissionRef { + eventId: string | null; + naddr: string | null; + title: string; + authorPubkey: string; +} + +// Resolves the Nostr longform event referenced by a submission into a blog +// import payload. Returns null when the event cannot be resolved into something +// publishable (e.g. naddr that no relay can serve and no fallback event id). +export async function resolveSubmissionImport( + submission: SubmissionRef +): Promise { + const event = submission.naddr + ? await nostrService.fetchLongformEvent(submission.naddr) + : submission.eventId + ? await nostrService.fetchEvent(submission.eventId) + : null; + + const nostrEventId: string | undefined = event?.id || submission.eventId || undefined; + if (!nostrEventId) return null; + + const eventTags: string[][] = Array.isArray(event?.tags) ? (event!.tags as string[][]) : []; + const titleTag = eventTags.find((t) => t[0] === 'title')?.[1]; + const topicTags = eventTags + .filter((t) => t[0] === 't' && t[1]) + .map((t) => (t[1] as string).toLowerCase()); + + const excerpt = ((event?.content as string) || '') + .slice(0, 200) + .replace(/[#*_\n]/g, '') + .trim(); + + return { + nostrEventId, + naddr: submission.naddr || undefined, + title: submission.title || titleTag || 'Untitled', + excerpt: excerpt || undefined, + authorPubkey: event?.pubkey || submission.authorPubkey, + publishedAt: event?.created_at ?? null, + tags: topicTags.length > 0 ? topicTags : undefined, + visible: true, + }; +} diff --git a/frontend/app/admin/submissions/page.tsx b/frontend/app/admin/submissions/page.tsx index 46b3a24..0d6dbc3 100644 --- a/frontend/app/admin/submissions/page.tsx +++ b/frontend/app/admin/submissions/page.tsx @@ -40,6 +40,7 @@ export default function AdminSubmissionsPage() { const [reviewingId, setReviewingId] = useState(null); const [reviewNote, setReviewNote] = useState(""); const [processing, setProcessing] = useState(false); + const [success, setSuccess] = useState(""); const loadSubmissions = async () => { try { @@ -61,10 +62,17 @@ export default function AdminSubmissionsPage() { const handleReview = async (id: string, status: "APPROVED" | "REJECTED") => { setProcessing(true); setError(""); + setSuccess(""); try { - await api.reviewSubmission(id, { status, reviewNote: reviewNote.trim() || undefined }); + const res = await api.reviewSubmission(id, { + status, + reviewNote: reviewNote.trim() || undefined, + }); setReviewingId(null); setReviewNote(""); + if (status === "APPROVED" && res?.post) { + setSuccess(`Approved and published "${res.post.title}" to the blog.`); + } await loadSubmissions(); } catch (err: any) { setError(err.message); @@ -87,6 +95,7 @@ export default function AdminSubmissionsPage() { {error &&

{error}

} + {success &&

{success}

}
{TABS.map((tab) => ( diff --git a/frontend/app/blog.md/route.ts b/frontend/app/blog.md/route.ts new file mode 100644 index 0000000..0605ba8 --- /dev/null +++ b/frontend/app/blog.md/route.ts @@ -0,0 +1,10 @@ +import { buildBlogMarkdown } from "@/lib/llms"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const body = await buildBlogMarkdown(); + return new Response(body, { + headers: { "Content-Type": "text/markdown; charset=utf-8" }, + }); +} diff --git a/frontend/app/blog/[slug]/BlogPostClient.tsx b/frontend/app/blog/[slug]/BlogPostClient.tsx index 1bcf32e..85921e8 100644 --- a/frontend/app/blog/[slug]/BlogPostClient.tsx +++ b/frontend/app/blog/[slug]/BlogPostClient.tsx @@ -3,14 +3,26 @@ import { useState, useEffect, useCallback } from "react"; import Link from "next/link"; import { ArrowLeft, Heart, Send } from "lucide-react"; -import ReactMarkdown from "react-markdown"; +import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; import remarkGfm from "remark-gfm"; import { api } from "@/lib/api"; import { formatDate } from "@/lib/utils"; -import { hasNostrExtension, getPublicKey, signEvent, publishEvent, shortenPubkey, fetchNostrProfile, fetchLongformFromRelays, fetchEventFromRelays, type NostrProfile } from "@/lib/nostr"; +import { + hasNostrExtension, + getPublicKey, + signEvent, + publishEvent, + shortenPubkey, + fetchNostrProfile, + fetchLongformFromRelays, + fetchEventFromRelays, + resolveEventFromRelays, + type NostrProfile, +} from "@/lib/nostr"; import { Navbar } from "@/components/public/Navbar"; import { Footer } from "@/components/public/Footer"; -import type { Components } from "react-markdown"; +import { markdownComponents } from "./markdownComponents"; +import { remarkNostr } from "./remarkNostr"; interface Post { id: string; @@ -34,84 +46,28 @@ interface NostrReply { created_at: number; } -const markdownComponents: Components = { - h1: ({ children }) => ( -

{children}

- ), - h2: ({ children }) => ( -

{children}

- ), - h3: ({ children }) => ( -

{children}

- ), - h4: ({ children }) => ( -

{children}

- ), - p: ({ children }) => ( -

{children}

- ), - a: ({ href, children }) => ( - - {children} - - ), - ul: ({ children }) => ( -
    {children}
- ), - ol: ({ children }) => ( -
    {children}
- ), - li: ({ children }) => ( -
  • {children}
  • - ), - blockquote: ({ children }) => ( -
    - {children} -
    - ), - code: ({ className, children }) => { - const isBlock = className?.includes("language-"); - if (isBlock) { - return ( - - {children} - - ); - } - return ( - - {children} - - ); - }, - pre: ({ children }) => ( -
    -      {children}
    -    
    - ), - img: ({ src, alt }) => ( - {alt - ), - hr: () =>
    , - table: ({ children }) => ( -
    - {children}
    -
    - ), - th: ({ children }) => ( - - {children} - - ), - td: ({ children }) => ( - {children} - ), -}; +// Builds a renderable Post from a raw long-form Nostr event, used when a slug is +// a NIP-19 reference (naddr/nevent/note) that was never indexed by the backend. +function postFromEvent(event: any, slug: string): Post { + const tag = (name: string): string | undefined => + event.tags?.find((t: string[]) => t[0] === name)?.[1]; + const publishedAtSec = Number(tag("published_at")) || event.created_at; + + return { + id: event.id, + slug, + title: tag("title") || "Untitled", + content: event.content || "", + excerpt: tag("summary"), + authorPubkey: event.pubkey, + publishedAt: new Date(publishedAtSec * 1000).toISOString(), + nostrEventId: event.id, + naddr: slug.startsWith("naddr") ? slug : undefined, + categories: (event.tags || []) + .filter((t: string[]) => t[0] === "t" && t[1]) + .map((t: string[]) => ({ category: { id: t[1], name: t[1], slug: t[1] } })), + }; +} function ArticleSkeleton() { const widths = [85, 92, 78, 95, 88, 72, 90, 83]; @@ -137,6 +93,18 @@ function ArticleSkeleton() { ); } +function RelayLoading() { + return ( +
    + + Fetching from Nostr relays… +
    + ); +} + export default function BlogPostClient({ slug }: { slug: string }) { const [post, setPost] = useState(null); const [loading, setLoading] = useState(true); @@ -150,6 +118,11 @@ export default function BlogPostClient({ slug }: { slug: string }) { const [authorProfile, setAuthorProfile] = useState(null); const [liveContent, setLiveContent] = useState(null); const [loadingContent, setLoadingContent] = useState(false); + const [resolvingFromRelays, setResolvingFromRelays] = useState(false); + const [contentError, setContentError] = useState(false); + const [retryKey, setRetryKey] = useState(0); + + const retry = useCallback(() => setRetryKey((k) => k + 1), []); useEffect(() => { setHasNostr(hasNostrExtension()); @@ -157,39 +130,70 @@ export default function BlogPostClient({ slug }: { slug: string }) { useEffect(() => { if (!slug) return; + let cancelled = false; setLoading(true); setError(null); setLiveContent(null); + setPost(null); + setAuthorProfile(null); + setContentError(false); + setResolvingFromRelays(false); + + // Loads the author's profile, then hydrates the article body from relays + // when only metadata is present (indexed posts store an empty body). + const hydrate = (data: Post) => { + if (cancelled) return; + setPost(data); + setLoading(false); + if (data?.authorPubkey) { + fetchNostrProfile(data.authorPubkey) + .then((profile) => !cancelled && setAuthorProfile(profile)) + .catch(() => {}); + } + if (!data?.content && (data?.naddr || data?.nostrEventId)) { + setLoadingContent(true); + setContentError(false); + const fetchPromise = data.naddr + ? fetchLongformFromRelays(data.naddr) + : fetchEventFromRelays(data.nostrEventId!); + fetchPromise + .then((event) => { + if (cancelled) return; + if (event?.content) setLiveContent(event.content); + else setContentError(true); // not found / timed out + }) + .catch(() => !cancelled && setContentError(true)) + .finally(() => !cancelled && setLoadingContent(false)); + } + }; + api .getPost(slug) - .then((data) => { - setPost(data); - setLoading(false); - if (data?.authorPubkey) { - fetchNostrProfile(data.authorPubkey) - .then((profile) => setAuthorProfile(profile)) - .catch(() => {}); - } - if (!data?.content && (data?.naddr || data?.nostrEventId)) { - setLoadingContent(true); - const fetchPromise = data.naddr - ? fetchLongformFromRelays(data.naddr) - : fetchEventFromRelays(data.nostrEventId!); - fetchPromise - .then((event) => { - if (event?.content) { - setLiveContent(event.content); - } - }) - .catch(() => {}) - .finally(() => setLoadingContent(false)); - } - }) - .catch((err) => { - setError(err.message); + .then((data) => hydrate(data)) + .catch(() => { + // Not indexed: treat the slug itself as a NIP-19 reference and resolve + // the long-form note live from relays. + if (cancelled) return; setLoading(false); + setResolvingFromRelays(true); + resolveEventFromRelays(slug) + .then((event) => { + if (cancelled) return; + setResolvingFromRelays(false); + if (event) hydrate(postFromEvent(event, slug)); + else setError("Post not found"); + }) + .catch((err) => { + if (cancelled) return; + setResolvingFromRelays(false); + setError(err?.message || "Post not found"); + }); }); - }, [slug]); + + return () => { + cancelled = true; + }; + }, [slug, retryKey]); useEffect(() => { if (!slug) return; @@ -271,9 +275,17 @@ export default function BlogPostClient({ slug }: { slug: string }) { {loading && } + {resolvingFromRelays && !post && } + {error && (
    - Failed to load post: {error} +

    Failed to load post: {error}

    +
    )} @@ -328,19 +340,29 @@ export default function BlogPostClient({ slug }: { slug: string }) {
    {loadingContent ? ( -
    - {[85, 92, 78, 95, 88, 72, 90, 83].map((w, i) => ( -
    - ))} + + ) : contentError && !(post.content || liveContent) ? ( +
    +

    + Couldn't fetch this article from the Nostr relays. It may be + temporarily unavailable. +

    +
    ) : ( + url.startsWith("nostr:") ? url : defaultUrlTransform(url) + } > {post.content || liveContent || ""} diff --git a/frontend/app/blog/[slug]/NostrEmbeds.tsx b/frontend/app/blog/[slug]/NostrEmbeds.tsx new file mode 100644 index 0000000..18fc676 --- /dev/null +++ b/frontend/app/blog/[slug]/NostrEmbeds.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { nip19 } from "nostr-tools"; +import { + loadNostrProfile, + resolveEventFromRelays, + shortenPubkey, + type NostrProfile, +} from "@/lib/nostr"; + +function njump(bech32: string): string { + return `https://njump.me/${bech32}`; +} + +// Inline @mention chip: avatar + display name, resolved live from the author's +// kind:0 profile. Falls back to a shortened pubkey until (or unless) it loads. +export function NostrMention({ bech32 }: { bech32: string }) { + const pubkey = useMemo(() => { + try { + const d = nip19.decode(bech32); + if (d.type === "npub") return d.data as string; + if (d.type === "nprofile") return (d.data as { pubkey: string }).pubkey; + } catch {} + return null; + }, [bech32]); + + const [profile, setProfile] = useState(null); + + useEffect(() => { + if (!pubkey) return; + let cancelled = false; + loadNostrProfile(pubkey) + .then((p) => !cancelled && setProfile(p)) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [pubkey]); + + // Show the username; fall back to a shortened npub only when no profile. + const npub = useMemo(() => { + if (bech32.startsWith("npub")) return bech32; + return pubkey ? nip19.npubEncode(pubkey) : bech32; + }, [bech32, pubkey]); + const name = profile?.name || profile?.displayName || shortenPubkey(npub); + + return ( + + {profile?.picture && ( + { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} + @{name} + + ); +} + +// Compact author line used inside an embedded note. +function NoteAuthor({ pubkey }: { pubkey: string }) { + const [profile, setProfile] = useState(null); + useEffect(() => { + let cancelled = false; + loadNostrProfile(pubkey) + .then((p) => !cancelled && setProfile(p)) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [pubkey]); + const fallback = useMemo(() => { + try { + return shortenPubkey(nip19.npubEncode(pubkey)); + } catch { + return shortenPubkey(pubkey); + } + }, [pubkey]); + const name = profile?.name || profile?.displayName || fallback; + return ( + + {profile?.picture && ( + { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} + {name} + + ); +} + +// Embedded referenced note (note/nevent/naddr). Renders as a bordered card. +// Uses span/block elements (not
    ) so it stays valid when the reference +// sits inside a markdown paragraph. +export function NostrNote({ bech32 }: { bech32: string }) { + const [event, setEvent] = useState(null); + const [state, setState] = useState<"loading" | "done" | "error">("loading"); + + useEffect(() => { + let cancelled = false; + setState("loading"); + resolveEventFromRelays(bech32) + .then((ev) => { + if (cancelled) return; + if (ev) { + setEvent(ev); + setState("done"); + } else { + setState("error"); + } + }) + .catch(() => !cancelled && setState("error")); + return () => { + cancelled = true; + }; + }, [bech32]); + + return ( + + {state === "loading" && ( + + + Loading note… + + )} + {state === "error" && ( + + View referenced note on njump.me + + )} + {state === "done" && event && ( + + + + + View note + + + + {(event.content || "").slice(0, 1000)} + {(event.content || "").length > 1000 ? "…" : ""} + + + )} + + ); +} + +// Dispatches a `nostr:` reference to the right embed by entity type. +export function NostrEntity({ bech32 }: { bech32: string }) { + let type: string; + try { + type = nip19.decode(bech32).type; + } catch { + return <>nostr:{bech32}; + } + if (type === "npub" || type === "nprofile") return ; + if (type === "note" || type === "nevent" || type === "naddr") return ; + return <>nostr:{bech32}; +} diff --git a/frontend/app/blog/[slug]/markdownComponents.tsx b/frontend/app/blog/[slug]/markdownComponents.tsx new file mode 100644 index 0000000..a6f89d6 --- /dev/null +++ b/frontend/app/blog/[slug]/markdownComponents.tsx @@ -0,0 +1,89 @@ +import type { Components } from "react-markdown"; +import { NostrEntity } from "./NostrEmbeds"; + +// Shared markdown renderers for blog article bodies. The `remarkNostr` plugin +// rewrites NIP-27 `nostr:` references into links with that scheme, which the +// `a` renderer below swaps for live profile/note embeds. +export const markdownComponents: Components = { + h1: ({ children }) => ( +

    {children}

    + ), + h2: ({ children }) => ( +

    {children}

    + ), + h3: ({ children }) => ( +

    {children}

    + ), + h4: ({ children }) => ( +

    {children}

    + ), + p: ({ children }) => ( +

    {children}

    + ), + a: ({ href, children }) => { + if (href?.startsWith("nostr:")) { + return ; + } + return ( + + {children} + + ); + }, + ul: ({ children }) => ( +
      {children}
    + ), + ol: ({ children }) => ( +
      {children}
    + ), + li: ({ children }) => ( +
  • {children}
  • + ), + blockquote: ({ children }) => ( +
    + {children} +
    + ), + code: ({ className, children }) => { + const isBlock = className?.includes("language-"); + if (isBlock) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); + }, + pre: ({ children }) => ( +
    +      {children}
    +    
    + ), + img: ({ src, alt }) => ( + {alt + ), + hr: () =>
    , + table: ({ children }) => ( +
    + {children}
    +
    + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + {children} + ), +}; diff --git a/frontend/app/blog/[slug]/page.tsx b/frontend/app/blog/[slug]/page.tsx index fdeccbc..83b4acb 100644 --- a/frontend/app/blog/[slug]/page.tsx +++ b/frontend/app/blog/[slug]/page.tsx @@ -30,7 +30,9 @@ export async function generateMetadata({ params }: Props): Promise { post.excerpt || `Read "${post.title}" on the Belgian Bitcoin Embassy blog.`; const author = post.authorName || "Belgian Bitcoin Embassy"; - const ogImageUrl = `/og?title=${encodeURIComponent(post.title)}&type=blog`; + // Prefer the post's own header image (Nostr `image` tag); fall back to a + // generated OG card so every post still gets a real preview image. + const ogImageUrl = post.image || `/og?title=${encodeURIComponent(post.title)}&type=blog`; return { title: post.title, diff --git a/frontend/app/blog/[slug]/remarkNostr.ts b/frontend/app/blog/[slug]/remarkNostr.ts new file mode 100644 index 0000000..a4e3a2e --- /dev/null +++ b/frontend/app/blog/[slug]/remarkNostr.ts @@ -0,0 +1,64 @@ +import { nip19 } from "nostr-tools"; + +// Matches NIP-27 `nostr:` references (and bare bech32 entities) embedded in +// article text: npub/nprofile (mentions) and note/nevent/naddr (notes). +const NOSTR_RE = + /(?:nostr:)?((?:npub|nprofile|note|nevent|naddr)1[023456789acdefghjklmnpqrstuvwxyz]+)/gi; + +function isValidEntity(bech32: string): boolean { + try { + const { type } = nip19.decode(bech32); + return ["npub", "nprofile", "note", "nevent", "naddr"].includes(type); + } catch { + return false; + } +} + +// Splits a plain-text value into text + `link` nodes, where each link's url is +// `nostr:`. The markdown `a` renderer detects that scheme and swaps in +// the live profile/note component. +function splitText(value: string): any[] { + const out: any[] = []; + let last = 0; + NOSTR_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = NOSTR_RE.exec(value)) !== null) { + const bech32 = m[1]; + if (!isValidEntity(bech32)) continue; + if (m.index > last) { + out.push({ type: "text", value: value.slice(last, m.index) }); + } + out.push({ + type: "link", + url: `nostr:${bech32}`, + children: [{ type: "text", value: m[0] }], + }); + last = m.index + m[0].length; + } + if (out.length === 0) return [{ type: "text", value }]; + if (last < value.length) out.push({ type: "text", value: value.slice(last) }); + return out; +} + +// Remark plugin: walk the mdast tree and replace `nostr:` tokens inside text +// nodes. Skips code and existing links so identifiers there are left untouched. +export function remarkNostr() { + return (tree: any) => { + const walk = (node: any) => { + if (!node || !Array.isArray(node.children)) return; + const next: any[] = []; + for (const child of node.children) { + if (child.type === "text") { + next.push(...splitText(child.value)); + continue; + } + if (child.type !== "link" && child.type !== "inlineCode" && child.type !== "code") { + walk(child); + } + next.push(child); + } + node.children = next; + }; + walk(tree); + }; +} diff --git a/frontend/app/blog/page.tsx b/frontend/app/blog/page.tsx index 2827568..9657219 100644 --- a/frontend/app/blog/page.tsx +++ b/frontend/app/blog/page.tsx @@ -1,109 +1,45 @@ -"use client"; - -import { useState, useEffect } from "react"; -import Link from "next/link"; -import { ArrowRight, ArrowLeft, ChevronRight } from "lucide-react"; -import { api } from "@/lib/api"; -import { formatDate } from "@/lib/utils"; import { Navbar } from "@/components/public/Navbar"; import { Footer } from "@/components/public/Footer"; +import { + BlogIndex, + type BlogPost, + type BlogCategory, +} from "@/components/public/BlogIndex"; +import { BreadcrumbJsonLd } from "@/components/public/JsonLd"; +import { apiUrl } from "@/lib/api-base"; -interface Post { - id: string; - slug: string; - title: string; - excerpt?: string; - content?: string; - author?: string; - authorPubkey?: string; - publishedAt?: string; - createdAt?: string; - categories?: { category: { id: string; name: string; slug: string } }[]; - featured?: boolean; +const LIMIT = 9; + +// Render at request time so the first page of posts is in the HTML for crawlers. +export const dynamic = "force-dynamic"; + +async function fetchJson(path: string, fallback: T): Promise { + try { + const res = await fetch(apiUrl(path), { cache: "no-store" }); + if (!res.ok) return fallback; + return (await res.json()) as T; + } catch { + return fallback; + } } -interface Category { - id: string; - name: string; - slug: string; -} - -function PostCardSkeleton() { - return ( -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - ); -} - -function FeaturedPostSkeleton() { - return ( -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - ); -} - -export default function BlogPage() { - const [posts, setPosts] = useState([]); - const [categories, setCategories] = useState([]); - const [activeCategory, setActiveCategory] = useState("all"); - const [page, setPage] = useState(1); - const [total, setTotal] = useState(0); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const limit = 9; - - useEffect(() => { - api.getCategories().then(setCategories).catch(() => {}); - }, []); - - useEffect(() => { - setLoading(true); - setError(null); - api - .getPosts({ - category: activeCategory === "all" ? undefined : activeCategory, - page, - limit, - }) - .then(({ posts: data, total: t }) => { - setPosts(data); - setTotal(t); - }) - .catch((err) => setError(err.message)) - .finally(() => setLoading(false)); - }, [activeCategory, page]); - - const totalPages = Math.ceil(total / limit); - const featured = posts.find((p) => p.featured); - const regularPosts = featured ? posts.filter((p) => p.id !== featured.id) : posts; +export default async function BlogPage() { + const [{ posts, total }, categories] = await Promise.all([ + fetchJson<{ posts: BlogPost[]; total: number }>(`/posts?page=1&limit=${LIMIT}`, { + posts: [], + total: 0, + }), + fetchJson("/categories", []), + ]); return ( <> +
    @@ -121,160 +57,12 @@ export default function BlogPage() {
    -
    -
    - - {categories.map((cat) => ( - - ))} -
    -
    - -
    - {error && ( -
    - Failed to load posts: {error} -
    - )} - - {loading ? ( - <> - -
    - {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
    - - ) : posts.length === 0 ? ( -
    -

    - No posts yet -

    -

    - Check back soon for curated Bitcoin content. -

    -
    - ) : ( - <> - {featured && page === 1 && ( - -
    - - Featured - -

    - {featured.title} -

    - {featured.excerpt && ( -

    - {featured.excerpt} -

    - )} -
    - {featured.author && {featured.author}} - {featured.publishedAt && ( - {formatDate(featured.publishedAt)} - )} -
    -
    - - )} - -
    - {regularPosts.map((post) => ( - - {post.categories && post.categories.length > 0 && ( -
    - {post.categories.map((pc) => ( - - {pc.category.name} - - ))} -
    - )} - -

    - {post.title} -

    - - {post.excerpt && ( -

    - {post.excerpt} -

    - )} - -
    -
    - {post.author && {post.author}} - {post.author && (post.publishedAt || post.createdAt) && ·} - {(post.publishedAt || post.createdAt) && ( - - {formatDate(post.publishedAt || post.createdAt!)} - - )} -
    - - Read - -
    - - ))} -
    - - {totalPages > 1 && ( -
    - - - Page {page} of {totalPages} - - -
    - )} - - )} -
    +