Skip to content

Commit 7e6aeb2

Browse files
authored
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
1 parent a19fce1 commit 7e6aeb2

8 files changed

Lines changed: 295 additions & 3 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { createHash } from 'crypto'
2+
import { createLogger } from '@sim/logger'
3+
import { getErrorMessage } from '@sim/utils/errors'
4+
import { truncate } from '@sim/utils/string'
5+
import * as cheerio from 'cheerio'
6+
import type { NextRequest } from 'next/server'
7+
import { NextResponse } from 'next/server'
8+
import type { LinkPreview } from '@/lib/api/contracts/link-preview'
9+
import { getLinkPreviewContract } from '@/lib/api/contracts/link-preview'
10+
import { parseRequest } from '@/lib/api/server'
11+
import { getSession } from '@/lib/auth'
12+
import { getRedisClient } from '@/lib/core/config/redis'
13+
import { enforceUserRateLimit } from '@/lib/core/rate-limiter/route-helpers'
14+
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
15+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
16+
17+
const logger = createLogger('LinkPreviewAPI')
18+
19+
const FETCH_TIMEOUT_MS = 5000
20+
const MAX_RESPONSE_BYTES = 256 * 1024
21+
const MAX_REDIRECTS = 3
22+
const TITLE_MAX_CHARS = 200
23+
const DESCRIPTION_MAX_CHARS = 300
24+
const CACHE_TTL_SECONDS = 24 * 60 * 60
25+
const NEGATIVE_CACHE_TTL_SECONDS = 60 * 60
26+
const CACHE_KEY_PREFIX = 'link-preview:v1:'
27+
28+
/**
29+
* Parses preview metadata from the fetched document (already capped at
30+
* MAX_RESPONSE_BYTES); cheerio handles attribute order, quoting, and entity
31+
* decoding.
32+
*/
33+
function parsePreview(html: string): LinkPreview {
34+
const $ = cheerio.load(html)
35+
36+
const meta = (key: string): string | null => {
37+
const value = $(`meta[property="${key}"], meta[name="${key}"]`).first().attr('content')
38+
return value?.trim() || null
39+
}
40+
41+
const title =
42+
meta('og:title') ?? meta('twitter:title') ?? ($('title').first().text().trim() || null)
43+
const description = meta('og:description') ?? meta('twitter:description') ?? meta('description')
44+
const siteName = meta('og:site_name')
45+
46+
if (!title && !description && !siteName) return null
47+
return {
48+
title: title ? truncate(title, TITLE_MAX_CHARS) : null,
49+
description: description ? truncate(description, DESCRIPTION_MAX_CHARS) : null,
50+
siteName: siteName ? truncate(siteName, TITLE_MAX_CHARS) : null,
51+
}
52+
}
53+
54+
async function fetchPreview(url: string): Promise<LinkPreview> {
55+
const response = await secureFetchWithValidation(url, {
56+
timeout: FETCH_TIMEOUT_MS,
57+
maxRedirects: MAX_REDIRECTS,
58+
maxResponseBytes: MAX_RESPONSE_BYTES,
59+
headers: {
60+
'User-Agent': 'Simbot/1.0 (+https://sim.ai)',
61+
Accept: 'text/html,application/xhtml+xml',
62+
},
63+
})
64+
if (response.status < 200 || response.status >= 300) return null
65+
const contentType = response.headers.get('content-type') ?? ''
66+
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml+xml')) {
67+
return null
68+
}
69+
return parsePreview(await response.text())
70+
}
71+
72+
export const GET = withRouteHandler(async (request: NextRequest) => {
73+
const session = await getSession()
74+
if (!session?.user?.id) {
75+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
76+
}
77+
78+
const rateLimited = await enforceUserRateLimit('link-preview', session.user.id)
79+
if (rateLimited) return rateLimited
80+
81+
const parsed = await parseRequest(getLinkPreviewContract, request, {})
82+
if (!parsed.success) return parsed.response
83+
const { url } = parsed.data.query
84+
85+
if (!url.startsWith('https://')) {
86+
return NextResponse.json({ preview: null })
87+
}
88+
89+
const redis = getRedisClient()
90+
const cacheKey = `${CACHE_KEY_PREFIX}${createHash('sha256').update(url).digest('hex')}`
91+
if (redis) {
92+
try {
93+
const cached = await redis.get(cacheKey)
94+
if (cached !== null) {
95+
return NextResponse.json({ preview: JSON.parse(cached) })
96+
}
97+
} catch (error) {
98+
logger.warn('Link preview cache read failed', { error })
99+
}
100+
}
101+
102+
let preview: LinkPreview = null
103+
try {
104+
preview = await fetchPreview(url)
105+
} catch (error) {
106+
logger.info('Link preview fetch failed; returning null preview', {
107+
host: new URL(url).hostname,
108+
error: getErrorMessage(error, 'unknown error').replaceAll(url, '[url]'),
109+
})
110+
}
111+
112+
if (redis) {
113+
const ttl = preview ? CACHE_TTL_SECONDS : NEGATIVE_CACHE_TTL_SECONDS
114+
try {
115+
await redis.set(cacheKey, JSON.stringify(preview), 'EX', ttl)
116+
} catch (error) {
117+
logger.warn('Link preview cache write failed', { error })
118+
}
119+
}
120+
121+
return NextResponse.json({ preview })
122+
})

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
import type { ChatContextKind, MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
2525
import { useSmoothText } from '@/hooks/use-smooth-text'
2626
import { sanitizeChatDisplayContent } from './chat-sanitize'
27+
import { ExternalLink, externalLinkHostname } from './external-link'
2728

2829
const LANG_ALIASES: Record<string, string> = {
2930
js: 'javascript',
@@ -268,6 +269,21 @@ const MARKDOWN_COMPONENTS = {
268269
</a>
269270
)
270271
}
272+
const hostname = externalLinkHostname(href)
273+
if (hostname && href) {
274+
return (
275+
<ExternalLink href={href} hostname={hostname}>
276+
{children}
277+
</ExternalLink>
278+
)
279+
}
280+
if (href?.startsWith('mailto:')) {
281+
return (
282+
<a href={href} className='not-prose text-[var(--text-primary)] no-underline'>
283+
{children}
284+
</a>
285+
)
286+
}
271287
return (
272288
<a
273289
href={href}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
'use client'
2+
3+
import { Tooltip } from '@sim/emcn'
4+
import { faviconUrl } from '@/lib/core/utils/favicon'
5+
import { useLinkPreview } from '@/hooks/queries/link-preview'
6+
7+
/** Hides a favicon img that failed to load so the link degrades to plain text. */
8+
function hideBrokenFavicon(e: React.SyntheticEvent<HTMLImageElement>): void {
9+
e.currentTarget.style.display = 'none'
10+
}
11+
12+
/**
13+
* Hostname for an external http(s) link, used to fetch its favicon. Returns
14+
* null for relative, anchor, mailto, and unparsable hrefs so those keep the
15+
* plain underlined treatment.
16+
*/
17+
export function externalLinkHostname(href?: string): string | null {
18+
if (!href || !/^https?:\/\//i.test(href)) return null
19+
try {
20+
return new URL(href).hostname
21+
} catch {
22+
return null
23+
}
24+
}
25+
26+
interface ExternalLinkProps {
27+
href: string
28+
hostname: string
29+
children?: React.ReactNode
30+
}
31+
32+
/**
33+
* Favicon + quiet-underline external link with an OG-preview tooltip. The
34+
* preview query fires when the link renders, so metadata is normally cached
35+
* (client and server side) before the first hover; the tooltip shows the
36+
* destination URL until metadata arrives or when the site has none. Previews
37+
* are https-only — plain-http links keep the URL tooltip, since fetching them
38+
* server-side would reach the URL validator's self-host loopback exception.
39+
*/
40+
export function ExternalLink({ href, hostname, children }: ExternalLinkProps) {
41+
const { data } = useLinkPreview(href.startsWith('https://') ? href : undefined)
42+
const preview = data?.preview
43+
44+
return (
45+
<Tooltip.Root>
46+
<Tooltip.Trigger asChild>
47+
<a
48+
href={href}
49+
className='not-prose group text-[var(--text-primary)] no-underline'
50+
target='_blank'
51+
rel='noopener noreferrer'
52+
>
53+
<img
54+
src={faviconUrl(hostname, 32)}
55+
alt=''
56+
className='relative top-[0.5px] mr-[2px] inline size-[12px] rounded-[3px]'
57+
onError={hideBrokenFavicon}
58+
/>
59+
<span className='underline decoration-[color:var(--text-muted)] underline-offset-4 transition-colors group-hover:decoration-[color:var(--text-primary)]'>
60+
{children}
61+
</span>
62+
</a>
63+
</Tooltip.Trigger>
64+
<Tooltip.Content>
65+
{preview ? (
66+
<span className='flex flex-col gap-0.5'>
67+
{preview.title && <span className='font-medium'>{preview.title}</span>}
68+
{preview.description && (
69+
<span className='line-clamp-2 text-[var(--text-muted)]'>{preview.description}</span>
70+
)}
71+
<span className='text-[var(--text-muted)]'>{preview.siteName ?? hostname}</span>
72+
</span>
73+
) : (
74+
<span className='break-all'>{href}</span>
75+
)}
76+
</Tooltip.Content>
77+
</Tooltip.Root>
78+
)
79+
}

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type React from 'react'
44
import { useEffect, useRef, useState } from 'react'
55
import { Badge, Checkbox, cn, Tooltip } from '@sim/emcn'
66
import { parse } from 'tldts'
7+
import { faviconUrl } from '@/lib/core/utils/favicon'
78
import type { RowExecutionMetadata } from '@/lib/table'
89
import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils'
910
import { storageToDisplay } from '../../../utils'
@@ -368,7 +369,7 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
368369
return (
369370
<span className={cn('flex min-w-0 items-center gap-1.5', isEditing && 'invisible')}>
370371
<img
371-
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(kind.domain)}&sz=16`}
372+
src={faviconUrl(kind.domain, 16)}
372373
alt=''
373374
width={12}
374375
height={12}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useQuery } from '@tanstack/react-query'
2+
import { requestJson } from '@/lib/api/client/request'
3+
import { getLinkPreviewContract, type LinkPreviewResponse } from '@/lib/api/contracts/link-preview'
4+
5+
/** Previews are near-immutable page metadata; the server also caches for 24h. */
6+
export const LINK_PREVIEW_STALE_TIME = 60 * 60 * 1000
7+
8+
export const linkPreviewKeys = {
9+
all: ['link-preview'] as const,
10+
details: () => [...linkPreviewKeys.all, 'detail'] as const,
11+
detail: (url?: string) => [...linkPreviewKeys.details(), url ?? ''] as const,
12+
}
13+
14+
async function fetchLinkPreview(url: string, signal?: AbortSignal): Promise<LinkPreviewResponse> {
15+
return requestJson(getLinkPreviewContract, { query: { url }, signal })
16+
}
17+
18+
/**
19+
* OG metadata for an external URL, fetched through the SSRF-hardened
20+
* `/api/link-preview` proxy. Fires when the consuming component renders so the
21+
* preview is normally cached before the user hovers; results are long-lived
22+
* (client staleTime + 24h server-side Redis cache) and failures are not
23+
* retried.
24+
*/
25+
export function useLinkPreview(url?: string) {
26+
return useQuery({
27+
queryKey: linkPreviewKeys.detail(url),
28+
queryFn: ({ signal }) => fetchLinkPreview(url as string, signal),
29+
enabled: Boolean(url),
30+
staleTime: LINK_PREVIEW_STALE_TIME,
31+
retry: false,
32+
})
33+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { z } from 'zod'
2+
import { defineRouteContract } from '@/lib/api/contracts/types'
3+
4+
export const linkPreviewQuerySchema = z.object({
5+
url: z
6+
.string()
7+
.trim()
8+
.min(1, 'url is required')
9+
.max(2048, 'url must be 2048 characters or less')
10+
.url('url must be a valid URL'),
11+
})
12+
13+
export const linkPreviewResponseSchema = z.object({
14+
preview: z
15+
.object({
16+
title: z.string().nullable(),
17+
description: z.string().nullable(),
18+
siteName: z.string().nullable(),
19+
})
20+
.nullable(),
21+
})
22+
23+
export type LinkPreviewQuery = z.input<typeof linkPreviewQuerySchema>
24+
export type LinkPreviewResponse = z.output<typeof linkPreviewResponseSchema>
25+
export type LinkPreview = LinkPreviewResponse['preview']
26+
27+
export const getLinkPreviewContract = defineRouteContract({
28+
method: 'GET',
29+
path: '/api/link-preview',
30+
query: linkPreviewQuerySchema,
31+
response: { mode: 'json', schema: linkPreviewResponseSchema },
32+
})

apps/sim/lib/core/utils/favicon.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* URL for a domain's favicon via Google's favicon service.
3+
*
4+
* @param domain - Bare hostname (e.g. `x.com`), not a full URL
5+
* @param size - Requested pixel size; request 2x the display size for retina
6+
*/
7+
export function faviconUrl(domain: string, size: number): string {
8+
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=${size}`
9+
}

scripts/check-api-validation-contracts.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
99
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
1010

1111
const BASELINE = {
12-
totalRoutes: 963,
13-
zodRoutes: 963,
12+
totalRoutes: 964,
13+
zodRoutes: 964,
1414
nonZodRoutes: 0,
1515
} as const
1616

0 commit comments

Comments
 (0)