-
Notifications
You must be signed in to change notification settings - Fork 3.7k
feat(chat): favicon external links with secure link-preview tooltips #5734
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
a6dab7a
feat(chat): favicon external links with secure link-preview tooltips
waleedlatif1 bde6f31
fix(link-preview): address review findings
waleedlatif1 242ad88
fix(link-preview): redact full URLs from failure logs
waleedlatif1 e98cc9e
improvement(link-preview): render-time preview fetch, cheerio parsing…
waleedlatif1 3eadb90
fix(link-preview): https-only previews and full-document parsing
waleedlatif1 93eb14e
improvement(chat): render mailto links as plain text
waleedlatif1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', | ||
| }, | ||
| }) | ||
| 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() | ||
|
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 }) | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
79 changes: 79 additions & 0 deletions
79
...e/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }, | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}` | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.