All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 43s
- Add stop button to all chat interfaces (floating, contextual, full-page) - Add conversation sliding window (50 messages) to prevent context overflow - Add chat timeout warning (30s toast) - Force response language in chat system prompt (mandatory per-locale) - Add image paste from clipboard in all note editors (card, list, input) - Fix upload API to infer extension from MIME type for clipboard images - Add image description support in note AI chat (base64 vision) - Fix search regex crash on special characters (escape user input) - Fix search case-insensitivity on PostgreSQL (mode: 'insensitive') - Add try/catch around semantic search in chat route (prevent blocking) - Add new chat button to floating AI assistant - Fix empty thinking bubbles for reasoning models (filter non-text parts) - Remove duplicate AI assistant toggle from note editor header - Improve link metadata scraping (timeout, content-type check, relative URLs) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
100 lines
3.3 KiB
TypeScript
100 lines
3.3 KiB
TypeScript
'use server'
|
|
|
|
import * as cheerio from 'cheerio';
|
|
|
|
export interface LinkMetadata {
|
|
url: string;
|
|
title?: string;
|
|
description?: string;
|
|
imageUrl?: string;
|
|
siteName?: string;
|
|
}
|
|
|
|
export async function fetchLinkMetadata(url: string): Promise<LinkMetadata | null> {
|
|
try {
|
|
// Add protocol if missing
|
|
let targetUrl = url;
|
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
targetUrl = 'https://' + url;
|
|
}
|
|
|
|
// SSRF protection: block internal/private IPs
|
|
const parsed = new URL(targetUrl)
|
|
const hostname = parsed.hostname.toLowerCase()
|
|
const blockedHosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1', '169.254.169.254']
|
|
if (blockedHosts.includes(hostname)) return null
|
|
if (hostname.startsWith('10.') || hostname.startsWith('172.') || hostname.startsWith('192.168.') || hostname.startsWith('fc') || hostname.startsWith('fd')) return null
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
|
|
|
|
const controller = new AbortController()
|
|
const timeoutId = setTimeout(() => controller.abort(), 10000)
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(targetUrl, {
|
|
headers: {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
|
'Accept-Language': 'en-US,en;q=0.5',
|
|
},
|
|
signal: controller.signal,
|
|
redirect: 'follow',
|
|
});
|
|
} catch (fetchError: any) {
|
|
clearTimeout(timeoutId)
|
|
if (fetchError.name === 'AbortError') {
|
|
console.error(`[Scrape] Timeout fetching ${url} (10s)`)
|
|
} else {
|
|
console.error(`[Scrape] Network error fetching ${url}:`, fetchError.message)
|
|
}
|
|
return null
|
|
}
|
|
|
|
clearTimeout(timeoutId)
|
|
|
|
if (!response.ok) {
|
|
console.error(`[Scrape] HTTP ${response.status} for ${url}`)
|
|
return null;
|
|
}
|
|
|
|
const contentType = response.headers.get('content-type') || ''
|
|
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml')) {
|
|
// Not HTML — return basic metadata
|
|
return { url: targetUrl, title: targetUrl };
|
|
}
|
|
|
|
const html = await response.text();
|
|
const $ = cheerio.load(html);
|
|
|
|
const getMeta = (prop: string) =>
|
|
$(`meta[property="${prop}"]`).attr('content') ||
|
|
$(`meta[name="${prop}"]`).attr('content');
|
|
|
|
const title = getMeta('og:title') || $('title').text()?.trim() || getMeta('twitter:title') || url;
|
|
const description = getMeta('og:description') || getMeta('description') || getMeta('twitter:description') || '';
|
|
let imageUrl = getMeta('og:image') || getMeta('twitter:image') || $('link[rel="image_src"]').attr('href');
|
|
|
|
// Resolve relative image URLs
|
|
if (imageUrl && !imageUrl.startsWith('http')) {
|
|
try {
|
|
imageUrl = new URL(imageUrl, targetUrl).href
|
|
} catch {
|
|
imageUrl = undefined
|
|
}
|
|
}
|
|
|
|
const siteName = getMeta('og:site_name') || '';
|
|
|
|
return {
|
|
url: targetUrl,
|
|
title: title.substring(0, 100),
|
|
description: description.substring(0, 200),
|
|
imageUrl,
|
|
siteName
|
|
};
|
|
} catch (error) {
|
|
console.error(`[Scrape] Error fetching ${url}:`, error);
|
|
return null;
|
|
}
|
|
}
|