mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-31 01:11:53 +08:00
feat(chat): favicon external links with secure link-preview tooltips (#5734)
* feat(chat): favicon external links with secure link-preview tooltips * fix(link-preview): address review findings - allow http fetches to match advertised http(s) link support - fix meta content regex to handle apostrophes and either quote delimiter - hash Redis cache keys so sensitive URLs are not stored verbatim - add per-user rate limit to the outbound-fetching route - render siteName-only previews instead of falling back to the URL * fix(link-preview): redact full URLs from failure logs * improvement(link-preview): render-time preview fetch, cheerio parsing, cleanup pass - fetch previews when links render (emcn tooltip shows instantly — hover prefetch had no delay to race); tooltip reads the warmed cache, eliminating the URL-then-preview flash - parse OG metadata with cheerio (already used server-side) instead of hand-rolled regexes + entity decoding, fixing double-decode and quote-handling classes - drop the no-longer-needed prefetch hook; remove dead side prop on Tooltip.Content - extract ExternalLink to a sibling module per component-size guidelines; fix TSDoc placement * fix(link-preview): https-only previews and full-document parsing - drop allowHttp: plain-http fetches would reach the URL validator's self-host loopback exception; previews are now explicitly https-only on both server (early null) and client (query never fires for http) - parse the full capped document instead of truncating at the first <body> substring, which could match inside head scripts/comments and drop metadata * improvement(chat): render mailto links as plain text
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import { createHash } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { truncate } from '@sim/utils/string'
|
||||
import * as cheerio from 'cheerio'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { LinkPreview } from '@/lib/api/contracts/link-preview'
|
||||
import { getLinkPreviewContract } from '@/lib/api/contracts/link-preview'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
import { enforceUserRateLimit } from '@/lib/core/rate-limiter/route-helpers'
|
||||
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('LinkPreviewAPI')
|
||||
|
||||
const FETCH_TIMEOUT_MS = 5000
|
||||
const MAX_RESPONSE_BYTES = 256 * 1024
|
||||
const MAX_REDIRECTS = 3
|
||||
const TITLE_MAX_CHARS = 200
|
||||
const DESCRIPTION_MAX_CHARS = 300
|
||||
const CACHE_TTL_SECONDS = 24 * 60 * 60
|
||||
const NEGATIVE_CACHE_TTL_SECONDS = 60 * 60
|
||||
const CACHE_KEY_PREFIX = 'link-preview:v1:'
|
||||
|
||||
/**
|
||||
* Parses preview metadata from the fetched document (already capped at
|
||||
* MAX_RESPONSE_BYTES); cheerio handles attribute order, quoting, and entity
|
||||
* decoding.
|
||||
*/
|
||||
function parsePreview(html: string): LinkPreview {
|
||||
const $ = cheerio.load(html)
|
||||
|
||||
const meta = (key: string): string | null => {
|
||||
const value = $(`meta[property="${key}"], meta[name="${key}"]`).first().attr('content')
|
||||
return value?.trim() || null
|
||||
}
|
||||
|
||||
const title =
|
||||
meta('og:title') ?? meta('twitter:title') ?? ($('title').first().text().trim() || null)
|
||||
const description = meta('og:description') ?? meta('twitter:description') ?? meta('description')
|
||||
const siteName = meta('og:site_name')
|
||||
|
||||
if (!title && !description && !siteName) return null
|
||||
return {
|
||||
title: title ? truncate(title, TITLE_MAX_CHARS) : null,
|
||||
description: description ? truncate(description, DESCRIPTION_MAX_CHARS) : null,
|
||||
siteName: siteName ? truncate(siteName, TITLE_MAX_CHARS) : null,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPreview(url: string): Promise<LinkPreview> {
|
||||
const response = await secureFetchWithValidation(url, {
|
||||
timeout: FETCH_TIMEOUT_MS,
|
||||
maxRedirects: MAX_REDIRECTS,
|
||||
maxResponseBytes: MAX_RESPONSE_BYTES,
|
||||
headers: {
|
||||
'User-Agent': 'Simbot/1.0 (+https://sim.ai)',
|
||||
Accept: 'text/html,application/xhtml+xml',
|
||||
},
|
||||
})
|
||||
if (response.status < 200 || response.status >= 300) return null
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml+xml')) {
|
||||
return null
|
||||
}
|
||||
return parsePreview(await response.text())
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const rateLimited = await enforceUserRateLimit('link-preview', session.user.id)
|
||||
if (rateLimited) return rateLimited
|
||||
|
||||
const parsed = await parseRequest(getLinkPreviewContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { url } = parsed.data.query
|
||||
|
||||
if (!url.startsWith('https://')) {
|
||||
return NextResponse.json({ preview: null })
|
||||
}
|
||||
|
||||
const redis = getRedisClient()
|
||||
const cacheKey = `${CACHE_KEY_PREFIX}${createHash('sha256').update(url).digest('hex')}`
|
||||
if (redis) {
|
||||
try {
|
||||
const cached = await redis.get(cacheKey)
|
||||
if (cached !== null) {
|
||||
return NextResponse.json({ preview: JSON.parse(cached) })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Link preview cache read failed', { error })
|
||||
}
|
||||
}
|
||||
|
||||
let preview: LinkPreview = null
|
||||
try {
|
||||
preview = await fetchPreview(url)
|
||||
} catch (error) {
|
||||
logger.info('Link preview fetch failed; returning null preview', {
|
||||
host: new URL(url).hostname,
|
||||
error: getErrorMessage(error, 'unknown error').replaceAll(url, '[url]'),
|
||||
})
|
||||
}
|
||||
|
||||
if (redis) {
|
||||
const ttl = preview ? CACHE_TTL_SECONDS : NEGATIVE_CACHE_TTL_SECONDS
|
||||
try {
|
||||
await redis.set(cacheKey, JSON.stringify(preview), 'EX', ttl)
|
||||
} catch (error) {
|
||||
logger.warn('Link preview cache write failed', { error })
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ preview })
|
||||
})
|
||||
+16
@@ -24,6 +24,7 @@ import {
|
||||
import type { ChatContextKind, MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import { useSmoothText } from '@/hooks/use-smooth-text'
|
||||
import { sanitizeChatDisplayContent } from './chat-sanitize'
|
||||
import { ExternalLink, externalLinkHostname } from './external-link'
|
||||
|
||||
const LANG_ALIASES: Record<string, string> = {
|
||||
js: 'javascript',
|
||||
@@ -268,6 +269,21 @@ const MARKDOWN_COMPONENTS = {
|
||||
</a>
|
||||
)
|
||||
}
|
||||
const hostname = externalLinkHostname(href)
|
||||
if (hostname && href) {
|
||||
return (
|
||||
<ExternalLink href={href} hostname={hostname}>
|
||||
{children}
|
||||
</ExternalLink>
|
||||
)
|
||||
}
|
||||
if (href?.startsWith('mailto:')) {
|
||||
return (
|
||||
<a href={href} className='not-prose text-[var(--text-primary)] no-underline'>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import { Tooltip } from '@sim/emcn'
|
||||
import { faviconUrl } from '@/lib/core/utils/favicon'
|
||||
import { useLinkPreview } from '@/hooks/queries/link-preview'
|
||||
|
||||
/** Hides a favicon img that failed to load so the link degrades to plain text. */
|
||||
function hideBrokenFavicon(e: React.SyntheticEvent<HTMLImageElement>): void {
|
||||
e.currentTarget.style.display = 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
* Hostname for an external http(s) link, used to fetch its favicon. Returns
|
||||
* null for relative, anchor, mailto, and unparsable hrefs so those keep the
|
||||
* plain underlined treatment.
|
||||
*/
|
||||
export function externalLinkHostname(href?: string): string | null {
|
||||
if (!href || !/^https?:\/\//i.test(href)) return null
|
||||
try {
|
||||
return new URL(href).hostname
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface ExternalLinkProps {
|
||||
href: string
|
||||
hostname: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Favicon + quiet-underline external link with an OG-preview tooltip. The
|
||||
* preview query fires when the link renders, so metadata is normally cached
|
||||
* (client and server side) before the first hover; the tooltip shows the
|
||||
* destination URL until metadata arrives or when the site has none. Previews
|
||||
* are https-only — plain-http links keep the URL tooltip, since fetching them
|
||||
* server-side would reach the URL validator's self-host loopback exception.
|
||||
*/
|
||||
export function ExternalLink({ href, hostname, children }: ExternalLinkProps) {
|
||||
const { data } = useLinkPreview(href.startsWith('https://') ? href : undefined)
|
||||
const preview = data?.preview
|
||||
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<a
|
||||
href={href}
|
||||
className='not-prose group text-[var(--text-primary)] no-underline'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<img
|
||||
src={faviconUrl(hostname, 32)}
|
||||
alt=''
|
||||
className='relative top-[0.5px] mr-[2px] inline size-[12px] rounded-[3px]'
|
||||
onError={hideBrokenFavicon}
|
||||
/>
|
||||
<span className='underline decoration-[color:var(--text-muted)] underline-offset-4 transition-colors group-hover:decoration-[color:var(--text-primary)]'>
|
||||
{children}
|
||||
</span>
|
||||
</a>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
{preview ? (
|
||||
<span className='flex flex-col gap-0.5'>
|
||||
{preview.title && <span className='font-medium'>{preview.title}</span>}
|
||||
{preview.description && (
|
||||
<span className='line-clamp-2 text-[var(--text-muted)]'>{preview.description}</span>
|
||||
)}
|
||||
<span className='text-[var(--text-muted)]'>{preview.siteName ?? hostname}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className='break-all'>{href}</span>
|
||||
)}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
+2
-1
@@ -4,6 +4,7 @@ import type React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Badge, Checkbox, cn, Tooltip } from '@sim/emcn'
|
||||
import { parse } from 'tldts'
|
||||
import { faviconUrl } from '@/lib/core/utils/favicon'
|
||||
import type { RowExecutionMetadata } from '@/lib/table'
|
||||
import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import { storageToDisplay } from '../../../utils'
|
||||
@@ -368,7 +369,7 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
|
||||
return (
|
||||
<span className={cn('flex min-w-0 items-center gap-1.5', isEditing && 'invisible')}>
|
||||
<img
|
||||
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(kind.domain)}&sz=16`}
|
||||
src={faviconUrl(kind.domain, 16)}
|
||||
alt=''
|
||||
width={12}
|
||||
height={12}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { getLinkPreviewContract, type LinkPreviewResponse } from '@/lib/api/contracts/link-preview'
|
||||
|
||||
/** Previews are near-immutable page metadata; the server also caches for 24h. */
|
||||
export const LINK_PREVIEW_STALE_TIME = 60 * 60 * 1000
|
||||
|
||||
export const linkPreviewKeys = {
|
||||
all: ['link-preview'] as const,
|
||||
details: () => [...linkPreviewKeys.all, 'detail'] as const,
|
||||
detail: (url?: string) => [...linkPreviewKeys.details(), url ?? ''] as const,
|
||||
}
|
||||
|
||||
async function fetchLinkPreview(url: string, signal?: AbortSignal): Promise<LinkPreviewResponse> {
|
||||
return requestJson(getLinkPreviewContract, { query: { url }, signal })
|
||||
}
|
||||
|
||||
/**
|
||||
* OG metadata for an external URL, fetched through the SSRF-hardened
|
||||
* `/api/link-preview` proxy. Fires when the consuming component renders so the
|
||||
* preview is normally cached before the user hovers; results are long-lived
|
||||
* (client staleTime + 24h server-side Redis cache) and failures are not
|
||||
* retried.
|
||||
*/
|
||||
export function useLinkPreview(url?: string) {
|
||||
return useQuery({
|
||||
queryKey: linkPreviewKeys.detail(url),
|
||||
queryFn: ({ signal }) => fetchLinkPreview(url as string, signal),
|
||||
enabled: Boolean(url),
|
||||
staleTime: LINK_PREVIEW_STALE_TIME,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod'
|
||||
import { defineRouteContract } from '@/lib/api/contracts/types'
|
||||
|
||||
export const linkPreviewQuerySchema = z.object({
|
||||
url: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'url is required')
|
||||
.max(2048, 'url must be 2048 characters or less')
|
||||
.url('url must be a valid URL'),
|
||||
})
|
||||
|
||||
export const linkPreviewResponseSchema = z.object({
|
||||
preview: z
|
||||
.object({
|
||||
title: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
siteName: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
})
|
||||
|
||||
export type LinkPreviewQuery = z.input<typeof linkPreviewQuerySchema>
|
||||
export type LinkPreviewResponse = z.output<typeof linkPreviewResponseSchema>
|
||||
export type LinkPreview = LinkPreviewResponse['preview']
|
||||
|
||||
export const getLinkPreviewContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/link-preview',
|
||||
query: linkPreviewQuerySchema,
|
||||
response: { mode: 'json', schema: linkPreviewResponseSchema },
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* URL for a domain's favicon via Google's favicon service.
|
||||
*
|
||||
* @param domain - Bare hostname (e.g. `x.com`), not a full URL
|
||||
* @param size - Requested pixel size; request 2x the display size for retina
|
||||
*/
|
||||
export function faviconUrl(domain: string, size: number): string {
|
||||
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=${size}`
|
||||
}
|
||||
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
|
||||
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
|
||||
|
||||
const BASELINE = {
|
||||
totalRoutes: 963,
|
||||
zodRoutes: 963,
|
||||
totalRoutes: 964,
|
||||
zodRoutes: 964,
|
||||
nonZodRoutes: 0,
|
||||
} as const
|
||||
|
||||
|
||||
Reference in New Issue
Block a user