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
44 changes: 44 additions & 0 deletions apps/sim/app/api/favicon/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { getFaviconContract } from '@/lib/api/contracts/favicon'
import {
defineInternalBinaryRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import type {
BinaryResponseDescriptor,
NoContentResponseDescriptor,
} from '@/lib/api/server/routes/types'
import { getFavicon } from '@/lib/favicon/application'

export const GET = defineInternalBinaryRoute({
contract: getFaviconContract,
auth: internalSessionAuth,
operation: getFavicon.operation,
rateLimit: internalRateLimits.user({
bucketName: 'favicon',
/** A page can render dozens of icons at once; the browser caches each one after its first load. */
config: { maxTokens: 300, refillRate: 300, refillIntervalMs: 60_000 },
}),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ query }) => ({ host: query.host }),
useCase: getFavicon,
/** No icon is an ordinary answer, not an error: 204, which an `<img>` still reports as failed. */
present: (favicon): BinaryResponseDescriptor | NoContentResponseDescriptor =>
favicon.kind === 'hit'
? {
body: new Uint8Array(favicon.bytes),
contentType: favicon.contentType,
contentLength: favicon.bytes.length,
headers: {
'Cache-Control': 'private, max-age=86400, stale-while-revalidate=604800',
'X-Content-Type-Options': 'nosniff',
},
}
: {
status: 204,
headers: {
'Cache-Control': favicon.kind === 'miss' ? 'private, max-age=3600' : 'no-store',
},
},
})
22 changes: 3 additions & 19 deletions apps/sim/app/o/[organizationId]/links/sim-link-icon.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,14 @@
'use client'

import { useState } from 'react'
import { Globe } from '@sim/emcn/icons'
import { faviconUrl } from '@/lib/core/utils/favicon'
import { Favicon } from '@/components/favicon'
import { simLinkOrigin } from '@/lib/sim-links/validation'

interface SimLinkIconProps {
/** The link's destination; a variable link's `{*}` never changes its host. */
url: string
}

/** The destination site's favicon, or a globe when the site has none. */
/** The destination site's favicon, or a globe when neither it nor a parent domain has one. */
export function SimLinkIcon({ url }: SimLinkIconProps) {
const [failedHostname, setFailedHostname] = useState<string | null>(null)
const origin = simLinkOrigin(url)
const hostname = origin ? new URL(origin).hostname : null
if (!hostname || failedHostname === hostname) return <Globe />
return (
<img
src={faviconUrl(hostname, 64)}
alt=''
referrerPolicy='no-referrer'
loading='lazy'
decoding='async'
className='rounded-sm'
onError={() => setFailedHostname(hostname)}
/>
)
return <Favicon host={origin ? new URL(origin).hostname : null} fallback={<Globe />} />
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ function tool(overrides: Partial<ToolCallData> = {}): AgentGroupItem {
}

describe('getBrowserAgentFaviconUrl', () => {
it('keeps the observed origin and port without disclosing page details', () => {
it('keeps only the observed hostname, never credentials, port, path, or query', () => {
expect(
getBrowserAgentFaviconUrl([
tool({
Expand All @@ -59,7 +59,7 @@ describe('getBrowserAgentFaviconUrl', () => {
},
}),
])
).toBe('https://example.com:8443/favicon.ico')
).toBe('/api/favicon?host=example.com')
})

it('ignores URLs from other tools and nested agent runs', () => {
Expand Down Expand Up @@ -87,7 +87,7 @@ describe('getBrowserAgentFaviconUrl', () => {
},
},
])
).toBe('https://example.com/favicon.ico')
).toBe('/api/favicon?host=example.com')
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,17 @@ import { cn } from '@sim/emcn'
import { Globe } from '@sim/emcn/icons'
import { isRecordLike, toRecordOrNull } from '@sim/utils/object'
import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport'
import { faviconSrc } from '@/lib/favicon/url'
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
import { useBrowserSessionStore } from '@/stores/browser-session/store'

/** Only the page's hostname leaves the browser, and only to Sim's favicon route. */
function pageFaviconUrl(url: string): string | null {
try {
const page = new URL(url)
/** External images must use HTTPS under the app's content security policy. */
if (page.protocol !== 'https:') return null
return `${page.origin}/favicon.ico`
} catch {
return null
}
if (!URL.canParse(url)) return null
const page = new URL(url)
return page.protocol === 'https:' || page.protocol === 'http:' ? faviconSrc(page.hostname) : null
}

/** Uses this run's page observations so historical icons never follow another run's live tab. */
Expand Down Expand Up @@ -120,11 +117,10 @@ function BrowserAgentFavicon({ url, canLoad }: BrowserAgentFaviconProps) {

return (
<span className='relative flex size-full items-center justify-center' aria-hidden='true'>
{/** History alone must not contact a site; keep an already loaded image after the tab closes. */}
{/* History alone never looks up a site's icon; keep an already loaded image after the tab closes. */}
{url && status !== 'failed' && (canLoad || status === 'loaded') && (
<img
src={url}
referrerPolicy='no-referrer'
alt=''
className={cn(
'size-full rounded-[3px]',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,6 @@ describe('source hostname branding', () => {
container.innerHTML = renderToStaticMarkup(<SourceIcon source={{ url }} />)
const image = container.querySelector('img')
expect(image).not.toBeNull()
expect(new URL(image!.src).searchParams.get('domain')).toBe(new URL(url).hostname)
expect(new URL(image!.src).searchParams.get('host')).toBe(new URL(url).hostname)
})
})
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
'use client'

import { useState } from 'react'
import { cn } from '@sim/emcn'
import { FileText } from '@sim/emcn/icons'
import { stripVersionSuffix } from '@sim/utils/string'
import { Favicon } from '@/components/favicon'
import { GoogleDocsIcon, GoogleSheetsIcon, GoogleSlidesIcon } from '@/components/icons'
import { faviconUrl } from '@/lib/core/utils/favicon'
import { blockTypeToIconMap } from '@/lib/integrations/icon-mapping'
import { externalLinkHostname } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-link'
import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
Expand Down Expand Up @@ -45,7 +44,6 @@ interface SourceIconProps {

/** Shared document mark, connector mark, favicon and fallback across all source presentations. */
export function SourceIcon({ source, size = 'default' }: SourceIconProps) {
const [failedHostname, setFailedHostname] = useState<string | null>(null)
const hostname = externalLinkHostname(source.url)
const ConnectorIcon = source.connectorType
? BRAND_ICON_BY_BASE_TYPE.get(stripVersionSuffix(source.connectorType))
Expand All @@ -62,16 +60,11 @@ export function SourceIcon({ source, size = 'default' }: SourceIconProps) {
const Icon = DocumentIcon ?? ConnectorIcon ?? HostIcon
const className = cn('shrink-0', size === 'inline' ? 'size-[12px]' : 'size-[14px]')
if (Icon) return <BrandIcon icon={Icon} className={className} />
if (hostname && failedHostname !== hostname) {
return (
<img
src={faviconUrl(hostname, 64)}
alt=''
referrerPolicy='no-referrer'
className={cn(className, 'rounded-sm')}
onError={() => setFailedHostname(hostname)}
/>
)
}
return <FileText aria-hidden className={cn(className, 'text-[var(--text-icon)]')} />
return (
<Favicon
host={hostname}
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
className={className}
fallback={<FileText aria-hidden className={cn(className, 'text-[var(--text-icon)]')} />}
/>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { useTheme } from 'next-themes'
import { createPortal } from 'react-dom'
import { BrowserImportDialog } from '@/components/browser-import/browser-import-dialog'
import { EmptyState } from '@/components/empty-state/empty-state'
import { Favicon } from '@/components/favicon'
import {
onBrowserOmniboxFocusRequest,
takeBrowserOmniboxFocusRequest,
Expand All @@ -58,7 +59,6 @@ import {
showBrowserToolbarMenu,
supportsAtomicBrowserPanelOcclusion,
} from '@/lib/browser-agent/transport'
import { faviconUrl } from '@/lib/core/utils/favicon'
import { getDesktopBridge } from '@/lib/desktop'
import {
loadDesktopBrowserAppearanceTheme,
Expand Down Expand Up @@ -104,20 +104,27 @@ const OMNIBOX_DRAG_THRESHOLD_PX = 4

const suggestionRowId = (index: number) => `${SUGGESTIONS_LIST_ID}-${index}`

/** A browser-imported icon stays local; otherwise the site's favicon. */
function BrowserSuggestionIcon({ suggestion }: { suggestion: UrlSuggestion }) {
const [failed, setFailed] = useState(false)
const source = suggestion.icon || faviconUrl(suggestion.hostname, 32)

if (failed) {
return <Link className='size-4 shrink-0 text-[var(--text-icon)]' />
const [failedIcon, setFailedIcon] = useState<string | null>(null)
const fallback = <Link className='size-4 shrink-0 text-[var(--text-icon)]' />
if (suggestion.icon) {
if (failedIcon === suggestion.icon) return fallback
const icon = suggestion.icon
return (
<img
src={icon}
alt=''
className='size-4 shrink-0 rounded-[3px]'
onError={() => setFailedIcon(icon)}
/>
)
}

return (
<img
src={source}
alt=''
<Favicon
host={suggestion.hostname}
className='size-4 shrink-0 rounded-[3px]'
onError={() => setFailed(true)}
fallback={fallback}
/>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { cn } from '@sim/emcn'
import { CircleAlert, Globe, Loader } from '@sim/emcn/icons'
import { ThinkingLoader } from '@/components/ui'
import { browserTabHostname, shouldShowBrowserTabSpinner } from '@/lib/browser-agent/tab-label'
import { faviconUrl } from '@/lib/core/utils/favicon'
import { faviconSrc } from '@/lib/favicon/url'
import { useBrowserSessionStore } from '@/stores/browser-session/store'

interface BrowserTabIconProps {
Expand Down Expand Up @@ -61,7 +61,7 @@ export function BrowserTabIcon({ tabId, scopeId, className }: BrowserTabIconProp
{hostname && !faviconFailed && (
<img
key={hostname}
src={faviconUrl(hostname, 32)}
src={faviconSrc(hostname)}
alt=''
className={cn(
'size-[16px] rounded-[3px]',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
import type React from 'react'
import { useEffect, useRef, useState } from 'react'
import { Badge, Checkbox, cn, Tooltip } from '@sim/emcn'
import { Globe } from '@sim/emcn/icons'
import { parse } from 'tldts'
import { faviconUrl } from '@/lib/core/utils/favicon'
import { Favicon } from '@/components/favicon'
import type { RowExecutionMetadata, SelectOption } from '@/lib/table'
import { columnTypeOf } from '@/lib/table/column-types'
import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils'
Expand Down Expand Up @@ -407,15 +408,10 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
case 'url':
return (
<span className={cn('flex min-w-0 items-center gap-1.5', isEditing && 'invisible')}>
<img
src={faviconUrl(kind.domain, 16)}
alt=''
width={12}
height={12}
className='shrink-0 rounded-[2px]'
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
<Favicon
host={kind.domain}
className='size-[12px] shrink-0 rounded-[2px]'
fallback={<Globe className='size-[12px] shrink-0 text-[var(--text-icon)]' />}
/>
<a
href={kind.href}
Expand Down
34 changes: 34 additions & 0 deletions apps/sim/components/favicon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use client'

import { type ReactNode, useState } from 'react'
import { cn } from '@sim/emcn'
import { faviconSrc } from '@/lib/favicon/url'

interface FaviconProps {
/** The site's hostname; without one the fallback renders. */
host: string | null | undefined
/** Rendered when there is no host, or when neither the host nor a parent domain has an icon. */
fallback: ReactNode
/** Sizing for the icon; the fallback carries its own. */
className?: string
}

/**
* A site's icon, served by Sim's favicon route: the browser never contacts a third party, a
* private tenant shows its platform's icon, and a site with none renders `fallback` instead
* of a provider's placeholder.
*/
export function Favicon({ host, fallback, className }: FaviconProps) {
const [failedHost, setFailedHost] = useState<string | null>(null)
if (!host || failedHost === host) return fallback
return (
<img
src={faviconSrc(host)}
alt=''
loading='lazy'
decoding='async'
className={cn('rounded-sm', className)}
onError={() => setFailedHost(host)}
/>
)
}
19 changes: 19 additions & 0 deletions apps/sim/lib/api/contracts/favicon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'

/**
* A site's icon by hostname: image bytes, or 204 when none can be served — neither the host nor
* a parent has one, the host is internal, or the providers are unavailable.
*/
export const getFaviconContract = defineRouteContract({
method: 'GET',
path: '/api/favicon',
query: z.object({
host: z
.string()
.trim()
.min(1, 'host is required')
.max(253, 'host must be 253 characters or less'),
}),
response: { mode: 'binary', status: [200, 204] },
})
Loading
Loading