diff --git a/api/article-og-refresh.ts b/api/article-og-refresh.ts new file mode 100644 index 00000000..beb03d42 --- /dev/null +++ b/api/article-og-refresh.ts @@ -0,0 +1,35 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import { setArticleMeta } from '../src/services/ogStore' +import { fetchArticleMetadataViaRelays } from '../src/services/articleMeta' + +export default async function handler(req: VercelRequest, res: VercelResponse) { + // Validate refresh secret + const providedSecret = req.headers['x-refresh-key'] + const expectedSecret = process.env.OG_REFRESH_SECRET || '' + + if (providedSecret !== expectedSecret) { + return res.status(401).json({ error: 'Unauthorized' }) + } + + const naddr = (req.query.naddr as string | undefined)?.trim() + if (!naddr) { + return res.status(400).json({ error: 'Missing naddr parameter' }) + } + + try { + // Fetch metadata via relays (WebSockets) + const meta = await fetchArticleMetadataViaRelays(naddr) + + if (meta) { + // Store in Redis + await setArticleMeta(naddr, meta) + return res.status(200).json({ ok: true, cached: true }) + } else { + return res.status(200).json({ ok: true, cached: false }) + } + } catch (err) { + console.error('Error refreshing article metadata:', err) + return res.status(500).json({ error: 'Internal server error' }) + } +} + diff --git a/api/article-og.ts b/api/article-og.ts index 2dc01eb6..a9d96675 100644 --- a/api/article-og.ts +++ b/api/article-og.ts @@ -1,213 +1,13 @@ import type { VercelRequest, VercelResponse } from '@vercel/node' -import WebSocket from 'ws' -;(globalThis as any).WebSocket ??= WebSocket as any -import { RelayPool } from 'applesauce-relay' -import { nip19 } from 'nostr-tools' -import { AddressPointer } from 'nostr-tools/nip19' -import { NostrEvent, Filter } from 'nostr-tools' -import { Helpers } from 'applesauce-core' -import { extractProfileDisplayName } from '../lib/profile' - -const { getArticleTitle, getArticleImage, getArticleSummary } = Helpers - -// Relay configuration (from src/config/relays.ts) -const RELAYS = [ - 'wss://relay.damus.io', - 'wss://nos.lol', - 'wss://relay.nostr.band', - 'wss://relay.dergigi.com', - 'wss://wot.dergigi.com', - 'wss://relay.snort.social', - 'wss://nostr-pub.wellorder.net', - 'wss://purplepag.es', - 'wss://relay.primal.net' -] - -type CacheEntry = { - html: string - expires: number -} - -const WEEK_MS = 7 * 24 * 60 * 60 * 1000 -const memoryCache = new Map() - - - -function escapeHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} +import { getArticleMeta, setArticleMeta } from '../src/services/ogStore' +import { fetchArticleMetadataViaGateway } from '../src/services/articleMeta' +import { generateHtml } from '../src/services/ogHtml' function setCacheHeaders(res: VercelResponse, maxAge: number = 86400): void { res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`) res.setHeader('Content-Type', 'text/html; charset=utf-8') } -interface ArticleMetadata { - title: string - summary: string - image: string - author: string - published?: number -} - -async function fetchEventsFromRelays( - relayPool: RelayPool, - relayUrls: string[], - filter: Filter, - timeoutMs: number -): Promise { - const events: NostrEvent[] = [] - - await new Promise((resolve) => { - const timeout = setTimeout(() => resolve(), timeoutMs) - - // `request` emits NostrEvent objects directly - relayPool.request(relayUrls, filter).subscribe({ - next: (event) => { - events.push(event) - }, - error: () => resolve(), - complete: () => { - clearTimeout(timeout) - resolve() - } - }) - }) - - // Sort by created_at and return most recent first - return events.sort((a, b) => b.created_at - a.created_at) -} - -async function fetchArticleMetadata(naddr: string): Promise { - const relayPool = new RelayPool() - - try { - // Decode naddr - const decoded = nip19.decode(naddr) - if (decoded.type !== 'naddr') { - return null - } - - const pointer = decoded.data as AddressPointer - - // Determine relay URLs - const relayUrls = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS - - // Fetch article and profile in parallel - const [articleEvents, profileEvents] = await Promise.all([ - fetchEventsFromRelays(relayPool, relayUrls, { - kinds: [pointer.kind], - authors: [pointer.pubkey], - '#d': [pointer.identifier || ''] - }, 7000), - fetchEventsFromRelays(relayPool, relayUrls, { - kinds: [0], - authors: [pointer.pubkey] - }, 5000) - ]) - - if (articleEvents.length === 0) { - return null - } - - const article = articleEvents[0] - - // Extract article metadata - const title = getArticleTitle(article) || 'Untitled Article' - const summary = getArticleSummary(article) || 'Read this article on Boris' - const image = getArticleImage(article) || '/boris-social-1200.png' - - // Extract author name from profile using centralized utility - let authorName = pointer.pubkey.slice(0, 8) + '...' - if (profileEvents.length > 0) { - const displayName = extractProfileDisplayName(profileEvents[0]) - if (displayName && !displayName.startsWith('@')) { - authorName = displayName - } else if (displayName) { - authorName = displayName.substring(1) // Remove @ prefix - } - } - - return { - title, - summary, - image, - author: authorName, - published: article.created_at - } - } catch (err) { - console.error('Failed to fetch article metadata:', err) - return null - } finally { - // No explicit close needed; pool manages connections internally - } -} - -function generateHtml(naddr: string, meta: ArticleMetadata | null): string { - const baseUrl = 'https://read.withboris.com' - const articleUrl = `${baseUrl}/a/${naddr}` - - const title = meta?.title || 'Boris – Read, Highlight, Explore' - const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.' - const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}` - const author = meta?.author || 'Boris' - - return ` - - - - - - - - - - - ${escapeHtml(title)} - - - - - - - - - - - ${meta?.published ? `` : ''} - - - - - - - - - - - - - -` -} - export default async function handler(req: VercelRequest, res: VercelResponse) { const naddr = (req.query.naddr as string | undefined)?.trim() @@ -220,43 +20,47 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { res.setHeader('X-Boris-Debug', '1') } - // Check cache for bots/crawlers - const now = Date.now() - const cached = memoryCache.get(naddr) - if (cached && cached.expires > now) { - setCacheHeaders(res) - if (debugEnabled) { - // Debug mode enabled + // Try Redis cache first + let meta = await getArticleMeta(naddr) + let cacheMaxAge = 86400 + + if (!meta) { + // Cache miss: try gateway (fast HTTP, no WebSockets) + meta = await fetchArticleMetadataViaGateway(naddr) + + if (meta) { + // Gateway found metadata: store it and use it + await setArticleMeta(naddr, meta).catch((err) => { + console.error('Failed to cache gateway metadata:', err) + }) + cacheMaxAge = 86400 + } else { + // Gateway failed: use default fallback + cacheMaxAge = 300 } - return res.status(200).send(cached.html) + + // Trigger background refresh (fire-and-forget) + const secret = process.env.OG_REFRESH_SECRET || '' + const origin = req.headers['x-forwarded-proto'] && req.headers['x-forwarded-host'] + ? `${req.headers['x-forwarded-proto']}://${req.headers['x-forwarded-host']}` + : `https://read.withboris.com` + + fetch(`${origin}/api/article-og-refresh?naddr=${encodeURIComponent(naddr)}`, { + method: 'POST', + headers: { 'x-refresh-key': secret }, + keepalive: true + }).catch(() => { + // Ignore errors in background refresh trigger + }) } - try { - // Fetch metadata - const meta = await fetchArticleMetadata(naddr) - - // Generate HTML - const html = generateHtml(naddr, meta) - - // Cache the result - memoryCache.set(naddr, { html, expires: now + WEEK_MS }) - - // Send response - setCacheHeaders(res) - if (debugEnabled) { - // Debug mode enabled - } - return res.status(200).send(html) - } catch (err) { - console.error('Error generating article OG HTML:', err) - - // Fallback to basic HTML with SPA boot - const html = generateHtml(naddr, null) - setCacheHeaders(res, 3600) - if (debugEnabled) { - // Debug mode enabled - } - return res.status(200).send(html) + // Generate and send HTML + const html = generateHtml(naddr, meta) + setCacheHeaders(res, cacheMaxAge) + + if (debugEnabled) { + // Debug mode enabled } + + return res.status(200).send(html) } - diff --git a/package-lock.json b/package-lock.json index fd0eddf5..2f15a304 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/react-fontawesome": "^3.0.2", "@treeee/youtube-caption-extractor": "^1.5.5", + "@upstash/redis": "^1.35.6", "@vercel/node": "^5.3.26", "applesauce-accounts": "^4.0.0", "applesauce-content": "^4.0.0", @@ -57,6 +58,9 @@ "vite": "^5.0.8", "vite-plugin-pwa": "^1.0.3", "workbox-window": "^7.3.0" + }, + "engines": { + "node": "22.x" } }, "node_modules/@alloc/quick-lru": { @@ -3804,6 +3808,15 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, + "node_modules/@upstash/redis": { + "version": "1.35.6", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.35.6.tgz", + "integrity": "sha512-aSEIGJgJ7XUfTYvhQcQbq835re7e/BXjs8Janq6Pvr6LlmTZnyqwT97RziZLO/8AVUL037RLXqqiQC6kCt+5pA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/@vercel/build-utils": { "version": "12.1.2", "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-12.1.2.tgz", @@ -11561,6 +11574,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici": { "version": "5.28.4", "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", diff --git a/package.json b/package.json index 003d7fc8..9a4d467e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/react-fontawesome": "^3.0.2", "@treeee/youtube-caption-extractor": "^1.5.5", + "@upstash/redis": "^1.35.6", "@vercel/node": "^5.3.26", "applesauce-accounts": "^4.0.0", "applesauce-content": "^4.0.0", diff --git a/src/services/articleMeta.ts b/src/services/articleMeta.ts new file mode 100644 index 00000000..da90c823 --- /dev/null +++ b/src/services/articleMeta.ts @@ -0,0 +1,134 @@ +import WebSocket from 'ws' +;(globalThis as any).WebSocket ??= WebSocket as any +import { RelayPool } from 'applesauce-relay' +import { nip19 } from 'nostr-tools' +import { AddressPointer } from 'nostr-tools/nip19' +import { NostrEvent, Filter } from 'nostr-tools' +import { Helpers } from 'applesauce-core' +import { extractProfileDisplayName } from '../lib/profile' +import { RELAYS } from '../config/relays' +import type { ArticleMetadata } from './ogStore' + +const { getArticleTitle, getArticleImage, getArticleSummary } = Helpers + +async function fetchEventsFromRelays( + relayPool: RelayPool, + relayUrls: string[], + filter: Filter, + timeoutMs: number +): Promise { + const events: NostrEvent[] = [] + + await new Promise((resolve) => { + const timeout = setTimeout(() => resolve(), timeoutMs) + + relayPool.request(relayUrls, filter).subscribe({ + next: (event) => { + events.push(event) + }, + error: () => resolve(), + complete: () => { + clearTimeout(timeout) + resolve() + } + }) + }) + + return events.sort((a, b) => b.created_at - a.created_at) +} + +export async function fetchArticleMetadataViaRelays(naddr: string): Promise { + const relayPool = new RelayPool() + + try { + const decoded = nip19.decode(naddr) + if (decoded.type !== 'naddr') { + return null + } + + const pointer = decoded.data as AddressPointer + const relayUrls = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS + + const [articleEvents, profileEvents] = await Promise.all([ + fetchEventsFromRelays(relayPool, relayUrls, { + kinds: [pointer.kind], + authors: [pointer.pubkey], + '#d': [pointer.identifier || ''] + }, 7000), + fetchEventsFromRelays(relayPool, relayUrls, { + kinds: [0], + authors: [pointer.pubkey] + }, 5000) + ]) + + if (articleEvents.length === 0) { + return null + } + + const article = articleEvents[0] + const title = getArticleTitle(article) || 'Untitled Article' + const summary = getArticleSummary(article) || 'Read this article on Boris' + const image = getArticleImage(article) || '/boris-social-1200.png' + + let authorName = pointer.pubkey.slice(0, 8) + '...' + if (profileEvents.length > 0) { + const displayName = extractProfileDisplayName(profileEvents[0]) + if (displayName && !displayName.startsWith('@')) { + authorName = displayName + } else if (displayName) { + authorName = displayName.substring(1) + } + } + + return { + title, + summary, + image, + author: authorName, + published: article.created_at + } + } catch (err) { + console.error('Failed to fetch article metadata via relays:', err) + return null + } +} + +export async function fetchArticleMetadataViaGateway(naddr: string): Promise { + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 2000) + + const resp = await fetch(`https://njump.to/${naddr}`, { + redirect: 'follow', + signal: controller.signal + }) + clearTimeout(timeout) + + if (!resp.ok) { + return null + } + + const html = await resp.text() + + const pick = (re: RegExp) => (html.match(re)?.[1] ?? '').trim() + const title = pick(/]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i) || + pick(/]*>([^<]+)<\/title>/i) + const summary = pick(/]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i) + const image = pick(/]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i) + + if (!title && !summary && !image) { + return null + } + + return { + title: title || 'Read on Boris', + summary: summary || 'Read this article on Boris', + image: image || '/boris-social-1200.png', + author: 'Boris' + } + } catch (err) { + console.error('Failed to fetch article metadata via gateway:', err) + return null + } +} + diff --git a/src/services/ogHtml.ts b/src/services/ogHtml.ts new file mode 100644 index 00000000..dfeba43f --- /dev/null +++ b/src/services/ogHtml.ts @@ -0,0 +1,71 @@ +import type { ArticleMetadata } from './ogStore' + +export function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +export function generateHtml(naddr: string, meta: ArticleMetadata | null): string { + const baseUrl = 'https://read.withboris.com' + const articleUrl = `${baseUrl}/a/${naddr}` + + const title = meta?.title || 'Boris – Read, Highlight, Explore' + const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.' + const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}` + const author = meta?.author || 'Boris' + + return ` + + + + + + + + + + + ${escapeHtml(title)} + + + + + + + + + + + ${meta?.published ? `` : ''} + + + + + + + + + + + + + +` +} + diff --git a/src/services/ogStore.ts b/src/services/ogStore.ts new file mode 100644 index 00000000..d57a021e --- /dev/null +++ b/src/services/ogStore.ts @@ -0,0 +1,25 @@ +import { Redis } from '@upstash/redis' + +const redisWrite = Redis.fromEnv() +const redisRead = process.env.KV_REST_API_READ_ONLY_TOKEN && process.env.KV_REST_API_URL + ? new Redis({ url: process.env.KV_REST_API_URL!, token: process.env.KV_REST_API_READ_ONLY_TOKEN! }) + : redisWrite + +const keyOf = (naddr: string) => `og:${naddr}` + +export type ArticleMetadata = { + title: string + summary: string + image: string + author: string + published?: number +} + +export async function getArticleMeta(naddr: string): Promise { + return (await redisRead.get(keyOf(naddr))) || null +} + +export async function setArticleMeta(naddr: string, meta: ArticleMetadata, ttlSec = 604800): Promise { + await redisWrite.set(keyOf(naddr), meta, { ex: ttlSec }) +} + diff --git a/vercel.json b/vercel.json index 9d044f6a..fb4b78a1 100644 --- a/vercel.json +++ b/vercel.json @@ -3,6 +3,9 @@ "functions": { "api/article-og.ts": { "maxDuration": 10 + }, + "api/article-og-refresh.ts": { + "maxDuration": 10 } }, "rewrites": [