Skip to content

Commit 4d7d451

Browse files
committed
fix(favicons): keep provisional icons provisional through reuse, walk past cached parent misses, skip hosting suffixes
A cached hit now records whether it was definite or provisional, so a host reusing a parent's timeout-derived icon inherits the one-day lifetime instead of a month. A cached miss at a parent skips only the candidates that parent's own walk covered, so a product fallback derived from the original host is still tried. The self-hosted product label no longer applies on a Public Suffix List private (hosting) suffix. Adds the Databricks military suffix the repo already recognizes, and the client remembers a failed icon only as long as the route retries an outage.
1 parent 1d15062 commit 4d7d451

6 files changed

Lines changed: 125 additions & 13 deletions

File tree

‎apps/sim/components/favicon.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ import { LRUCache } from 'lru-cache'
66
import { faviconSrc } from '@/lib/favicon/url'
77

88
/**
9-
* Hosts with no icon, so an icon that mounts again renders its fallback at once instead of
10-
* asking again and showing an empty slot first. Expires with the route's caching of a miss.
9+
* Hosts whose icon just failed, so an icon that mounts again renders its fallback at once
10+
* instead of an empty slot first. An `<img>` can't tell no icon from a provider outage, so this
11+
* lasts only as long as the route retries an outage; a confirmed miss stays in the HTTP cache.
1112
*/
12-
const knownMisses = new LRUCache<string, true>({ max: 500, ttl: 60 * 60 * 1000 })
13+
const knownMisses = new LRUCache<string, true>({ max: 500, ttl: 5 * 60 * 1000 })
1314

1415
interface FaviconProps {
1516
/** The site's hostname; without one the fallback renders. */

‎apps/sim/lib/favicon/hosts.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ describe('faviconHostCandidates', () => {
5757
['acme.gitlab-dedicated.com', 'gitlab-dedicated.com', 'gitlab.com'],
5858
],
5959
['acme.testrail.io', ['acme.testrail.io', 'testrail.io', 'testrail.com']],
60+
[
61+
'acme.databricksapps.mil',
62+
['acme.databricksapps.mil', 'databricksapps.mil', 'databricks.com'],
63+
],
6064
[
6165
'dbc-1.cloud.databricks.us',
6266
['dbc-1.cloud.databricks.us', 'cloud.databricks.us', 'databricks.us', 'databricks.com'],
@@ -81,6 +85,8 @@ describe('faviconHostCandidates', () => {
8185
'docs.gitlab.acme-internal.io',
8286
'notawsapps.com',
8387
'acme.herokuapp.com',
88+
'gitlab.azurewebsites.net',
89+
'gitlab.acme.vercel.app',
8490
])('adds no platform for %s, which only resembles one', (host) => {
8591
const candidates = faviconHostCandidates(host)
8692
expect(candidates).not.toContain('gitlab.com')

‎apps/sim/lib/favicon/platforms.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { parse } from 'tldts'
2+
13
/**
24
* Platforms whose tenant hosts no icon provider can see — not even at any parent the walk
35
* reaches — mapped to the platform's own site, so a tenant still shows the product it is. A
@@ -14,6 +16,7 @@ const PLATFORM_SITES: ReadonlyMap<string, string> = new Map([
1416
['coupahost.com', 'coupa.com'],
1517
['databricks.us', 'databricks.com'],
1618
['databricksapps.com', 'databricks.com'],
19+
['databricksapps.mil', 'databricks.com'],
1720
['databricksapps.us', 'databricks.com'],
1821
['gitlab-dedicated.com', 'gitlab.com'],
1922
['gitlab-dedicated.site', 'gitlab.com'],
@@ -24,7 +27,10 @@ const PLATFORM_SITES: ReadonlyMap<string, string> = new Map([
2427
['ultipro.com', 'ukg.com'],
2528
])
2629

27-
/** Self-hosted tools conventionally served at `<product>.<company domain>`. */
30+
/**
31+
* Self-hosted tools conventionally served at `<product>.<company domain>` — only on a company's
32+
* own domain, never on a hosting platform's suffix (`gitlab.azurewebsites.net` is someone's app).
33+
*/
2834
const SELF_HOSTED_PRODUCTS: ReadonlyMap<string, string> = new Map([
2935
['confluence', 'atlassian.com'],
3036
['gitlab', 'gitlab.com'],
@@ -41,5 +47,6 @@ export function platformSite(hostname: string): string | null {
4147
const site = PLATFORM_SITES.get(labels.slice(index).join('.'))
4248
if (site) return site
4349
}
50+
if (parse(hostname, { allowPrivateDomains: true }).isPrivate) return null
4451
return SELF_HOSTED_PRODUCTS.get(labels[0]) ?? null
4552
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import {
2+
createMockRedis,
3+
inputValidationMock,
4+
inputValidationMockFns,
5+
redisConfigMockFns,
6+
} from '@sim/testing'
7+
import { beforeEach, describe, expect, it, vi } from 'vitest'
8+
import { resolveFavicon } from '@/lib/favicon/resolve.server'
9+
10+
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
11+
12+
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0])
13+
const DAY = 24 * 60 * 60
14+
15+
type Answer = 'icon' | 'none' | 'timeout'
16+
17+
/** Each host's answer at both providers; any host not listed has no icon. */
18+
let answers: Record<string, Answer> = {}
19+
let stored: Map<string, { value: Buffer; ttl: number }>
20+
21+
function hostOf(url: string): string {
22+
const match = url.match(/url=https:\/\/([^&]+)&/) ?? url.match(/\/ip3\/(.+)\.ico$/)
23+
if (!match) throw new Error(`Unexpected provider URL ${url}`)
24+
return match[1]
25+
}
26+
27+
beforeEach(() => {
28+
answers = {}
29+
stored = new Map()
30+
const redis = createMockRedis()
31+
redis.getBuffer.mockImplementation(async (key: string) => stored.get(key)?.value ?? null)
32+
redis.set.mockImplementation(
33+
async (key: string, value: Buffer | string, _ex: string, ttl: number) => {
34+
stored.set(key, { value: Buffer.from(value), ttl })
35+
return 'OK'
36+
}
37+
)
38+
redisConfigMockFns.mockGetRedisClient.mockReturnValue(redis)
39+
inputValidationMockFns.mockSecureFetchWithValidation.mockImplementation(async (url: string) => {
40+
const answer = answers[hostOf(url)] ?? 'none'
41+
if (answer === 'timeout') throw new Error('timed out')
42+
return {
43+
status: answer === 'icon' ? 200 : 404,
44+
arrayBuffer: async () => PNG.buffer.slice(PNG.byteOffset, PNG.byteOffset + PNG.byteLength),
45+
body: { cancel: async () => {} },
46+
}
47+
})
48+
})
49+
50+
const ttlOf = (host: string) => stored.get(`favicon:v3:${host}`)?.ttl
51+
52+
describe('resolveFavicon', () => {
53+
it('keeps a parent icon chosen after a timeout provisional when a child host reuses it', async () => {
54+
answers = { 'gitlab.acme-internal.io': 'timeout', 'gitlab.com': 'icon' }
55+
expect((await resolveFavicon('gitlab.acme-internal.io')).kind).toBe('hit')
56+
expect(ttlOf('gitlab.acme-internal.io')).toBe(DAY)
57+
58+
expect((await resolveFavicon('docs.gitlab.acme-internal.io')).kind).toBe('hit')
59+
expect(ttlOf('docs.gitlab.acme-internal.io')).toBe(DAY)
60+
})
61+
62+
it('still reaches the product fallback after its parent was cached as a miss', async () => {
63+
answers = { 'atlassian.com': 'icon' }
64+
expect((await resolveFavicon('acme-internal.io')).kind).toBe('miss')
65+
66+
expect((await resolveFavicon('jira.acme-internal.io')).kind).toBe('hit')
67+
})
68+
69+
it('remembers a definite icon for a month', async () => {
70+
answers = { 'acme.example.com': 'icon' }
71+
expect((await resolveFavicon('acme.example.com')).kind).toBe('hit')
72+
expect(ttlOf('acme.example.com')).toBe(30 * DAY)
73+
})
74+
})

‎apps/sim/lib/favicon/resolve.server.ts‎

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ const MISS_TTL_SECONDS = 24 * 60 * 60
1515
/** A parent's icon chosen because the host's own lookup timed out: retried a day later, not a month. */
1616
const PROVISIONAL_HIT_TTL_SECONDS = 24 * 60 * 60
1717
const UNAVAILABLE_TTL_SECONDS = 5 * 60
18-
/** Cached values: an icon's bytes, or one of these markers. */
18+
/**
19+
* Cached values: a hit is one prefix byte — definite or provisional, which a host reusing it
20+
* must inherit — followed by the icon; the markers stand for no icon and for an outage.
21+
*/
22+
const DEFINITE_HIT_PREFIX = 'h'
23+
const PROVISIONAL_HIT_PREFIX = 'p'
1924
const MISS_MARKER = ''
2025
const UNAVAILABLE_MARKER = '-'
2126

@@ -46,8 +51,13 @@ async function readCache(host: string): Promise<FaviconResolution | null> {
4651
const cached = await redis.getBuffer(cacheKey(host))
4752
if (cached === null) return null
4853
if (cached.toString() === UNAVAILABLE_MARKER) return { kind: 'unavailable' }
49-
const result = classifyProviderResponse(200, cached)
50-
return result.kind === 'hit' ? { ...result, bytes: cached } : { kind: 'miss' }
54+
const prefix = cached.subarray(0, 1).toString()
55+
const bytes = cached.subarray(1)
56+
const result = classifyProviderResponse(200, bytes)
57+
if (result.kind !== 'hit') return { kind: 'miss' }
58+
return prefix === PROVISIONAL_HIT_PREFIX
59+
? { ...result, bytes, provisional: true }
60+
: { ...result, bytes }
5161
} catch (error) {
5262
logger.warn('Favicon cache read failed', { error })
5363
return null
@@ -59,10 +69,15 @@ async function writeCache(host: string, resolution: FaviconResolution): Promise<
5969
if (!redis) return
6070
const [value, ttl] =
6171
resolution.kind === 'hit'
62-
? ([
63-
resolution.bytes,
64-
resolution.provisional ? PROVISIONAL_HIT_TTL_SECONDS : HIT_TTL_SECONDS,
65-
] as const)
72+
? resolution.provisional
73+
? ([
74+
Buffer.concat([Buffer.from(PROVISIONAL_HIT_PREFIX), resolution.bytes]),
75+
PROVISIONAL_HIT_TTL_SECONDS,
76+
] as const)
77+
: ([
78+
Buffer.concat([Buffer.from(DEFINITE_HIT_PREFIX), resolution.bytes]),
79+
HIT_TTL_SECONDS,
80+
] as const)
6681
: resolution.kind === 'miss'
6782
? ([MISS_MARKER, MISS_TTL_SECONDS] as const)
6883
: ([UNAVAILABLE_MARKER, UNAVAILABLE_TTL_SECONDS] as const)
@@ -112,11 +127,19 @@ async function askProviders(host: string, signal: AbortSignal): Promise<FaviconR
112127
*/
113128
async function walk(candidates: string[], signal: AbortSignal): Promise<FaviconResolution> {
114129
let unavailable = false
130+
const covered = new Set<string>()
115131
for (const [index, host] of candidates.entries()) {
132+
if (covered.has(host)) continue
116133
const parent = index > 0
117134
const cached = parent ? await readCache(host) : null
118-
if (cached?.kind === 'hit') return unavailable ? { ...cached, provisional: true } : cached
119-
if (cached) return unavailable ? { kind: 'unavailable' } : cached
135+
if (cached?.kind === 'hit') {
136+
return unavailable || cached.provisional ? { ...cached, provisional: true } : cached
137+
}
138+
if (cached) {
139+
if (cached.kind === 'unavailable') unavailable = true
140+
for (const reached of faviconHostCandidates(host)) covered.add(reached)
141+
continue
142+
}
120143
const result = await askProviders(host, signal)
121144
if (result.kind === 'hit') {
122145
if (parent) await writeCache(host, result)

‎packages/testing/src/mocks/redis.mock.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export function createMockRedis() {
3030

3131
// Key operations
3232
get: vi.fn().mockResolvedValue(null),
33+
getBuffer: vi.fn().mockResolvedValue(null),
3334
set: vi.fn().mockResolvedValue('OK'),
3435
del: vi.fn().mockResolvedValue(1),
3536
exists: vi.fn().mockResolvedValue(0),

0 commit comments

Comments
 (0)