diff --git a/api/article-og-refresh.ts b/api/article-og-refresh.ts new file mode 100644 index 00000000..c967dded --- /dev/null +++ b/api/article-og-refresh.ts @@ -0,0 +1,41 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import { setArticleMeta } from './services/ogStore.js' +import { fetchArticleMetadataViaRelays } from './services/articleMeta.js' + +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) { + console.error('Background refresh unauthorized: secret mismatch') + 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' }) + } + + console.log(`Background refresh started for ${naddr}`) + + try { + // Fetch metadata via relays (WebSockets) - no timeout, let it take as long as needed + const meta = await fetchArticleMetadataViaRelays(naddr) + + if (meta) { + console.log(`Background refresh found metadata for ${naddr}:`, { title: meta.title, summary: meta.summary?.substring(0, 50) }) + // Store in Redis + await setArticleMeta(naddr, meta) + console.log(`Background refresh cached metadata for ${naddr}`) + return res.status(200).json({ ok: true, cached: true }) + } else { + console.log(`Background refresh found no metadata for ${naddr}`) + return res.status(200).json({ ok: true, cached: false }) + } + } catch (err) { + console.error(`Error refreshing article metadata for ${naddr}:`, err) + return res.status(500).json({ error: 'Internal server error' }) + } +} + diff --git a/api/article-og.ts b/api/article-og.ts index 2dc01eb6..90834d0b 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 './services/ogStore.js' +import { fetchArticleMetadataViaRelays } from './services/articleMeta.js' +import { generateHtml } from './services/ogHtml.js' 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,41 @@ 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).catch((err) => { + console.error('Failed to get article meta from Redis:', err) + return null + }) + let cacheMaxAge = 86400 + + if (!meta) { + // Cache miss: fetch from relays (let it use its natural timeouts) + try { + meta = await fetchArticleMetadataViaRelays(naddr) + + if (meta) { + // Store in Redis and use it + await setArticleMeta(naddr, meta).catch((err) => { + console.error('Failed to cache relay metadata:', err) + }) + cacheMaxAge = 86400 + } else { + // Relay fetch failed: use default fallback + cacheMaxAge = 300 + } + } catch (err) { + console.error(`Error fetching from relays for ${naddr}:`, err) + cacheMaxAge = 300 } - return res.status(200).send(cached.html) } - 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/api/services/articleMeta.ts b/api/services/articleMeta.ts new file mode 100644 index 00000000..3124cb83 --- /dev/null +++ b/api/services/articleMeta.ts @@ -0,0 +1,224 @@ +import WebSocket from 'ws' +;(globalThis as unknown as { WebSocket?: typeof WebSocket }).WebSocket ??= WebSocket +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.js' +import { RELAYS } from '../../src/config/relays.js' +import type { ArticleMetadata } from './ogStore.js' + +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) +} + +async function fetchFirstEvent( + relayPool: RelayPool, + relayUrls: string[], + filter: Filter, + timeoutMs: number +): Promise { + return new Promise((resolve) => { + let resolved = false + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true + resolve(null) + } + }, timeoutMs) + + const subscription = relayPool.request(relayUrls, filter).subscribe({ + next: (event) => { + if (!resolved) { + resolved = true + clearTimeout(timeout) + subscription.unsubscribe() + resolve(event) + } + }, + error: () => { + if (!resolved) { + resolved = true + clearTimeout(timeout) + resolve(null) + } + }, + complete: () => { + if (!resolved) { + resolved = true + clearTimeout(timeout) + resolve(null) + } + } + }) + }) +} + +async function fetchAuthorProfile( + relayPool: RelayPool, + relayUrls: string[], + pubkey: string, + timeoutMs: number +): Promise { + const profileEvents = await fetchEventsFromRelays(relayPool, relayUrls, { + kinds: [0], + authors: [pubkey] + }, timeoutMs) + + if (profileEvents.length === 0) { + return null + } + + const displayName = extractProfileDisplayName(profileEvents[0]) + if (displayName && !displayName.startsWith('@')) { + return displayName + } else if (displayName) { + return displayName.substring(1) + } + + return null +} + +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 + + // Step A: Fetch article - return as soon as first event arrives + const article = await fetchFirstEvent(relayPool, relayUrls, { + kinds: [pointer.kind], + authors: [pointer.pubkey], + '#d': [pointer.identifier || ''] + }, 7000) + + if (!article) { + return null + } + + // Step B: Extract article metadata immediately + const title = getArticleTitle(article) || 'Untitled Article' + const summary = getArticleSummary(article) || 'Read this article on Boris' + const image = getArticleImage(article) || '/boris-social-1200.png' + + // Extract 't' tags (topic tags) from article event + const tags = article.tags + ?.filter((tag) => tag[0] === 't' && tag[1]) + .map((tag) => tag[1]) + .filter((tag) => tag.length > 0) || [] + + // Generate image alt text (use title as fallback) + const imageAlt = title || 'Article cover image' + + // Step C: Fetch author profile with micro-wait (connections already warm) + let authorName = await fetchAuthorProfile(relayPool, relayUrls, pointer.pubkey, 400) + + // Step D: Optional hedge - try again with slightly longer timeout if first attempt failed + if (!authorName) { + authorName = await fetchAuthorProfile(relayPool, relayUrls, pointer.pubkey, 600) + } + + if (!authorName) { + authorName = pointer.pubkey.slice(0, 8) + '...' + } + + return { + title, + summary, + image, + author: authorName, + published: article.created_at, + tags: tags.length > 0 ? tags : undefined, + imageAlt + } + } 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 url = `https://njump.to/${naddr}` + console.log(`Fetching from gateway: ${url}`) + + const resp = await fetch(url, { + redirect: 'follow', + signal: controller.signal + }) + clearTimeout(timeout) + + if (!resp.ok) { + console.error(`Gateway fetch failed: ${resp.status} ${resp.statusText} for ${url}`) + return null + } + + const html = await resp.text() + console.log(`Gateway response length: ${html.length} chars`) + + const pick = (re: RegExp) => { + const match = html.match(re) + return match?.[1] ? match[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) + + console.log(`Parsed from gateway - title: ${title ? 'found' : 'missing'}, summary: ${summary ? 'found' : 'missing'}, image: ${image ? 'found' : 'missing'}`) + + if (!title && !summary && !image) { + console.log('No OG metadata found in gateway response') + 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) + if (err instanceof Error) { + console.error('Error details:', err.message, err.stack) + } + return null + } +} + diff --git a/api/services/ogHtml.ts b/api/services/ogHtml.ts new file mode 100644 index 00000000..f2713b84 --- /dev/null +++ b/api/services/ogHtml.ts @@ -0,0 +1,79 @@ +import type { ArticleMetadata } from './ogStore.js' + +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' + const imageAlt = meta?.imageAlt || title + + // Generate article:tag meta tags + const articleTags = meta?.tags && meta.tags.length > 0 + ? meta.tags.map((tag) => ` `).join('\n') + : '' + + return ` + + + + + + + + + + + ${escapeHtml(title)} + + + + + + + + + + + + ${meta?.published ? ` ` : ''} + +${articleTags} + + + + + + + + + + + + +` +} + diff --git a/api/services/ogStore.ts b/api/services/ogStore.ts new file mode 100644 index 00000000..812429a3 --- /dev/null +++ b/api/services/ogStore.ts @@ -0,0 +1,39 @@ +import { Redis } from '@upstash/redis' + +// Support both KV_* and UPSTASH_* env var names +const redisUrl = process.env.UPSTASH_REDIS_REST_URL || process.env.KV_REST_API_URL +const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN || process.env.KV_REST_API_TOKEN +const readOnlyToken = process.env.KV_REST_API_READ_ONLY_TOKEN + +if (!redisUrl || !redisToken) { + console.error('Missing Redis credentials: UPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN or KV_REST_API_URL/KV_REST_API_TOKEN') +} + +const redisWrite = redisUrl && redisToken + ? new Redis({ url: redisUrl, token: redisToken }) + : Redis.fromEnv() // Fallback to fromEnv() if explicit vars not set + +const redisRead = readOnlyToken && redisUrl + ? new Redis({ url: redisUrl, token: readOnlyToken }) + : redisWrite + +const keyOf = (naddr: string) => `og:${naddr}` + +export type ArticleMetadata = { + title: string + summary: string + image: string + author: string + published?: number + tags?: string[] + imageAlt?: string +} + +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/package-lock.json b/package-lock.json index fd0eddf5..fe03468d 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", @@ -44,6 +45,7 @@ "@tailwindcss/postcss": "^4.1.14", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", + "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "@vitejs/plugin-react": "^4.2.1", @@ -57,6 +59,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": { @@ -3599,6 +3604,16 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "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/@typescript-eslint/eslint-plugin": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", @@ -3804,6 +3819,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 +11585,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..2987afbb 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", @@ -51,6 +52,7 @@ "@tailwindcss/postcss": "^4.1.14", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", + "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "@vitejs/plugin-react": "^4.2.1", @@ -102,6 +104,15 @@ "@typescript-eslint/no-explicit-any": "warn", "prefer-const": "error", "no-var": "error" - } + }, + "overrides": [ + { + "files": ["api/**/*.ts"], + "env": { + "node": true, + "browser": false + } + } + ] } } 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": [