Merge pull request #42 from dergigi/fix-opengraph-try3

Fix OpenGraph metadata fetching and add Redis-backed caching
This commit is contained in:
Gigi
2025-11-07 19:59:19 +01:00
committed by GitHub
8 changed files with 464 additions and 239 deletions

41
api/article-og-refresh.ts Normal file
View 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' })
}
}

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
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
}
return res.status(200).send(cached.html)
}
if (!meta) {
// Cache miss: fetch from relays (let it use its natural timeouts)
try { try {
// Fetch metadata meta = await fetchArticleMetadataViaRelays(naddr)
const meta = await fetchArticleMetadata(naddr)
// Generate HTML if (meta) {
const html = generateHtml(naddr, meta) // Store in Redis and use it
await setArticleMeta(naddr, meta).catch((err) => {
// Cache the result console.error('Failed to cache relay metadata:', err)
memoryCache.set(naddr, { html, expires: now + WEEK_MS }) })
cacheMaxAge = 86400
// Send response } else {
setCacheHeaders(res) // Relay fetch failed: use default fallback
if (debugEnabled) { cacheMaxAge = 300
// Debug mode enabled
} }
return res.status(200).send(html)
} catch (err) { } catch (err) {
console.error('Error generating article OG HTML:', err) console.error(`Error fetching from relays for ${naddr}:`, err)
cacheMaxAge = 300
}
}
// Generate and send HTML
const html = generateHtml(naddr, meta)
setCacheHeaders(res, cacheMaxAge)
// Fallback to basic HTML with SPA boot
const html = generateHtml(naddr, null)
setCacheHeaders(res, 3600)
if (debugEnabled) { if (debugEnabled) {
// Debug mode enabled // Debug mode enabled
} }
return res.status(200).send(html)
}
}
return res.status(200).send(html)
}

224
api/services/articleMeta.ts Normal file
View 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
View File

@@ -0,0 +1,79 @@
import type { ArticleMetadata } from './ogStore.js'
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
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
View 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
View File

@@ -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",

View File

@@ -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
} }
} }
]
}
} }

View File

@@ -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": [