mirror of
https://github.com/dergigi/boris.git
synced 2025-12-17 06:34:24 +01:00
feat: implement storage-backed OG previews with Upstash Redis
- Add ogStore service for Redis get/set operations - Extract shared logic: ogHtml (generateHtml, escapeHtml) and articleMeta (relay/gateway fetching) - Refactor article-og endpoint to read from Redis, try gateway on miss, trigger background refresh - Add article-og-refresh endpoint for background relay fetching and caching - Update vercel.json with refresh function config - Remove WebSocket dependencies from main OG endpoint for faster crawler responses
This commit is contained in:
35
api/article-og-refresh.ts
Normal file
35
api/article-og-refresh.ts
Normal file
@@ -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' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,213 +1,13 @@
|
|||||||
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||||
import WebSocket from 'ws'
|
import { getArticleMeta, setArticleMeta } from '../src/services/ogStore'
|
||||||
;(globalThis as any).WebSocket ??= WebSocket as any
|
import { fetchArticleMetadataViaGateway } from '../src/services/articleMeta'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { generateHtml } from '../src/services/ogHtml'
|
||||||
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<string, CacheEntry>()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function escapeHtml(text: string): string {
|
|
||||||
return text
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCacheHeaders(res: VercelResponse, maxAge: number = 86400): void {
|
function setCacheHeaders(res: VercelResponse, maxAge: number = 86400): void {
|
||||||
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`)
|
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`)
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
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<NostrEvent[]> {
|
|
||||||
const events: NostrEvent[] = []
|
|
||||||
|
|
||||||
await new Promise<void>((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<ArticleMetadata | null> {
|
|
||||||
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 `<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
||||||
<meta name="theme-color" content="#0f172a" />
|
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
|
||||||
<title>${escapeHtml(title)}</title>
|
|
||||||
<meta name="description" content="${escapeHtml(description)}" />
|
|
||||||
<link rel="canonical" href="${articleUrl}" />
|
|
||||||
|
|
||||||
<!-- Open Graph / Social Media -->
|
|
||||||
<meta property="og:type" content="article" />
|
|
||||||
<meta property="og:url" content="${articleUrl}" />
|
|
||||||
<meta property="og:title" content="${escapeHtml(title)}" />
|
|
||||||
<meta property="og:description" content="${escapeHtml(description)}" />
|
|
||||||
<meta property="og:image" content="${escapeHtml(image)}" />
|
|
||||||
<meta property="og:site_name" content="Boris" />
|
|
||||||
${meta?.published ? `<meta property="article:published_time" content="${new Date(meta.published * 1000).toISOString()}" />` : ''}
|
|
||||||
<meta property="article:author" content="${escapeHtml(author)}" />
|
|
||||||
|
|
||||||
<!-- Twitter Card -->
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
|
||||||
<meta name="twitter:url" content="${articleUrl}" />
|
|
||||||
<meta name="twitter:title" content="${escapeHtml(title)}" />
|
|
||||||
<meta name="twitter:description" content="${escapeHtml(description)}" />
|
|
||||||
<meta name="twitter:image" content="${escapeHtml(image)}" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<noscript>
|
|
||||||
<p>Redirecting to <a href="/">Boris</a>...</p>
|
|
||||||
</noscript>
|
|
||||||
<script>
|
|
||||||
(function(){
|
|
||||||
try {
|
|
||||||
var p = '/a/${naddr}';
|
|
||||||
if (window.location.pathname !== p) {
|
|
||||||
history.replaceState(null, '', p);
|
|
||||||
}
|
|
||||||
window.location.replace('/');
|
|
||||||
} catch (e) {}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
const naddr = (req.query.naddr as string | undefined)?.trim()
|
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')
|
res.setHeader('X-Boris-Debug', '1')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cache for bots/crawlers
|
// Try Redis cache first
|
||||||
const now = Date.now()
|
let meta = await getArticleMeta(naddr)
|
||||||
const cached = memoryCache.get(naddr)
|
let cacheMaxAge = 86400
|
||||||
if (cached && cached.expires > now) {
|
|
||||||
setCacheHeaders(res)
|
if (!meta) {
|
||||||
if (debugEnabled) {
|
// Cache miss: try gateway (fast HTTP, no WebSockets)
|
||||||
// Debug mode enabled
|
meta = await fetchArticleMetadataViaGateway(naddr)
|
||||||
}
|
|
||||||
return res.status(200).send(cached.html)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Trigger background refresh (fire-and-forget)
|
||||||
// Fetch metadata
|
const secret = process.env.OG_REFRESH_SECRET || ''
|
||||||
const meta = await fetchArticleMetadata(naddr)
|
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`
|
||||||
|
|
||||||
// Generate HTML
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate and send HTML
|
||||||
const html = generateHtml(naddr, meta)
|
const html = generateHtml(naddr, meta)
|
||||||
|
setCacheHeaders(res, cacheMaxAge)
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
memoryCache.set(naddr, { html, expires: now + WEEK_MS })
|
|
||||||
|
|
||||||
// Send response
|
|
||||||
setCacheHeaders(res)
|
|
||||||
if (debugEnabled) {
|
if (debugEnabled) {
|
||||||
// Debug mode enabled
|
// 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)
|
return res.status(200).send(html)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
19
package-lock.json
generated
19
package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.0.2",
|
"@fortawesome/react-fontawesome": "^3.0.2",
|
||||||
"@treeee/youtube-caption-extractor": "^1.5.5",
|
"@treeee/youtube-caption-extractor": "^1.5.5",
|
||||||
|
"@upstash/redis": "^1.35.6",
|
||||||
"@vercel/node": "^5.3.26",
|
"@vercel/node": "^5.3.26",
|
||||||
"applesauce-accounts": "^4.0.0",
|
"applesauce-accounts": "^4.0.0",
|
||||||
"applesauce-content": "^4.0.0",
|
"applesauce-content": "^4.0.0",
|
||||||
@@ -57,6 +58,9 @@
|
|||||||
"vite": "^5.0.8",
|
"vite": "^5.0.8",
|
||||||
"vite-plugin-pwa": "^1.0.3",
|
"vite-plugin-pwa": "^1.0.3",
|
||||||
"workbox-window": "^7.3.0"
|
"workbox-window": "^7.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "22.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
@@ -3804,6 +3808,15 @@
|
|||||||
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/@vercel/build-utils": {
|
||||||
"version": "12.1.2",
|
"version": "12.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-12.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-12.1.2.tgz",
|
||||||
@@ -11561,6 +11574,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/undici": {
|
||||||
"version": "5.28.4",
|
"version": "5.28.4",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.0.2",
|
"@fortawesome/react-fontawesome": "^3.0.2",
|
||||||
"@treeee/youtube-caption-extractor": "^1.5.5",
|
"@treeee/youtube-caption-extractor": "^1.5.5",
|
||||||
|
"@upstash/redis": "^1.35.6",
|
||||||
"@vercel/node": "^5.3.26",
|
"@vercel/node": "^5.3.26",
|
||||||
"applesauce-accounts": "^4.0.0",
|
"applesauce-accounts": "^4.0.0",
|
||||||
"applesauce-content": "^4.0.0",
|
"applesauce-content": "^4.0.0",
|
||||||
|
|||||||
134
src/services/articleMeta.ts
Normal file
134
src/services/articleMeta.ts
Normal file
@@ -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<NostrEvent[]> {
|
||||||
|
const events: NostrEvent[] = []
|
||||||
|
|
||||||
|
await new Promise<void>((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<ArticleMetadata | null> {
|
||||||
|
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<ArticleMetadata | null> {
|
||||||
|
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(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i) ||
|
||||||
|
pick(/<title[^>]*>([^<]+)<\/title>/i)
|
||||||
|
const summary = pick(/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i)
|
||||||
|
const image = pick(/<meta[^>]+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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
71
src/services/ogHtml.ts
Normal file
71
src/services/ogHtml.ts
Normal file
@@ -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, '"')
|
||||||
|
.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 `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<title>${escapeHtml(title)}</title>
|
||||||
|
<meta name="description" content="${escapeHtml(description)}" />
|
||||||
|
<link rel="canonical" href="${articleUrl}" />
|
||||||
|
|
||||||
|
<!-- Open Graph / Social Media -->
|
||||||
|
<meta property="og:type" content="article" />
|
||||||
|
<meta property="og:url" content="${articleUrl}" />
|
||||||
|
<meta property="og:title" content="${escapeHtml(title)}" />
|
||||||
|
<meta property="og:description" content="${escapeHtml(description)}" />
|
||||||
|
<meta property="og:image" content="${escapeHtml(image)}" />
|
||||||
|
<meta property="og:site_name" content="Boris" />
|
||||||
|
${meta?.published ? `<meta property="article:published_time" content="${new Date(meta.published * 1000).toISOString()}" />` : ''}
|
||||||
|
<meta property="article:author" content="${escapeHtml(author)}" />
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:url" content="${articleUrl}" />
|
||||||
|
<meta name="twitter:title" content="${escapeHtml(title)}" />
|
||||||
|
<meta name="twitter:description" content="${escapeHtml(description)}" />
|
||||||
|
<meta name="twitter:image" content="${escapeHtml(image)}" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<p>Redirecting to <a href="/">Boris</a>...</p>
|
||||||
|
</noscript>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
try {
|
||||||
|
var p = '/a/${naddr}';
|
||||||
|
if (window.location.pathname !== p) {
|
||||||
|
history.replaceState(null, '', p);
|
||||||
|
}
|
||||||
|
window.location.replace('/');
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
25
src/services/ogStore.ts
Normal file
25
src/services/ogStore.ts
Normal file
@@ -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<ArticleMetadata | null> {
|
||||||
|
return (await redisRead.get<ArticleMetadata>(keyOf(naddr))) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setArticleMeta(naddr: string, meta: ArticleMetadata, ttlSec = 604800): Promise<void> {
|
||||||
|
await redisWrite.set(keyOf(naddr), meta, { ex: ttlSec })
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
"functions": {
|
"functions": {
|
||||||
"api/article-og.ts": {
|
"api/article-og.ts": {
|
||||||
"maxDuration": 10
|
"maxDuration": 10
|
||||||
|
},
|
||||||
|
"api/article-og-refresh.ts": {
|
||||||
|
"maxDuration": 10
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rewrites": [
|
"rewrites": [
|
||||||
|
|||||||
Reference in New Issue
Block a user