Files
archy/packages/app/vite-rss.ts
T
DorianandClaude Opus 4.6 4dc9588c8a fix(app): replace CORS Access-Control-Allow-Origin * with explicit localhost origin
Add setCorsHeaders() and handleCorsOptions() helpers in server/dev-auth.ts.
Replace wildcard CORS origin with http://localhost:5173 in all Vite plugins
and claude-proxy.ts. Include Authorization in allowed CORS headers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:25:10 +00:00

165 lines
5.7 KiB
TypeScript

import type { Plugin } from 'vite'
import type { Connect } from 'vite'
import Parser from 'rss-parser'
import { validateDevAuth, setCorsHeaders } from './server/dev-auth'
export interface RssArticle {
title: string
url: string
content?: string
imgSrc?: string
}
const RSS_PATHS = ['/feed', '/rss', '/atom.xml', '/feed.xml', '/?feed=rss', '/rss.xml', '/.rss', '/feeds/posts/default']
function extractImgFromItem(item: Record<string, unknown>): string | undefined {
const enc = item.enclosure as { url?: string; type?: string } | undefined
if (enc?.url && /^https?:\/\//i.test(enc.url)) {
const t = (enc.type ?? '').toLowerCase()
if (t.startsWith('image/') || t === '') return enc.url
}
const thumb = item['media:thumbnail'] as { $?: { url?: string }; url?: string } | undefined
if (thumb?.$?.url) return thumb.$.url
if (typeof thumb?.url === 'string') return thumb.url
const arr = thumb as unknown[]
if (Array.isArray(arr) && arr[0]?.$?.url) return (arr[0] as { $: { url: string } }).$.url
const media = item['media:content'] as { $?: { url?: string }; url?: string } | undefined
if (media?.$?.url) return media.$.url
if (typeof media?.url === 'string') return media.url
const group = item['media:group'] as { 'media:content'?: Array<{ $?: { url?: string } }> } | undefined
if (group?.['media:content']?.[0]?.$?.url) return group['media:content'][0].$.url
const itunes = item['itunes:image'] as { href?: string } | undefined
if (itunes?.href) return itunes.href
const content = item.content ?? item.summary
const html = typeof content === 'string' ? content : ''
const imgMatch = html.match(/<img[^>]+src=["']([^"']+)["']/i)
if (imgMatch?.[1]) return imgMatch[1]
return undefined
}
async function tryParseFeed(parser: Parser, feedUrl: string): Promise<RssArticle[] | null> {
try {
const feed = await parser.parseURL(feedUrl)
if (!feed?.items?.length) return null
return feed.items.slice(0, 10).map((item) => {
const rawContent = item.contentSnippet ?? item.content ?? item.summary
const contentStr = typeof rawContent === 'string' ? rawContent : ''
const imgSrc = extractImgFromItem(item as Record<string, unknown>)
return {
title: (item.title ?? '').trim() || 'Untitled',
url: (item.link ?? item.guid ?? '').trim() || feedUrl,
content: contentStr.trim().slice(0, 15000),
imgSrc: imgSrc && /^https?:\/\//i.test(imgSrc) ? imgSrc : undefined,
}
})
} catch {
return null
}
}
function isPrivateUrl(urlStr: string): boolean {
try {
const u = new URL(urlStr)
const hostname = u.hostname.toLowerCase()
// Block localhost
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]') return true
// Block private IPv4 ranges
if (/^10\./.test(hostname)) return true
if (/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)) return true
if (/^192\.168\./.test(hostname)) return true
// Block link-local
if (/^169\.254\./.test(hostname)) return true
// Block 0.0.0.0
if (hostname === '0.0.0.0') return true
return false
} catch {
return true
}
}
function discoverFeedUrl(siteUrl: string): string[] {
try {
if (isPrivateUrl(siteUrl)) return []
const u = new URL(siteUrl)
const base = `${u.protocol}//${u.host}`
return RSS_PATHS.map((path) => base + path)
} catch {
return []
}
}
async function fetchRssFromUrls(urls: string[]): Promise<RssArticle[]> {
const parser = new Parser({
timeout: 5000,
headers: { 'User-Agent': 'AIUI/1.0 (RSS reader)' },
})
const seen = new Set<string>()
const articles: RssArticle[] = []
for (const url of urls.slice(0, 5)) {
const candidates = discoverFeedUrl(url)
for (const feedUrl of candidates) {
const items = await tryParseFeed(parser, feedUrl)
if (!items?.length) continue
for (const a of items) {
const k = a.url.toLowerCase()
if (seen.has(k)) continue
seen.add(k)
articles.push(a)
}
break // found a feed for this site, move to next URL
}
}
return articles.slice(0, 15)
}
function createRssMiddleware() {
return async (req: Connect.IncomingMessage, res: any, next: () => void) => {
if (req.method !== 'GET') return next()
if (!validateDevAuth(req, res)) return
const requestUrl = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`)
if (!requestUrl.pathname.startsWith('/api/rss-articles')) return next()
const urls = requestUrl.searchParams.getAll('url').map((u) => u.trim()).filter(Boolean)
if (urls.length === 0) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Missing urls (array or comma-separated)' }))
return
}
const safe = urls.filter((u) => /^https?:\/\//i.test(u.trim()) && !isPrivateUrl(u.trim())).slice(0, 8)
if (safe.length === 0) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'No valid https URLs' }))
return
}
try {
const articles = await fetchRssFromUrls(safe)
res.setHeader('Content-Type', 'application/json')
setCorsHeaders(res)
res.setHeader('Cache-Control', 'public, max-age=300')
res.end(JSON.stringify({ articles }))
} catch (err) {
console.error('[rss]', err)
res.writeHead(502, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: String(err) }))
}
}
}
export function rssPlugin(): Plugin {
return {
name: 'aiui-rss',
configureServer(server) {
server.middlewares.use(createRssMiddleware())
},
configurePreviewServer(server) {
server.middlewares.use(createRssMiddleware())
},
}
}