Skip to content

Commit 6c5ba2e

Browse files
committed
feat(favicons): resolve every site icon through one server route with parent-domain fallback
Icons were requested straight from Google in the browser, which shows Google's placeholder globe for any host it can't see (a 404 with an image body that an <img> treats as success), so private tenants and internal hosts rendered a generic placeholder. GET /api/favicon asks Google and DuckDuckGo server-side, where the 404 is visible, walks up to the parent domain so a tenant shows its platform's icon, never sends internal hosts or the destination itself anything, and caches results in Redis. Every surface now uses the shared <Favicon> or faviconSrc, and binary internal routes accept a user rate limit.
1 parent 873489c commit 6c5ba2e

20 files changed

Lines changed: 479 additions & 90 deletions

File tree

‎apps/sim/app/api/favicon/route.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { getFaviconContract } from '@/lib/api/contracts/favicon'
2+
import {
3+
defineInternalBinaryRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { getFavicon } from '@/lib/favicon/application'
9+
10+
export const GET = defineInternalBinaryRoute({
11+
contract: getFaviconContract,
12+
auth: internalSessionAuth,
13+
operation: getFavicon.operation,
14+
rateLimit: internalRateLimits.user({
15+
bucketName: 'favicon',
16+
/** A page can render dozens of icons at once; the browser caches each one after its first load. */
17+
config: { maxTokens: 300, refillRate: 300, refillIntervalMs: 60_000 },
18+
}),
19+
errorPolicy: internalOrchestrationErrorPolicy,
20+
mapInput: ({ query }) => ({ host: query.host }),
21+
useCase: getFavicon,
22+
present: ({ bytes, contentType }) => ({
23+
body: new Uint8Array(bytes),
24+
contentType,
25+
contentLength: bytes.length,
26+
headers: {
27+
'Cache-Control': 'private, max-age=86400, stale-while-revalidate=604800',
28+
'X-Content-Type-Options': 'nosniff',
29+
},
30+
}),
31+
})
Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,14 @@
1-
'use client'
2-
3-
import { useState } from 'react'
41
import { Globe } from '@sim/emcn/icons'
5-
import { faviconUrl } from '@/lib/core/utils/favicon'
2+
import { Favicon } from '@/components/favicon'
63
import { simLinkOrigin } from '@/lib/sim-links/validation'
74

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

13-
/** The destination site's favicon, or a globe when the site has none. */
10+
/** The destination site's favicon, or a globe when neither it nor a parent domain has one. */
1411
export function SimLinkIcon({ url }: SimLinkIconProps) {
15-
const [failedHostname, setFailedHostname] = useState<string | null>(null)
1612
const origin = simLinkOrigin(url)
17-
const hostname = origin ? new URL(origin).hostname : null
18-
if (!hostname || failedHostname === hostname) return <Globe />
19-
return (
20-
<img
21-
src={faviconUrl(hostname, 64)}
22-
alt=''
23-
referrerPolicy='no-referrer'
24-
loading='lazy'
25-
decoding='async'
26-
className='rounded-sm'
27-
onError={() => setFailedHostname(hostname)}
28-
/>
29-
)
13+
return <Favicon host={origin ? new URL(origin).hostname : null} fallback={<Globe />} />
3014
}

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ function tool(overrides: Partial<ToolCallData> = {}): AgentGroupItem {
4747
}
4848

4949
describe('getBrowserAgentFaviconUrl', () => {
50-
it('keeps the observed origin and port without disclosing page details', () => {
50+
it('keeps only the observed hostname, never credentials, port, path, or query', () => {
5151
expect(
5252
getBrowserAgentFaviconUrl([
5353
tool({
@@ -59,7 +59,7 @@ describe('getBrowserAgentFaviconUrl', () => {
5959
},
6060
}),
6161
])
62-
).toBe('https://example.com:8443/favicon.ico')
62+
).toBe('/api/favicon?host=example.com')
6363
})
6464

6565
it('ignores URLs from other tools and nested agent runs', () => {
@@ -87,7 +87,7 @@ describe('getBrowserAgentFaviconUrl', () => {
8787
},
8888
},
8989
])
90-
).toBe('https://example.com/favicon.ico')
90+
).toBe('/api/favicon?host=example.com')
9191
})
9292
})
9393

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx‎

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,17 @@ import { cn } from '@sim/emcn'
66
import { Globe } from '@sim/emcn/icons'
77
import { isRecordLike, toRecordOrNull } from '@sim/utils/object'
88
import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport'
9+
import { faviconSrc } from '@/lib/favicon/url'
910
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
1011
import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
1112
import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
1213
import { useBrowserSessionStore } from '@/stores/browser-session/store'
1314

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

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

121118
return (
122119
<span className='relative flex size-full items-center justify-center' aria-hidden='true'>
123-
{/** History alone must not contact a site; keep an already loaded image after the tab closes. */}
120+
{/* History alone never looks up a site's icon; keep an already loaded image after the tab closes. */}
124121
{url && status !== 'failed' && (canLoad || status === 'loaded') && (
125122
<img
126123
src={url}
127-
referrerPolicy='no-referrer'
128124
alt=''
129125
className={cn(
130126
'size-full rounded-[3px]',

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-icon.tsx‎

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
'use client'
22

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

4645
/** Shared document mark, connector mark, favicon and fallback across all source presentations. */
4746
export function SourceIcon({ source, size = 'default' }: SourceIconProps) {
48-
const [failedHostname, setFailedHostname] = useState<string | null>(null)
4947
const hostname = externalLinkHostname(source.url)
5048
const ConnectorIcon = source.connectorType
5149
? BRAND_ICON_BY_BASE_TYPE.get(stripVersionSuffix(source.connectorType))
@@ -62,16 +60,11 @@ export function SourceIcon({ source, size = 'default' }: SourceIconProps) {
6260
const Icon = DocumentIcon ?? ConnectorIcon ?? HostIcon
6361
const className = cn('shrink-0', size === 'inline' ? 'size-[12px]' : 'size-[14px]')
6462
if (Icon) return <BrandIcon icon={Icon} className={className} />
65-
if (hostname && failedHostname !== hostname) {
66-
return (
67-
<img
68-
src={faviconUrl(hostname, 64)}
69-
alt=''
70-
referrerPolicy='no-referrer'
71-
className={cn(className, 'rounded-sm')}
72-
onError={() => setFailedHostname(hostname)}
73-
/>
74-
)
75-
}
76-
return <FileText aria-hidden className={cn(className, 'text-[var(--text-icon)]')} />
63+
return (
64+
<Favicon
65+
host={hostname}
66+
className={className}
67+
fallback={<FileText aria-hidden className={cn(className, 'text-[var(--text-icon)]')} />}
68+
/>
69+
)
7770
}

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { useTheme } from 'next-themes'
3434
import { createPortal } from 'react-dom'
3535
import { BrowserImportDialog } from '@/components/browser-import/browser-import-dialog'
3636
import { EmptyState } from '@/components/empty-state/empty-state'
37+
import { Favicon } from '@/components/favicon'
3738
import {
3839
onBrowserOmniboxFocusRequest,
3940
takeBrowserOmniboxFocusRequest,
@@ -58,7 +59,6 @@ import {
5859
showBrowserToolbarMenu,
5960
supportsAtomicBrowserPanelOcclusion,
6061
} from '@/lib/browser-agent/transport'
61-
import { faviconUrl } from '@/lib/core/utils/favicon'
6262
import { getDesktopBridge } from '@/lib/desktop'
6363
import {
6464
loadDesktopBrowserAppearanceTheme,
@@ -104,20 +104,27 @@ const OMNIBOX_DRAG_THRESHOLD_PX = 4
104104

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

107+
/** A browser-imported icon stays local; otherwise the site's favicon. */
107108
function BrowserSuggestionIcon({ suggestion }: { suggestion: UrlSuggestion }) {
108-
const [failed, setFailed] = useState(false)
109-
const source = suggestion.icon || faviconUrl(suggestion.hostname, 32)
110-
111-
if (failed) {
112-
return <Link className='size-4 shrink-0 text-[var(--text-icon)]' />
109+
const [failedIcon, setFailedIcon] = useState<string | null>(null)
110+
const fallback = <Link className='size-4 shrink-0 text-[var(--text-icon)]' />
111+
if (suggestion.icon) {
112+
if (failedIcon === suggestion.icon) return fallback
113+
const icon = suggestion.icon
114+
return (
115+
<img
116+
src={icon}
117+
alt=''
118+
className='size-4 shrink-0 rounded-[3px]'
119+
onError={() => setFailedIcon(icon)}
120+
/>
121+
)
113122
}
114-
115123
return (
116-
<img
117-
src={source}
118-
alt=''
124+
<Favicon
125+
host={suggestion.hostname}
119126
className='size-4 shrink-0 rounded-[3px]'
120-
onError={() => setFailed(true)}
127+
fallback={fallback}
121128
/>
122129
)
123130
}

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/browser-tab-icon.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { cn } from '@sim/emcn'
55
import { CircleAlert, Globe, Loader } from '@sim/emcn/icons'
66
import { ThinkingLoader } from '@/components/ui'
77
import { browserTabHostname, shouldShowBrowserTabSpinner } from '@/lib/browser-agent/tab-label'
8-
import { faviconUrl } from '@/lib/core/utils/favicon'
8+
import { faviconSrc } from '@/lib/favicon/url'
99
import { useBrowserSessionStore } from '@/stores/browser-session/store'
1010

1111
interface BrowserTabIconProps {
@@ -61,7 +61,7 @@ export function BrowserTabIcon({ tabId, scopeId, className }: BrowserTabIconProp
6161
{hostname && !faviconFailed && (
6262
<img
6363
key={hostname}
64-
src={faviconUrl(hostname, 32)}
64+
src={faviconSrc(hostname)}
6565
alt=''
6666
className={cn(
6767
'size-[16px] rounded-[3px]',

‎apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx‎

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
import type React from 'react'
44
import { useEffect, useRef, useState } from 'react'
55
import { Badge, Checkbox, cn, Tooltip } from '@sim/emcn'
6+
import { Globe } from '@sim/emcn/icons'
67
import { parse } from 'tldts'
7-
import { faviconUrl } from '@/lib/core/utils/favicon'
8+
import { Favicon } from '@/components/favicon'
89
import type { RowExecutionMetadata, SelectOption } from '@/lib/table'
910
import { columnTypeOf } from '@/lib/table/column-types'
1011
import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils'
@@ -407,15 +408,10 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
407408
case 'url':
408409
return (
409410
<span className={cn('flex min-w-0 items-center gap-1.5', isEditing && 'invisible')}>
410-
<img
411-
src={faviconUrl(kind.domain, 16)}
412-
alt=''
413-
width={12}
414-
height={12}
415-
className='shrink-0 rounded-[2px]'
416-
onError={(e) => {
417-
e.currentTarget.style.display = 'none'
418-
}}
411+
<Favicon
412+
host={kind.domain}
413+
className='size-[12px] shrink-0 rounded-[2px]'
414+
fallback={<Globe className='size-[12px] shrink-0 text-[var(--text-icon)]' />}
419415
/>
420416
<a
421417
href={kind.href}

‎apps/sim/components/favicon.tsx‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
'use client'
2+
3+
import { type ReactNode, useState } from 'react'
4+
import { cn } from '@sim/emcn'
5+
import { faviconSrc } from '@/lib/favicon/url'
6+
7+
interface FaviconProps {
8+
/** The site's hostname; without one the fallback renders. */
9+
host: string | null | undefined
10+
/** Rendered when there is no host, or when neither the host nor a parent domain has an icon. */
11+
fallback: ReactNode
12+
/** Sizing for the icon; the fallback carries its own. */
13+
className?: string
14+
}
15+
16+
/**
17+
* A site's icon, served by Sim's favicon route: the browser never contacts a third party, a
18+
* private tenant shows its platform's icon, and a site with none renders `fallback` instead
19+
* of a provider's placeholder.
20+
*/
21+
export function Favicon({ host, fallback, className }: FaviconProps) {
22+
const [failedHost, setFailedHost] = useState<string | null>(null)
23+
if (!host || failedHost === host) return fallback
24+
return (
25+
<img
26+
src={faviconSrc(host)}
27+
alt=''
28+
loading='lazy'
29+
decoding='async'
30+
className={cn('rounded-sm', className)}
31+
onError={() => setFailedHost(host)}
32+
/>
33+
)
34+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { z } from 'zod'
2+
import { defineRouteContract } from '@/lib/api/contracts/types'
3+
4+
/**
5+
* A site's icon by hostname: image bytes, or 404 when none can be served — neither the host nor
6+
* a parent has one, the host is internal, or the providers are unavailable.
7+
*/
8+
export const getFaviconContract = defineRouteContract({
9+
method: 'GET',
10+
path: '/api/favicon',
11+
query: z.object({
12+
host: z
13+
.string()
14+
.trim()
15+
.min(1, 'host is required')
16+
.max(253, 'host must be 253 characters or less'),
17+
}),
18+
response: { mode: 'binary' },
19+
})

0 commit comments

Comments
 (0)