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 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<string, CacheEntry>()
|
||||
|
||||
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.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<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) {
|
||||
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 and send HTML
|
||||
const html = generateHtml(naddr, meta)
|
||||
setCacheHeaders(res, cacheMaxAge)
|
||||
|
||||
// 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)
|
||||
if (debugEnabled) {
|
||||
// Debug mode enabled
|
||||
}
|
||||
|
||||
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/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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
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": {
|
||||
"api/article-og.ts": {
|
||||
"maxDuration": 10
|
||||
},
|
||||
"api/article-og-refresh.ts": {
|
||||
"maxDuration": 10
|
||||
}
|
||||
},
|
||||
"rewrites": [
|
||||
|
||||
Reference in New Issue
Block a user