diff --git a/backend/src/api/posts.ts b/backend/src/api/posts.ts index 6bc6391..79cbef0 100644 --- a/backend/src/api/posts.ts +++ b/backend/src/api/posts.ts @@ -35,6 +35,55 @@ async function canViewHiddenPosts(req: Request): Promise { 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; @@ -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 } }), ]); diff --git a/backend/src/services/nostr.ts b/backend/src/services/nostr.ts index 17c627d..2f2d984 100644 --- a/backend/src/services/nostr.ts +++ b/backend/src/services/nostr.ts @@ -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 []; diff --git a/frontend/app/blog.md/route.ts b/frontend/app/blog.md/route.ts index 6dbbab5..0605ba8 100644 --- a/frontend/app/blog.md/route.ts +++ b/frontend/app/blog.md/route.ts @@ -1,6 +1,6 @@ import { buildBlogMarkdown } from "@/lib/llms"; -export const revalidate = 300; +export const dynamic = "force-dynamic"; export async function GET() { const body = await buildBlogMarkdown(); 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 }) => ( - - ), - 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 847c3a0..9657219 100644 --- a/frontend/app/blog/page.tsx +++ b/frontend/app/blog/page.tsx @@ -10,9 +10,12 @@ import { apiUrl } from "@/lib/api-base"; 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), { next: { revalidate: 300 } }); + const res = await fetch(apiUrl(path), { cache: "no-store" }); if (!res.ok) return fallback; return (await res.json()) as T; } catch { diff --git a/frontend/app/community.md/route.ts b/frontend/app/community.md/route.ts index b99b69c..9597ee5 100644 --- a/frontend/app/community.md/route.ts +++ b/frontend/app/community.md/route.ts @@ -1,6 +1,6 @@ import { buildCommunityMarkdown } from "@/lib/llms"; -export const revalidate = 300; +export const dynamic = "force-dynamic"; export async function GET() { const body = await buildCommunityMarkdown(); diff --git a/frontend/app/contact.md/route.ts b/frontend/app/contact.md/route.ts index d094195..a6464d2 100644 --- a/frontend/app/contact.md/route.ts +++ b/frontend/app/contact.md/route.ts @@ -1,6 +1,6 @@ import { buildContactMarkdown } from "@/lib/llms"; -export const revalidate = 300; +export const dynamic = "force-dynamic"; export async function GET() { const body = await buildContactMarkdown(); diff --git a/frontend/app/events.md/route.ts b/frontend/app/events.md/route.ts index b99308a..702c068 100644 --- a/frontend/app/events.md/route.ts +++ b/frontend/app/events.md/route.ts @@ -1,6 +1,6 @@ import { buildEventsMarkdown } from "@/lib/llms"; -export const revalidate = 300; +export const dynamic = "force-dynamic"; export async function GET() { const body = await buildEventsMarkdown(); diff --git a/frontend/app/events/page.tsx b/frontend/app/events/page.tsx index d4e3be2..c2ffd8a 100644 --- a/frontend/app/events/page.tsx +++ b/frontend/app/events/page.tsx @@ -2,39 +2,15 @@ import { Navbar } from "@/components/public/Navbar"; import { Footer } from "@/components/public/Footer"; import { MeetupCard } from "@/components/public/MeetupCard"; import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog"; -import { getMeetupStartUtc } from "@/lib/meetupEventTime"; -import { apiUrl } from "@/lib/api-base"; +import { fetchMeetupsLive, partitionMeetups } from "@/lib/meetupsData"; -// Re-render at most every 5 minutes so the upcoming/past split stays current. -export const revalidate = 300; - -async function fetchMeetups(): Promise { - try { - const res = await fetch(apiUrl("/meetups"), { next: { revalidate: 300 } }); - if (!res.ok) return []; - const data = await res.json(); - return Array.isArray(data) ? data : []; - } catch { - return []; - } -} +// Render at request time from the live backend so crawlers (no JS) always get +// the real list, and the count stays in lock-step with /events.md and /llms.txt. +export const dynamic = "force-dynamic"; export default async function EventsPage() { - const meetups = await fetchMeetups(); - - const now = new Date(); - const upcoming = meetups.filter((m) => { - const start = getMeetupStartUtc(m.date, m.time || "00:00"); - if (Number.isNaN(start.getTime())) return false; - return start >= now; - }); - const past = meetups - .filter((m) => { - const start = getMeetupStartUtc(m.date, m.time || "00:00"); - if (Number.isNaN(start.getTime())) return false; - return start < now; - }) - .reverse(); + const meetups = await fetchMeetupsLive(); + const { upcoming, past } = partitionMeetups(meetups); return ( <> @@ -54,7 +30,11 @@ export default async function EventsPage() {
    -
    +

    diff --git a/frontend/app/faq.md/route.ts b/frontend/app/faq.md/route.ts index 599a6e9..26c92aa 100644 --- a/frontend/app/faq.md/route.ts +++ b/frontend/app/faq.md/route.ts @@ -1,6 +1,6 @@ import { buildFaqMarkdown } from "@/lib/llms"; -export const revalidate = 300; +export const dynamic = "force-dynamic"; export async function GET() { const body = await buildFaqMarkdown(); diff --git a/frontend/app/faq/page.tsx b/frontend/app/faq/page.tsx index e528b5e..f50a550 100644 --- a/frontend/app/faq/page.tsx +++ b/frontend/app/faq/page.tsx @@ -12,11 +12,13 @@ interface FaqItem { showOnHomepage: boolean; } +// Render at request time from the live backend so the Q&A and FAQPage JSON-LD +// are always present in the HTML for crawlers, never a build-time-empty shell. +export const dynamic = "force-dynamic"; + async function fetchFaqs(): Promise { try { - const res = await fetch(apiUrl("/faqs?all=true"), { - next: { revalidate: 300 }, - }); + const res = await fetch(apiUrl("/faqs?all=true"), { cache: "no-store" }); if (!res.ok) return []; const data = await res.json(); return Array.isArray(data) ? data : []; diff --git a/frontend/app/index.html.md/route.ts b/frontend/app/index.html.md/route.ts index 028d9c8..620db52 100644 --- a/frontend/app/index.html.md/route.ts +++ b/frontend/app/index.html.md/route.ts @@ -1,6 +1,6 @@ import { buildHomeMarkdown } from "@/lib/llms"; -export const revalidate = 300; +export const dynamic = "force-dynamic"; export async function GET() { const body = await buildHomeMarkdown(); diff --git a/frontend/app/llms.txt/route.ts b/frontend/app/llms.txt/route.ts index 799d8e9..ae7243f 100644 --- a/frontend/app/llms.txt/route.ts +++ b/frontend/app/llms.txt/route.ts @@ -1,6 +1,6 @@ import { buildLlmsTxt } from "@/lib/llms"; -export const revalidate = 3600; +export const dynamic = "force-dynamic"; export async function GET() { const body = await buildLlmsTxt(); diff --git a/frontend/lib/browserCache.ts b/frontend/lib/browserCache.ts new file mode 100644 index 0000000..550c173 --- /dev/null +++ b/frontend/lib/browserCache.ts @@ -0,0 +1,52 @@ +// Lightweight TTL cache backed by localStorage so resolved Nostr data (author +// profiles, long-form events) survives page reloads and navigation instead of +// being re-queried from relays every visit. No-ops on the server and degrades +// silently on quota/parse errors so it can never break rendering. + +const PREFIX = "bbe:nostr:"; + +interface Entry { + v: T; + t: number; // stored-at epoch ms +} + +export function readCache(key: string, ttlMs: number): T | null { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(PREFIX + key); + if (!raw) return null; + const entry = JSON.parse(raw) as Entry; + if (Date.now() - entry.t > ttlMs) { + window.localStorage.removeItem(PREFIX + key); + return null; + } + return entry.v; + } catch { + return null; + } +} + +export function writeCache(key: string, value: T): void { + if (typeof window === "undefined") return; + const payload = JSON.stringify({ v: value, t: Date.now() } satisfies Entry); + try { + window.localStorage.setItem(PREFIX + key, payload); + } catch { + // Quota exceeded (or similar): evict our namespace and retry once. + try { + pruneNamespace(); + window.localStorage.setItem(PREFIX + key, payload); + } catch { + // Give up silently — caching is best-effort. + } + } +} + +// Removes every entry written by this cache to recover space when localStorage +// is full. Only touches our own namespace. +function pruneNamespace(): void { + for (let i = window.localStorage.length - 1; i >= 0; i--) { + const k = window.localStorage.key(i); + if (k && k.startsWith(PREFIX)) window.localStorage.removeItem(k); + } +} diff --git a/frontend/lib/llms.ts b/frontend/lib/llms.ts index 176b873..7a40289 100644 --- a/frontend/lib/llms.ts +++ b/frontend/lib/llms.ts @@ -7,14 +7,17 @@ * reading these needs the actual content, not repeated chrome. */ import { apiUrl } from "./api-base"; -import { formatMeetupCivilDateLong, getMeetupStartUtc } from "./meetupEventTime"; +import { formatMeetupCivilDateLong } from "./meetupEventTime"; +import { fetchMeetupsLive, partitionMeetups, countUpcoming } from "./meetupsData"; export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org"; +// Live (uncached) so the mirrors always reflect the same backend state as the +// rendered pages — no ISR drift between /events, /events.md, and /llms.txt. async function fetchJson(path: string, fallback: T): Promise { try { - const res = await fetch(apiUrl(path), { next: { revalidate: 300 } }); + const res = await fetch(apiUrl(path), { cache: "no-store" }); if (!res.ok) return fallback; return (await res.json()) as T; } catch { @@ -85,14 +88,10 @@ function configuredChannels(settings: Record) { export async function buildLlmsTxt(): Promise { const [settings, meetups] = await Promise.all([ fetchJson>("/settings/public", {}), - fetchJson("/meetups", []), + fetchMeetupsLive(), ]); - const now = new Date(); - const upcomingCount = (Array.isArray(meetups) ? meetups : []).filter((m) => { - const start = getMeetupStartUtc(m.date, m.time || "00:00"); - return !Number.isNaN(start.getTime()) && start >= now; - }).length; + const upcomingCount = countUpcoming(meetups); const channels = configuredChannels(settings); const channelNames = channels.map((c) => c.name).join(", "); @@ -101,12 +100,14 @@ export async function buildLlmsTxt(): Promise { ? `How to connect on ${channelNames}` : "How to connect with the community"; + // Always state the count (including zero) so it stays machine-parseable and + // verifiably consistent with /events and /events.md. const upcomingLine = upcomingCount > 0 ? `There ${upcomingCount === 1 ? "is" : "are"} currently ${upcomingCount} upcoming meetup${ upcomingCount === 1 ? "" : "s" } scheduled.` - : "Meetups run monthly; check the events page for the next date."; + : "There are currently 0 upcoming meetups scheduled; the community meets monthly."; return `# Belgian Bitcoin Embassy @@ -135,19 +136,8 @@ Belgian Bitcoin Embassy is a volunteer-run network, not a business. Content here // --------------------------------------------------------------------------- export async function buildHomeMarkdown(): Promise { - const meetups = await fetchJson("/meetups", []); - const now = new Date(); - const next = (Array.isArray(meetups) ? meetups : []) - .filter((m) => { - if (m.status && m.status !== "PUBLISHED") return false; - const start = getMeetupStartUtc(m.date, m.time || "00:00"); - return !Number.isNaN(start.getTime()) && start >= now; - }) - .sort( - (a, b) => - getMeetupStartUtc(a.date, a.time || "00:00").getTime() - - getMeetupStartUtc(b.date, b.time || "00:00").getTime(), - )[0]; + const meetups = await fetchMeetupsLive(); + const next = partitionMeetups(meetups).upcoming[0]; let nextSection = ""; if (next) { @@ -182,20 +172,8 @@ We help people in Belgium understand and adopt Bitcoin through education, meetup } export async function buildEventsMarkdown(): Promise { - const meetups = await fetchJson("/meetups", []); - const list = Array.isArray(meetups) ? meetups : []; - const now = new Date(); - - const upcoming = list.filter((m) => { - const start = getMeetupStartUtc(m.date, m.time || "00:00"); - return !Number.isNaN(start.getTime()) && start >= now; - }); - const past = list - .filter((m) => { - const start = getMeetupStartUtc(m.date, m.time || "00:00"); - return !Number.isNaN(start.getTime()) && start < now; - }) - .reverse(); + const meetups = await fetchMeetupsLive(); + const { upcoming, past } = partitionMeetups(meetups); const renderMeetup = (m: any): string => { const when = formatMeetupCivilDateLong(m.date); diff --git a/frontend/lib/meetupsData.ts b/frontend/lib/meetupsData.ts new file mode 100644 index 0000000..41da26a --- /dev/null +++ b/frontend/lib/meetupsData.ts @@ -0,0 +1,71 @@ +/** + * Single source of truth for meetup data used by the public /events page, the + * /events.md mirror, and the /llms.txt summary line. Centralizing the fetch + + * upcoming/past partition guarantees those three can never disagree on the count. + * + * Fetched with `no-store` so every render reflects the live backend — this is + * what keeps crawlers (which don't run JS) and llms.txt consumers from seeing a + * stale or build-time-empty list. + */ +import { apiUrl } from "./api-base"; +import { getMeetupStartUtc } from "./meetupEventTime"; + +export interface Meetup { + id: string; + title: string; + date: string; + time?: string; + location?: string; + description?: string; + status?: string; + organizer?: { name?: string; slug?: string } | null; + [key: string]: unknown; +} + +/** Fetch all publicly-visible meetups from the backend, live (uncached). */ +export async function fetchMeetupsLive(): Promise { + try { + const res = await fetch(apiUrl("/meetups"), { cache: "no-store" }); + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data) ? (data as Meetup[]) : []; + } catch { + return []; + } +} + +export interface PartitionedMeetups { + upcoming: Meetup[]; + past: Meetup[]; +} + +/** + * Split meetups into upcoming (start >= now, soonest first) and past (start < + * now, most recent first). Meetups with an unparseable date are dropped. + */ +export function partitionMeetups( + meetups: Meetup[], + now: Date = new Date(), +): PartitionedMeetups { + const upcoming: Meetup[] = []; + const past: Meetup[] = []; + + for (const m of meetups) { + const start = getMeetupStartUtc(m.date, m.time || "00:00"); + if (Number.isNaN(start.getTime())) continue; + if (start >= now) upcoming.push(m); + else past.push(m); + } + + const startMs = (m: Meetup) => + getMeetupStartUtc(m.date, m.time || "00:00").getTime(); + upcoming.sort((a, b) => startMs(a) - startMs(b)); + past.sort((a, b) => startMs(b) - startMs(a)); + + return { upcoming, past }; +} + +/** Number of upcoming meetups — the single value llms.txt and events.md share. */ +export function countUpcoming(meetups: Meetup[], now: Date = new Date()): number { + return partitionMeetups(meetups, now).upcoming.length; +} diff --git a/frontend/lib/nostr.ts b/frontend/lib/nostr.ts index fd84069..65a83f6 100644 --- a/frontend/lib/nostr.ts +++ b/frontend/lib/nostr.ts @@ -1,5 +1,14 @@ import { generateSecretKey, getPublicKey as getPubKeyFromSecret } from "nostr-tools/pure"; import { nip19 } from "nostr-tools"; +import { readCache, writeCache } from "./browserCache"; + +// Persistent (localStorage) cache lifetimes. Longer than the in-memory TTLs: +// these survive reloads, where the whole point is to avoid re-querying relays. +// Profiles and immutable events change rarely; addressable (replaceable) +// long-form events use a shorter window so edits are picked up reasonably soon. +const PROFILE_PERSIST_TTL = 6 * 60 * 60 * 1000; // 6 hours +const EVENT_PERSIST_TTL = 6 * 60 * 60 * 1000; // 6 hours (events by id are immutable) +const LONGFORM_PERSIST_TTL = 30 * 60 * 1000; // 30 minutes (replaceable) // Relays return events keyed by hex pubkeys and only accept hex in `authors` // filters. Pubkeys may be stored/passed as npub (or nprofile), so normalize. @@ -87,6 +96,72 @@ const FALLBACK_RELAYS = [ "wss://relay.nostr.band", ]; +// Only secure WebSocket (wss://) relays may be dialed: the site is served over +// HTTPS, so ws:// endpoints (e.g. ws://umbrel.local) are blocked as mixed +// content and must never be connected to. Relay hints reach us from untrusted +// sources (naddr hints, NIP-65 lists, user/extension relay configs), so filter +// at every connection point. +export function isSecureRelay(url: unknown): url is string { + return typeof url === "string" && /^wss:\/\//i.test(url.trim()); +} + +export function filterSecureRelays(urls: Iterable): string[] { + const out: string[] = []; + for (const url of urls) { + if (isSecureRelay(url)) out.push(url.trim()); + } + return [...new Set(out)]; +} + +// How long a single relay query may run before we give up on it. Relays go +// dead or stall mid-subscription without ever sending EOSE, which is what makes +// blog loads occasionally hang; bounding every query keeps the page responsive. +const RELAY_MAX_WAIT = 5000; // ms, passed to nostr-tools as `maxWait` +const RELAY_HARD_TIMEOUT = 8000; // ms, absolute ceiling incl. connection setup + +// A single shared read pool for the whole app. Reusing one pool keeps relay +// websockets warm and—crucially—avoids opening several simultaneous +// connections to the same relays (author profile + article body + NIP-65 all at +// once on a blog open), which relays throttle/drop and which caused the +// intermittent "couldn't fetch" failure on first load. Never closed: the pool +// manages and reuses its own connections for the SPA's lifetime. +let _readPool: any = null; +async function getReadPool(): Promise { + if (!_readPool) { + const { SimplePool } = await import("nostr-tools/pool"); + _readPool = new SimplePool(); + } + return _readPool; +} + +// Resolves to `fallback` if `promise` hasn't settled within `ms`. Used as a +// hard ceiling around relay queries so a connection that never opens (or never +// closes) can't block rendering. +function withTimeout(promise: Promise, ms: number, fallback: T): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(fallback), ms); + promise.then( + (v) => { clearTimeout(timer); resolve(v); }, + () => { clearTimeout(timer); resolve(fallback); }, + ); + }); +} + +// A single bounded query across `relays` (which are dialed in parallel by the +// pool). Returns null on timeout, empty relay set, or error. +async function getEventBounded( + pool: { get: (relays: string[], filter: any, params?: { maxWait?: number }) => Promise }, + relays: string[], + filter: any, +): Promise { + if (relays.length === 0) return null; + return withTimeout( + pool.get(relays, filter, { maxWait: RELAY_MAX_WAIT }), + RELAY_HARD_TIMEOUT, + null, + ); +} + let _siteRelaysCache: { relays: string[]; fetchedAt: number } | null = null; const SITE_RELAY_TTL = 5 * 60 * 1000; // 5 minutes @@ -98,9 +173,10 @@ export async function getSiteRelays(): Promise { const res = await fetch("/api/relays/public"); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); - if (Array.isArray(data.relays) && data.relays.length > 0) { - _siteRelaysCache = { relays: data.relays, fetchedAt: Date.now() }; - return data.relays; + const secure = Array.isArray(data.relays) ? filterSecureRelays(data.relays) : []; + if (secure.length > 0) { + _siteRelaysCache = { relays: secure, fetchedAt: Date.now() }; + return secure; } } catch {} return FALLBACK_RELAYS; @@ -119,12 +195,11 @@ export async function fetchNip65RelayList(pubkey: string): Promise isSecureRelay(r?.url)); + _userRelaysCache = { relays: secure, fetchedAt: Date.now() }; + return secure; } } catch {} return []; @@ -217,7 +292,7 @@ export async function publishEvent(signedEvent: any): Promise { } } catch {} - const relayUrls = [...allRelays]; + const relayUrls = filterSecureRelays(allRelays); const pool = new SimplePool(); try { await Promise.allSettled(pool.publish(relayUrls, signedEvent)); @@ -233,30 +308,40 @@ export async function fetchNostrProfile( const hex = toHexPubkey(pubkey); if (!hex) return {}; - const { SimplePool } = await import("nostr-tools/pool"); - const allRelays = new Set(relayUrls || await getSiteRelays()); - PROFILE_METADATA_RELAYS.forEach((url) => allRelays.add(url)); + const cached = readProfileCache(hex, Date.now()); + if (cached) return cached; + const filter = { kinds: [0], authors: [hex] }; + + // Overlap the NIP-65 lookup with the first query rather than waiting on it. + const nip65Promise = fetchNip65RelayList(hex).catch( + () => ({ write: [], read: [], all: [] }) as Nip65RelayList, + ); + + const pool = await getReadPool(); + const tried = new Set(); try { - const nip65 = await fetchNip65RelayList(hex); - nip65.write.forEach((url) => allRelays.add(url)); - } catch {} + // Phase 1: provided relays (or site relays) + profile aggregators. + const phase1 = filterSecureRelays([ + ...(relayUrls || await getSiteRelays()), + ...PROFILE_METADATA_RELAYS, + ]); + phase1.forEach((u) => tried.add(u)); + let event = await getEventBounded(pool, phase1, filter); - const urls = [...allRelays]; - const pool = new SimplePool(); - - try { - const event = await pool.get(urls, { - kinds: [0], - authors: [hex], - }); + // Phase 2: author's NIP-65 write relays if not found yet. + if (!event?.content) { + const nip65 = await nip65Promise; + const phase2 = filterSecureRelays(nip65.write).filter((u) => !tried.has(u)); + if (phase2.length > 0) event = await getEventBounded(pool, phase2, filter); + } if (!event?.content) return {}; - return parseProfileContent(event.content); + const profile = parseProfileContent(event.content); + writeProfileCache(hex, profile, Date.now()); + return profile; } catch { return {}; - } finally { - pool.close(urls); } } @@ -281,13 +366,25 @@ function isProfileEmpty(p: NostrProfile): boolean { function readProfileCache(pubkey: string, now: number): NostrProfile | null { const cached = _profileCache.get(pubkey); - if (!cached) return null; - const ttl = cached.empty ? PROFILE_EMPTY_TTL : PROFILE_TTL; - return now - cached.fetchedAt < ttl ? cached.profile : null; + if (cached) { + const ttl = cached.empty ? PROFILE_EMPTY_TTL : PROFILE_TTL; + if (now - cached.fetchedAt < ttl) return cached.profile; + } + // Fall back to the persistent browser cache (survives reloads/navigation). + // Only resolved profiles are persisted, so a hit here is always non-empty. + const persisted = readCache(`profile:${pubkey}`, PROFILE_PERSIST_TTL); + if (persisted) { + _profileCache.set(pubkey, { profile: persisted, fetchedAt: now, empty: false }); + return persisted; + } + return null; } function writeProfileCache(pubkey: string, profile: NostrProfile, now: number): void { - _profileCache.set(pubkey, { profile, fetchedAt: now, empty: isProfileEmpty(profile) }); + const empty = isProfileEmpty(profile); + _profileCache.set(pubkey, { profile, fetchedAt: now, empty }); + // Persist resolved profiles only; skip empties so a reload can retry the miss. + if (!empty) writeCache(`profile:${pubkey}`, profile); } // Batched profile fetch. Resolves kind:0 metadata for many pubkeys using a @@ -321,10 +418,9 @@ export async function fetchNostrProfiles( if (hexByKey.size === 0) return result; - const { SimplePool } = await import("nostr-tools/pool"); const base = relayUrls && relayUrls.length > 0 ? relayUrls : await getSiteRelays(); - const urls = [...new Set([...base, ...PROFILE_METADATA_RELAYS])]; - const pool = new SimplePool(); + const urls = filterSecureRelays([...base, ...PROFILE_METADATA_RELAYS]); + const pool = await getReadPool(); try { const authors = [...new Set(hexByKey.values())]; @@ -363,8 +459,6 @@ export async function fetchNostrProfiles( if (!(key in result)) result[key] = {}; } return result; - } finally { - pool.close(urls); } } @@ -417,22 +511,27 @@ export function loadNostrProfile(pubkey: string): Promise { } export async function fetchEventFromRelays(eventId: string): Promise { - const { SimplePool } = await import("nostr-tools/pool"); + const cacheKey = `event:${eventId}`; + const cached = readCache(cacheKey, EVENT_PERSIST_TTL); + if (cached) return cached; + const siteRelays = await getSiteRelays(); - const pool = new SimplePool(); + const pool = await getReadPool(); try { - const event = await pool.get(siteRelays, { ids: [eventId] }); + const event = await getEventBounded(pool, siteRelays, { ids: [eventId] }); + if (event) writeCache(cacheKey, event); return event || null; } catch { return null; - } finally { - pool.close(siteRelays); } } export async function fetchLongformFromRelays(naddrStr: string): Promise { - const { SimplePool } = await import("nostr-tools/pool"); + const cacheKey = `longform:${naddrStr}`; + const cached = readCache(cacheKey, LONGFORM_PERSIST_TTL); + if (cached) return cached; + const { nip19 } = await import("nostr-tools"); let decoded: { kind: number; pubkey: string; identifier: string; relays?: string[] }; @@ -445,29 +544,65 @@ export async function fetchLongformFromRelays(naddrStr: string): Promise([...naddrRelays, ...siteRelays]); - - try { - const nip65 = await fetchNip65RelayList(decoded.pubkey); - nip65.write.forEach((url) => allRelays.add(url)); - } catch {} - - const relayUrls = [...allRelays]; + const naddrRelays = filterSecureRelays(decoded.relays || []); const filter = { kinds: [decoded.kind], authors: [decoded.pubkey], "#d": [decoded.identifier], }; - const pool = new SimplePool(); + // Kick off the author's NIP-65 lookup immediately so it overlaps the first + // query instead of adding a serial round-trip before it. + const nip65Promise = fetchNip65RelayList(decoded.pubkey).catch( + () => ({ write: [], read: [], all: [] }) as Nip65RelayList, + ); + + const pool = await getReadPool(); + const tried = new Set(); + // Phase 1: naddr relay hints + site relays, all dialed in parallel. The + // hints usually point at the author's own relay, so this is the fast path. + const phase1 = filterSecureRelays([...naddrRelays, ...siteRelays]); + phase1.forEach((u) => tried.add(u)); + let event = await getEventBounded(pool, phase1, filter); + + // Phase 2: fall back to the author's NIP-65 write relays only if needed. + if (!event) { + const nip65 = await nip65Promise; + const phase2 = filterSecureRelays(nip65.write).filter((u) => !tried.has(u)); + if (phase2.length > 0) event = await getEventBounded(pool, phase2, filter); + } + + if (event) writeCache(cacheKey, event); + return event || null; +} + +// Resolves any NIP-19 reference (naddr / nevent / note) or a raw 64-char hex +// event id to its underlying Nostr event by querying relays. Lets the blog page +// render a long-form note straight from a shared link even when it was never +// indexed by the backend. Returns null if it can't be decoded or found. +export async function resolveEventFromRelays(identifier: string): Promise { + const trimmed = identifier.trim(); + + if (/^[0-9a-f]{64}$/i.test(trimmed)) { + return fetchEventFromRelays(trimmed.toLowerCase()); + } + + let decoded; try { - const event = await pool.get(relayUrls, filter); - return event || null; + decoded = nip19.decode(trimmed); } catch { return null; - } finally { - pool.close(relayUrls); + } + + switch (decoded.type) { + case "naddr": + return fetchLongformFromRelays(trimmed); + case "nevent": + return fetchEventFromRelays((decoded.data as { id: string }).id); + case "note": + return fetchEventFromRelays(decoded.data as string); + default: + return null; } } diff --git a/frontend/package.json b/frontend/package.json index 4fb60c0..7caff2f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "check:consistency": "node scripts/check-consistency.mjs" }, "dependencies": { "class-variance-authority": "^0.7.0", diff --git a/frontend/scripts/check-consistency.mjs b/frontend/scripts/check-consistency.mjs new file mode 100644 index 0000000..f49cd42 --- /dev/null +++ b/frontend/scripts/check-consistency.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * Consistency check: confirms the number of upcoming meetups reported by the + * live /events page, the /events.md mirror, and the /llms.txt summary all match. + * + * Catches the exact "confidently wrong" drift this check exists to prevent: + * a mirror or summary going stale relative to the rendered page. + * + * Usage: + * node scripts/check-consistency.mjs [baseUrl] + * BASE_URL=https://belgianbitcoinembassy.org node scripts/check-consistency.mjs + * + * Exits 0 if all three agree, 1 on any mismatch or fetch failure. + */ + +const baseUrl = ( + process.argv[2] || + process.env.BASE_URL || + "http://localhost:3000" +).replace(/\/$/, ""); + +async function getText(path) { + const url = `${baseUrl}${path}`; + const res = await fetch(url, { + headers: { "User-Agent": "bbe-consistency-check" }, + }); + if (!res.ok) { + throw new Error(`GET ${url} -> ${res.status}`); + } + return res.text(); +} + +/** /llms.txt: "There are currently N upcoming meetup(s) scheduled." */ +function parseLlmsTxt(text) { + const m = text.match(/currently\s+(\d+)\s+upcoming meetup/i); + if (!m) throw new Error("llms.txt: could not find upcoming-meetup count line"); + return Number(m[1]); +} + +/** /events.md: count "### " headings within the "## Upcoming" section. */ +function parseEventsMd(text) { + const lines = text.split("\n"); + let inUpcoming = false; + let count = 0; + let sawUpcoming = false; + for (const line of lines) { + if (/^##\s+Upcoming\b/.test(line)) { + inUpcoming = true; + sawUpcoming = true; + continue; + } + if (/^##\s+/.test(line) && inUpcoming) inUpcoming = false; // next section + if (inUpcoming && /^###\s+/.test(line)) count += 1; + } + if (!sawUpcoming) throw new Error("events.md: no '## Upcoming' section found"); + return count; +} + +/** /events: data-upcoming-count attribute rendered into the HTML. */ +function parseEventsHtml(text) { + const m = text.match(/data-upcoming-count="(\d+)"/); + if (!m) throw new Error("events: data-upcoming-count attribute not found in HTML"); + return Number(m[1]); +} + +async function main() { + const [llmsTxt, eventsMd, eventsHtml] = await Promise.all([ + getText("/llms.txt"), + getText("/events.md"), + getText("/events"), + ]); + + const counts = { + "/llms.txt": parseLlmsTxt(llmsTxt), + "/events.md": parseEventsMd(eventsMd), + "/events": parseEventsHtml(eventsHtml), + }; + + const values = Object.values(counts); + const allMatch = values.every((v) => v === values[0]); + + console.log(`Consistency check against ${baseUrl}`); + for (const [name, value] of Object.entries(counts)) { + console.log(` ${name.padEnd(12)} upcoming = ${value}`); + } + + if (!allMatch) { + console.error( + `\n✗ MISMATCH: upcoming-meetup counts disagree (${values.join(", ")}).`, + ); + process.exit(1); + } + + console.log(`\n✓ All sources agree: ${values[0]} upcoming meetup(s).`); +} + +main().catch((err) => { + console.error(`✗ Consistency check failed: ${err.message}`); + process.exit(1); +});