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>
165 lines
5.7 KiB
TypeScript
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())
|
|
},
|
|
}
|
|
}
|