Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions apps/sim/app/api/link-preview/route.ts
Original file line number Diff line number Diff line change
@@ -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',
},
})
Comment thread
waleedlatif1 marked this conversation as resolved.
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()
Comment thread
waleedlatif1 marked this conversation as resolved.
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 })
})
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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}
Expand Down
33 changes: 33 additions & 0 deletions apps/sim/hooks/queries/link-preview.ts
Original file line number Diff line number Diff line change
@@ -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,
})
}
32 changes: 32 additions & 0 deletions apps/sim/lib/api/contracts/link-preview.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 9 additions & 0 deletions apps/sim/lib/core/utils/favicon.ts
Original file line number Diff line number Diff line change
@@ -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}`
}
4 changes: 2 additions & 2 deletions scripts/check-api-validation-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading