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:
Waleed
2026-07-17 10:10:30 -07:00
committed by GitHub
parent a19fce1cd8
commit 7e6aeb2b7a
8 changed files with 295 additions and 3 deletions
+122
View File
@@ -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 })
})
@@ -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}
@@ -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>
)
}
@@ -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}
+33
View File
@@ -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 },
})
+9
View File
@@ -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}`
}
+2 -2
View File
@@ -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