102 lines
3.6 KiB
TypeScript
102 lines
3.6 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
|
import { auth } from '@/auth'
|
|
import { extractArticleFromHtml } from '@/lib/clip/extract-article'
|
|
import { analyzeClipContent } from '@/lib/clip/analyze-clip'
|
|
import { resolveClipLocale, wrapClipPlainParagraph } from '@/lib/clip/rtl-content'
|
|
|
|
function isBlockedUrl(url: string): boolean {
|
|
try {
|
|
const parsed = new URL(url)
|
|
const hostname = parsed.hostname.toLowerCase()
|
|
const blocked = ['localhost', '127.0.0.1', '0.0.0.0', '::1', '169.254.169.254']
|
|
if (blocked.includes(hostname)) return true
|
|
if (hostname.startsWith('10.') || hostname.startsWith('172.') || hostname.startsWith('192.168.')) return true
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return true
|
|
return false
|
|
} catch {
|
|
return true
|
|
}
|
|
}
|
|
|
|
async function fetchPageHtml(url: string): Promise<string | null> {
|
|
const controller = new AbortController()
|
|
const timeoutId = setTimeout(() => controller.abort(), 15000)
|
|
try {
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
'User-Agent': 'Mozilla/5.0 (compatible; MementoClipper/1.0)',
|
|
Accept: 'text/html,application/xhtml+xml',
|
|
},
|
|
signal: controller.signal,
|
|
redirect: 'follow',
|
|
})
|
|
if (!response.ok) return null
|
|
const ct = response.headers.get('content-type') || ''
|
|
if (!ct.includes('text/html') && !ct.includes('application/xhtml')) return null
|
|
return await response.text()
|
|
} catch {
|
|
return null
|
|
} finally {
|
|
clearTimeout(timeoutId)
|
|
}
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const session = await auth()
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const body = await request.json()
|
|
const url = typeof body.url === 'string' ? body.url.trim() : ''
|
|
const htmlInput = typeof body.html === 'string' ? body.html : ''
|
|
const selection = typeof body.selection === 'string' ? body.selection.trim() : ''
|
|
|
|
if (!url || isBlockedUrl(url)) {
|
|
return NextResponse.json({ error: 'Invalid URL' }, { status: 400 })
|
|
}
|
|
|
|
let title = ''
|
|
let textContent = ''
|
|
let contentHtml = ''
|
|
|
|
if (body.mode === 'link') {
|
|
title = new URL(url).hostname
|
|
textContent = url
|
|
contentHtml = `<p><a href="${url}" rel="noopener noreferrer">${url}</a></p>`
|
|
} else if (body.mode === 'selection' && selection) {
|
|
title = typeof body.title === 'string' ? body.title : new URL(url).hostname
|
|
textContent = selection
|
|
const locale = resolveClipLocale(url, title, selection)
|
|
contentHtml = wrapClipPlainParagraph(selection, locale)
|
|
} else {
|
|
const html = htmlInput || (await fetchPageHtml(url))
|
|
if (!html) {
|
|
return NextResponse.json({ error: 'Could not fetch page content' }, { status: 422 })
|
|
}
|
|
const extracted = extractArticleFromHtml(html, url)
|
|
if (!extracted) {
|
|
return NextResponse.json({ error: 'Could not extract readable article' }, { status: 422 })
|
|
}
|
|
title = extracted.title || (typeof body.title === 'string' ? body.title : '')
|
|
textContent = extracted.textContent
|
|
contentHtml = extracted.content
|
|
}
|
|
|
|
const analysis = await analyzeClipContent({ url, title, textContent })
|
|
|
|
return NextResponse.json({
|
|
title: analysis.title,
|
|
summary: analysis.summary,
|
|
tags: analysis.tags,
|
|
readingTime: analysis.readingTimeMinutes,
|
|
content: contentHtml,
|
|
excerpt: textContent.slice(0, 500),
|
|
})
|
|
} catch (error) {
|
|
console.error('[POST /api/clip/analyze]', error)
|
|
return NextResponse.json({ error: 'Analysis failed' }, { status: 500 })
|
|
}
|
|
}
|