From a6dab7a55aae338566e9845690562a70a5d37350 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 09:23:32 -0700 Subject: [PATCH 1/6] feat(chat): favicon external links with secure link-preview tooltips --- apps/sim/app/api/link-preview/route.ts | 134 ++++++++++++++++++ .../components/chat-content/chat-content.tsx | 81 ++++++++++- .../table-grid/cells/cell-render.tsx | 3 +- apps/sim/hooks/queries/link-preview.ts | 31 ++++ apps/sim/lib/api/contracts/link-preview.ts | 32 +++++ apps/sim/lib/core/utils/favicon.ts | 9 ++ scripts/check-api-validation-contracts.ts | 4 +- 7 files changed, 290 insertions(+), 4 deletions(-) create mode 100644 apps/sim/app/api/link-preview/route.ts create mode 100644 apps/sim/hooks/queries/link-preview.ts create mode 100644 apps/sim/lib/api/contracts/link-preview.ts create mode 100644 apps/sim/lib/core/utils/favicon.ts diff --git a/apps/sim/app/api/link-preview/route.ts b/apps/sim/app/api/link-preview/route.ts new file mode 100644 index 00000000000..3b901807581 --- /dev/null +++ b/apps/sim/app/api/link-preview/route.ts @@ -0,0 +1,134 @@ +import { createLogger } from '@sim/logger' +import { truncate } from '@sim/utils/string' +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 { 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:' + +function decodeHtmlEntities(value: string): string { + return value + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/�?39;/g, "'") + .replace(/'/gi, "'") + .replace(/ /g, ' ') +} + +/** + * Content of a `` tag matched by `property` or `name`, handling either + * attribute order. Only the document head matters for previews, so callers + * pass a head-truncated HTML string. + */ +function metaContent(html: string, key: string): string | null { + const attr = `(?:property|name)=["']${key}["']` + const patterns = [ + new RegExp(`]*${attr}[^>]*content=["']([^"']*)["']`, 'i'), + new RegExp(`]*content=["']([^"']*)["'][^>]*${attr}`, 'i'), + ] + for (const pattern of patterns) { + const match = html.match(pattern) + if (match?.[1]) return decodeHtmlEntities(match[1]).trim() || null + } + return null +} + +function parsePreview(html: string): LinkPreview { + const bodyIndex = html.search(/]/i) + const head = bodyIndex === -1 ? html : html.slice(0, bodyIndex) + + const titleTag = head.match(/]*>([^<]*)<\/title>/i)?.[1] + const title = + metaContent(head, 'og:title') ?? + metaContent(head, 'twitter:title') ?? + (titleTag ? decodeHtmlEntities(titleTag).trim() || null : null) + const description = + metaContent(head, 'og:description') ?? + metaContent(head, 'twitter:description') ?? + metaContent(head, 'description') + const siteName = metaContent(head, '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 { + 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 parsed = await parseRequest(getLinkPreviewContract, request, {}) + if (!parsed.success) return parsed.response + const { url } = parsed.data.query + + const redis = getRedisClient() + const cacheKey = `${CACHE_KEY_PREFIX}${url}` + 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', { url, error }) + } + + 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 }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 2bd4800c6c7..eb71d77398c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -11,8 +11,9 @@ import 'prismjs/components/prism-bash' import 'prismjs/components/prism-css' import 'prismjs/components/prism-markup' import '@sim/emcn/components/code/code.css' -import { Checkbox, CopyCodeButton, cn, highlight, languages } from '@sim/emcn' +import { Checkbox, CopyCodeButton, cn, highlight, languages, Tooltip } from '@sim/emcn' import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils' +import { faviconUrl } from '@/lib/core/utils/favicon' import { extractTextContent } from '@/lib/core/utils/react-node-text' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' import { @@ -22,6 +23,7 @@ import { SpecialTags, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import type { ChatContextKind, MothershipResource } from '@/app/workspace/[workspaceId]/home/types' +import { useLinkPreview } from '@/hooks/queries/link-preview' import { useSmoothText } from '@/hooks/use-smooth-text' import { sanitizeChatDisplayContent } from './chat-sanitize' @@ -160,6 +162,55 @@ function fileIconLabel(ref: string, fallback: string): string { return fallback } +/** Hides a favicon img that failed to load so the link degrades to plain text. */ +function hideBrokenFavicon(e: React.SyntheticEvent): void { + e.currentTarget.style.display = 'none' +} + +interface ExternalLinkTooltipProps { + href: string + hostname: string +} + +/** + * OG-metadata card for an external link's tooltip. Rendered only while the + * tooltip is open (Radix lazy-mounts content), so the preview request fires on + * hover intent; until metadata arrives — or when the site has none — it shows + * the destination URL. + */ +function ExternalLinkTooltip({ href, hostname }: ExternalLinkTooltipProps) { + const { data } = useLinkPreview(href) + const preview = data?.preview + + if (!preview?.title && !preview?.description) { + return {href} + } + + return ( + + {preview.title && {preview.title}} + {preview.description && ( + {preview.description} + )} + {preview.siteName ?? hostname} + + ) +} + +/** + * 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. + */ +function externalLinkHostname(href?: string): string | null { + if (!href || !/^https?:\/\//i.test(href)) return null + try { + return new URL(href).hostname + } catch { + return null + } +} + const MARKDOWN_COMPONENTS = { table({ children }: { children?: React.ReactNode }) { return ( @@ -268,6 +319,34 @@ const MARKDOWN_COMPONENTS = { ) } + const hostname = externalLinkHostname(href) + if (hostname && href) { + return ( + + + + + + {children} + + + + + + + + ) + } return ( [...linkPreviewKeys.all, 'detail'] as const, + detail: (url?: string) => [...linkPreviewKeys.details(), url ?? ''] as const, +} + +async function fetchLinkPreview(url: string, signal?: AbortSignal): Promise { + return requestJson(getLinkPreviewContract, { query: { url }, signal }) +} + +/** + * OG metadata for an external URL, fetched through the SSRF-hardened + * `/api/link-preview` proxy. Mount the consuming component lazily (e.g. inside + * a tooltip that renders on open) so the request only fires on intent. + */ +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, + }) +} diff --git a/apps/sim/lib/api/contracts/link-preview.ts b/apps/sim/lib/api/contracts/link-preview.ts new file mode 100644 index 00000000000..48b705c19e4 --- /dev/null +++ b/apps/sim/lib/api/contracts/link-preview.ts @@ -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 +export type LinkPreviewResponse = z.output +export type LinkPreview = LinkPreviewResponse['preview'] + +export const getLinkPreviewContract = defineRouteContract({ + method: 'GET', + path: '/api/link-preview', + query: linkPreviewQuerySchema, + response: { mode: 'json', schema: linkPreviewResponseSchema }, +}) diff --git a/apps/sim/lib/core/utils/favicon.ts b/apps/sim/lib/core/utils/favicon.ts new file mode 100644 index 00000000000..cb4aaff7910 --- /dev/null +++ b/apps/sim/lib/core/utils/favicon.ts @@ -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}` +} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 449309328c4..6e44238b79f 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -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 From bde6f316af693e3f8b0787c7113143a19649998b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 09:29:23 -0700 Subject: [PATCH 2/6] 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 --- apps/sim/app/api/link-preview/route.ts | 16 ++++++++++++---- .../components/chat-content/chat-content.tsx | 2 +- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/link-preview/route.ts b/apps/sim/app/api/link-preview/route.ts index 3b901807581..5826aaa7fb5 100644 --- a/apps/sim/app/api/link-preview/route.ts +++ b/apps/sim/app/api/link-preview/route.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto' import { createLogger } from '@sim/logger' import { truncate } from '@sim/utils/string' import type { NextRequest } from 'next/server' @@ -7,6 +8,7 @@ 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' @@ -39,13 +41,15 @@ function decodeHtmlEntities(value: string): string { */ function metaContent(html: string, key: string): string | null { const attr = `(?:property|name)=["']${key}["']` + const content = `content=(?:"([^"]*)"|'([^']*)')` const patterns = [ - new RegExp(`]*${attr}[^>]*content=["']([^"']*)["']`, 'i'), - new RegExp(`]*content=["']([^"']*)["'][^>]*${attr}`, 'i'), + new RegExp(`]*${attr}[^>]*${content}`, 'i'), + new RegExp(`]*${content}[^>]*${attr}`, 'i'), ] for (const pattern of patterns) { const match = html.match(pattern) - if (match?.[1]) return decodeHtmlEntities(match[1]).trim() || null + const value = match?.[1] ?? match?.[2] + if (value) return decodeHtmlEntities(value).trim() || null } return null } @@ -75,6 +79,7 @@ function parsePreview(html: string): LinkPreview { async function fetchPreview(url: string): Promise { const response = await secureFetchWithValidation(url, { + allowHttp: true, timeout: FETCH_TIMEOUT_MS, maxRedirects: MAX_REDIRECTS, maxResponseBytes: MAX_RESPONSE_BYTES, @@ -97,12 +102,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 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 const redis = getRedisClient() - const cacheKey = `${CACHE_KEY_PREFIX}${url}` + const cacheKey = `${CACHE_KEY_PREFIX}${createHash('sha256').update(url).digest('hex')}` if (redis) { try { const cached = await redis.get(cacheKey) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index eb71d77398c..e84437668f6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -182,7 +182,7 @@ function ExternalLinkTooltip({ href, hostname }: ExternalLinkTooltipProps) { const { data } = useLinkPreview(href) const preview = data?.preview - if (!preview?.title && !preview?.description) { + if (!preview) { return {href} } From 242ad88ac8ed01e31f13c38ab312e584c9e38ad6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 09:38:40 -0700 Subject: [PATCH 3/6] fix(link-preview): redact full URLs from failure logs --- apps/sim/app/api/link-preview/route.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/link-preview/route.ts b/apps/sim/app/api/link-preview/route.ts index 5826aaa7fb5..cf813a49259 100644 --- a/apps/sim/app/api/link-preview/route.ts +++ b/apps/sim/app/api/link-preview/route.ts @@ -1,5 +1,6 @@ import { createHash } from 'crypto' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { truncate } from '@sim/utils/string' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' @@ -126,7 +127,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { try { preview = await fetchPreview(url) } catch (error) { - logger.info('Link preview fetch failed; returning null preview', { url, 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) { From e98cc9e5a7d0ba12c95266e53eb3bc9295702cbd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 09:57:36 -0700 Subject: [PATCH 4/6] improvement(link-preview): render-time preview fetch, cheerio parsing, cleanup pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/sim/app/api/link-preview/route.ts | 52 +++--------- .../components/chat-content/chat-content.tsx | 80 ++----------------- .../components/chat-content/external-link.tsx | 77 ++++++++++++++++++ apps/sim/hooks/queries/link-preview.ts | 6 +- 4 files changed, 99 insertions(+), 116 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx diff --git a/apps/sim/app/api/link-preview/route.ts b/apps/sim/app/api/link-preview/route.ts index cf813a49259..4723eaaa34b 100644 --- a/apps/sim/app/api/link-preview/route.ts +++ b/apps/sim/app/api/link-preview/route.ts @@ -2,6 +2,7 @@ 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' @@ -24,51 +25,24 @@ const CACHE_TTL_SECONDS = 24 * 60 * 60 const NEGATIVE_CACHE_TTL_SECONDS = 60 * 60 const CACHE_KEY_PREFIX = 'link-preview:v1:' -function decodeHtmlEntities(value: string): string { - return value - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/�?39;/g, "'") - .replace(/'/gi, "'") - .replace(/ /g, ' ') -} - /** - * Content of a `` tag matched by `property` or `name`, handling either - * attribute order. Only the document head matters for previews, so callers - * pass a head-truncated HTML string. + * Parses preview metadata from the document head. Only the head matters for + * previews, so the input is truncated at `` before parsing; cheerio + * handles attribute order, quoting, and entity decoding. */ -function metaContent(html: string, key: string): string | null { - const attr = `(?:property|name)=["']${key}["']` - const content = `content=(?:"([^"]*)"|'([^']*)')` - const patterns = [ - new RegExp(`]*${attr}[^>]*${content}`, 'i'), - new RegExp(`]*${content}[^>]*${attr}`, 'i'), - ] - for (const pattern of patterns) { - const match = html.match(pattern) - const value = match?.[1] ?? match?.[2] - if (value) return decodeHtmlEntities(value).trim() || null - } - return null -} - function parsePreview(html: string): LinkPreview { const bodyIndex = html.search(/]/i) - const head = bodyIndex === -1 ? html : html.slice(0, bodyIndex) + const $ = cheerio.load(bodyIndex === -1 ? html : html.slice(0, bodyIndex)) + + const meta = (key: string): string | null => { + const value = $(`meta[property="${key}"], meta[name="${key}"]`).first().attr('content') + return value?.trim() || null + } - const titleTag = head.match(/]*>([^<]*)<\/title>/i)?.[1] const title = - metaContent(head, 'og:title') ?? - metaContent(head, 'twitter:title') ?? - (titleTag ? decodeHtmlEntities(titleTag).trim() || null : null) - const description = - metaContent(head, 'og:description') ?? - metaContent(head, 'twitter:description') ?? - metaContent(head, 'description') - const siteName = metaContent(head, 'og:site_name') + 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 { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index e84437668f6..0991dc94c92 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -11,9 +11,8 @@ import 'prismjs/components/prism-bash' import 'prismjs/components/prism-css' import 'prismjs/components/prism-markup' import '@sim/emcn/components/code/code.css' -import { Checkbox, CopyCodeButton, cn, highlight, languages, Tooltip } from '@sim/emcn' +import { Checkbox, CopyCodeButton, cn, highlight, languages } from '@sim/emcn' import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils' -import { faviconUrl } from '@/lib/core/utils/favicon' import { extractTextContent } from '@/lib/core/utils/react-node-text' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' import { @@ -23,9 +22,9 @@ import { SpecialTags, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import type { ChatContextKind, MothershipResource } from '@/app/workspace/[workspaceId]/home/types' -import { useLinkPreview } from '@/hooks/queries/link-preview' import { useSmoothText } from '@/hooks/use-smooth-text' import { sanitizeChatDisplayContent } from './chat-sanitize' +import { ExternalLink, externalLinkHostname } from './external-link' const LANG_ALIASES: Record = { js: 'javascript', @@ -162,55 +161,6 @@ function fileIconLabel(ref: string, fallback: string): string { return fallback } -/** Hides a favicon img that failed to load so the link degrades to plain text. */ -function hideBrokenFavicon(e: React.SyntheticEvent): void { - e.currentTarget.style.display = 'none' -} - -interface ExternalLinkTooltipProps { - href: string - hostname: string -} - -/** - * OG-metadata card for an external link's tooltip. Rendered only while the - * tooltip is open (Radix lazy-mounts content), so the preview request fires on - * hover intent; until metadata arrives — or when the site has none — it shows - * the destination URL. - */ -function ExternalLinkTooltip({ href, hostname }: ExternalLinkTooltipProps) { - const { data } = useLinkPreview(href) - const preview = data?.preview - - if (!preview) { - return {href} - } - - return ( - - {preview.title && {preview.title}} - {preview.description && ( - {preview.description} - )} - {preview.siteName ?? hostname} - - ) -} - -/** - * 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. - */ -function externalLinkHostname(href?: string): string | null { - if (!href || !/^https?:\/\//i.test(href)) return null - try { - return new URL(href).hostname - } catch { - return null - } -} - const MARKDOWN_COMPONENTS = { table({ children }: { children?: React.ReactNode }) { return ( @@ -322,29 +272,9 @@ const MARKDOWN_COMPONENTS = { const hostname = externalLinkHostname(href) if (hostname && href) { return ( - - - - - - {children} - - - - - - - + + {children} + ) } return ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx new file mode 100644 index 00000000000..5f046876f71 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx @@ -0,0 +1,77 @@ +'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): 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. + */ +export function ExternalLink({ href, hostname, children }: ExternalLinkProps) { + const { data } = useLinkPreview(href) + const preview = data?.preview + + return ( + + + + + + {children} + + + + + {preview ? ( + + {preview.title && {preview.title}} + {preview.description && ( + {preview.description} + )} + {preview.siteName ?? hostname} + + ) : ( + {href} + )} + + + ) +} diff --git a/apps/sim/hooks/queries/link-preview.ts b/apps/sim/hooks/queries/link-preview.ts index e8f0a5f756a..9e7ecd8f35d 100644 --- a/apps/sim/hooks/queries/link-preview.ts +++ b/apps/sim/hooks/queries/link-preview.ts @@ -17,8 +17,10 @@ async function fetchLinkPreview(url: string, signal?: AbortSignal): Promise Date: Fri, 17 Jul 2026 10:05:50 -0700 Subject: [PATCH 5/6] 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 substring, which could match inside head scripts/comments and drop metadata --- apps/sim/app/api/link-preview/route.ts | 14 ++++++++------ .../components/chat-content/external-link.tsx | 6 ++++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/api/link-preview/route.ts b/apps/sim/app/api/link-preview/route.ts index 4723eaaa34b..5dc8c230df1 100644 --- a/apps/sim/app/api/link-preview/route.ts +++ b/apps/sim/app/api/link-preview/route.ts @@ -26,13 +26,12 @@ const NEGATIVE_CACHE_TTL_SECONDS = 60 * 60 const CACHE_KEY_PREFIX = 'link-preview:v1:' /** - * Parses preview metadata from the document head. Only the head matters for - * previews, so the input is truncated at `` before parsing; cheerio - * handles attribute order, quoting, and entity decoding. + * 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 bodyIndex = html.search(/]/i) - const $ = cheerio.load(bodyIndex === -1 ? html : html.slice(0, bodyIndex)) + const $ = cheerio.load(html) const meta = (key: string): string | null => { const value = $(`meta[property="${key}"], meta[name="${key}"]`).first().attr('content') @@ -54,7 +53,6 @@ function parsePreview(html: string): LinkPreview { async function fetchPreview(url: string): Promise { const response = await secureFetchWithValidation(url, { - allowHttp: true, timeout: FETCH_TIMEOUT_MS, maxRedirects: MAX_REDIRECTS, maxResponseBytes: MAX_RESPONSE_BYTES, @@ -84,6 +82,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 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) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx index 5f046876f71..0aa0e1a607b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx @@ -33,10 +33,12 @@ interface ExternalLinkProps { * 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. + * 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) + const { data } = useLinkPreview(href.startsWith('https://') ? href : undefined) const preview = data?.preview return ( From 93eb14e0966f24ce09f3da214c162bdb22e6dc13 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 10:10:11 -0700 Subject: [PATCH 6/6] improvement(chat): render mailto links as plain text --- .../components/chat-content/chat-content.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 0991dc94c92..0a25cc37a23 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -277,6 +277,13 @@ const MARKDOWN_COMPONENTS = { ) } + if (href?.startsWith('mailto:')) { + return ( + + {children} + + ) + } return (