mirror of
https://github.com/dergigi/boris.git
synced 2025-12-17 06:34:24 +01:00
Merge pull request #42 from dergigi/fix-opengraph-try3
Fix OpenGraph metadata fetching and add Redis-backed caching
This commit is contained in:
41
api/article-og-refresh.ts
Normal file
41
api/article-og-refresh.ts
Normal file
@@ -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' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 './services/ogStore.js'
|
||||||
;(globalThis as any).WebSocket ??= WebSocket as any
|
import { fetchArticleMetadataViaRelays } from './services/articleMeta.js'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { generateHtml } from './services/ogHtml.js'
|
||||||
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,41 @@ 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).catch((err) => {
|
||||||
const cached = memoryCache.get(naddr)
|
console.error('Failed to get article meta from Redis:', err)
|
||||||
if (cached && cached.expires > now) {
|
return null
|
||||||
setCacheHeaders(res)
|
})
|
||||||
if (debugEnabled) {
|
let cacheMaxAge = 86400
|
||||||
// Debug mode enabled
|
|
||||||
|
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 {
|
// Generate and send HTML
|
||||||
// Fetch metadata
|
const html = generateHtml(naddr, meta)
|
||||||
const meta = await fetchArticleMetadata(naddr)
|
setCacheHeaders(res, cacheMaxAge)
|
||||||
|
|
||||||
// Generate HTML
|
if (debugEnabled) {
|
||||||
const html = generateHtml(naddr, meta)
|
// Debug mode enabled
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return res.status(200).send(html)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
224
api/services/articleMeta.ts
Normal file
224
api/services/articleMeta.ts
Normal file
@@ -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<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)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFirstEvent(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
relayUrls: string[],
|
||||||
|
filter: Filter,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<NostrEvent | null> {
|
||||||
|
return new Promise<NostrEvent | null>((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<string | null> {
|
||||||
|
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<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
|
||||||
|
|
||||||
|
// 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<ArticleMetadata | null> {
|
||||||
|
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(/<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)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
79
api/services/ogHtml.ts
Normal file
79
api/services/ogHtml.ts
Normal file
@@ -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, '"')
|
||||||
|
.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) => ` <meta property="article:tag" content="${escapeHtml(tag)}" />`).join('\n')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
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:image:alt" content="${escapeHtml(imageAlt)}" />
|
||||||
|
<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)}" />
|
||||||
|
${articleTags}
|
||||||
|
|
||||||
|
<!-- 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>`
|
||||||
|
}
|
||||||
|
|
||||||
39
api/services/ogStore.ts
Normal file
39
api/services/ogStore.ts
Normal file
@@ -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<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 })
|
||||||
|
}
|
||||||
|
|
||||||
30
package-lock.json
generated
30
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",
|
||||||
@@ -44,6 +45,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.14",
|
"@tailwindcss/postcss": "^4.1.14",
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
"@typescript-eslint/parser": "^6.14.0",
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
@@ -57,6 +59,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": {
|
||||||
@@ -3599,6 +3604,16 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
|
||||||
@@ -3804,6 +3819,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 +11585,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",
|
||||||
|
|||||||
13
package.json
13
package.json
@@ -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",
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.14",
|
"@tailwindcss/postcss": "^4.1.14",
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
"@typescript-eslint/parser": "^6.14.0",
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
@@ -102,6 +104,15 @@
|
|||||||
"@typescript-eslint/no-explicit-any": "warn",
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
"prefer-const": "error",
|
"prefer-const": "error",
|
||||||
"no-var": "error"
|
"no-var": "error"
|
||||||
}
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["api/**/*.ts"],
|
||||||
|
"env": {
|
||||||
|
"node": true,
|
||||||
|
"browser": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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