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..5dc8c230df1 --- /dev/null +++ b/apps/sim/app/api/link-preview/route.ts @@ -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 { + 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 }) +}) 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..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 @@ -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 = { js: 'javascript', @@ -268,6 +269,21 @@ const MARKDOWN_COMPONENTS = { ) } + const hostname = externalLinkHostname(href) + if (hostname && href) { + return ( + + {children} + + ) + } + if (href?.startsWith('mailto:')) { + return ( + + {children} + + ) + } return ( ): 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 ( + + + + + + {children} + + + + + {preview ? ( + + {preview.title && {preview.title}} + {preview.description && ( + {preview.description} + )} + {preview.siteName ?? hostname} + + ) : ( + {href} + )} + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx index 81613b3d5db..edc4a92302e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx @@ -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 ( [...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. 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, + }) +} 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