diff --git a/apps/sim/app/api/favicon/route.ts b/apps/sim/app/api/favicon/route.ts new file mode 100644 index 00000000000..1a52f8417b1 --- /dev/null +++ b/apps/sim/app/api/favicon/route.ts @@ -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 `` 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', + }, + }, +}) diff --git a/apps/sim/app/o/[organizationId]/links/sim-link-icon.tsx b/apps/sim/app/o/[organizationId]/links/sim-link-icon.tsx index 4849b22ed33..f28f247f9ce 100644 --- a/apps/sim/app/o/[organizationId]/links/sim-link-icon.tsx +++ b/apps/sim/app/o/[organizationId]/links/sim-link-icon.tsx @@ -1,8 +1,5 @@ -'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 { @@ -10,21 +7,8 @@ interface SimLinkIconProps { 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(null) const origin = simLinkOrigin(url) - const hostname = origin ? new URL(origin).hostname : null - if (!hostname || failedHostname === hostname) return - return ( - setFailedHostname(hostname)} - /> - ) + return } /> } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx index 1e34090c1f8..b3455260f4f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx @@ -47,7 +47,7 @@ function tool(overrides: Partial = {}): 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({ @@ -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', () => { @@ -87,7 +87,7 @@ describe('getBrowserAgentFaviconUrl', () => { }, }, ]) - ).toBe('https://example.com/favicon.ico') + ).toBe('/api/favicon?host=example.com') }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx index 5bf1c53ac4d..2d0b925e8c3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx @@ -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. */ @@ -120,11 +117,10 @@ function BrowserAgentFavicon({ url, canLoad }: BrowserAgentFaviconProps) { return (