Skip to content

Commit ec7b31c

Browse files
committed
improvement(favicons): resolve platform tenants no provider can see, and retry icons chosen after a timeout
A tenant on a platform whose domain has no icon at either provider, even at the parent (AWS sign-in portals, Coupa, GitLab Dedicated, Databricks apps, Salesforce, Okta preview, UKG, Jama), and a self-hosted gitlab./jira./testrail. host, now end their candidate walk at the platform's own site, after their own host and parents so a tenant's custom icon still wins. A parent's icon reached only because a more specific lookup timed out is cached for a day instead of thirty, since the host's own icon may exist.
1 parent e56bf3d commit ec7b31c

4 files changed

Lines changed: 115 additions & 8 deletions

File tree

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,60 @@ describe('faviconHostCandidates', () => {
4040
])
4141
})
4242

43+
it.each([
44+
['portal.awsapps.com', ['portal.awsapps.com', 'awsapps.com', 'aws.amazon.com']],
45+
['acme.coupahost.com', ['acme.coupahost.com', 'coupahost.com', 'coupa.com']],
46+
[
47+
'docs.acme.gitlab-dedicated.site',
48+
[
49+
'docs.acme.gitlab-dedicated.site',
50+
'acme.gitlab-dedicated.site',
51+
'gitlab-dedicated.site',
52+
'gitlab.com',
53+
],
54+
],
55+
[
56+
'acme.lightning.force.com',
57+
['acme.lightning.force.com', 'lightning.force.com', 'force.com', 'salesforce.com'],
58+
],
59+
])(
60+
'ends a platform tenant %s at the platform, after its own host and parents',
61+
(host, expected) => {
62+
expect(faviconHostCandidates(host)).toEqual(expected)
63+
}
64+
)
65+
66+
it.each([
67+
['gitlab.acme-internal.io', 'gitlab.com'],
68+
['jira.acme-internal.io', 'atlassian.com'],
69+
['testrail.qa.acme-internal.io', 'testrail.com'],
70+
])('ends a self-hosted %s at its product', (host, product) => {
71+
expect(faviconHostCandidates(host).at(-1)).toBe(product)
72+
})
73+
74+
it.each([
75+
'mygitlab.acme-internal.io',
76+
'docs.gitlab.acme-internal.io',
77+
'notawsapps.com',
78+
'acme.herokuapp.com',
79+
])('adds no platform for %s, which only resembles one', (host) => {
80+
const candidates = faviconHostCandidates(host)
81+
expect(candidates).not.toContain('gitlab.com')
82+
expect(candidates).not.toContain('aws.amazon.com')
83+
expect(candidates.length).toBeGreaterThan(0)
84+
})
85+
86+
it.each(['gitlab.corp', 'gitlab', 'jira.local'])(
87+
'still offers nothing for internal %s',
88+
(host) => {
89+
expect(faviconHostCandidates(host)).toEqual([])
90+
}
91+
)
92+
93+
it('never repeats a host the walk already reaches', () => {
94+
expect(faviconHostCandidates('gitlab.gitlab.com')).toEqual(['gitlab.gitlab.com', 'gitlab.com'])
95+
})
96+
4397
it('tries at most three parents', () => {
4498
expect(faviconHostCandidates('a.b.c.d.e.example.com')).toEqual([
4599
'a.b.c.d.e.example.com',

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { parse } from 'tldts'
2+
import { platformSite } from '@/lib/favicon/platforms'
23

34
const MAX_PARENT_HOSTS = 3
45
const HOST_SHAPE = /^[^\s/?#@\\]+$/
56

67
/**
78
* Hosts to ask a favicon provider about, most specific first. A private tenant such as
89
* `acme.atlassian.net` is invisible to providers, but its platform is not, so each parent
9-
* domain follows — at most three, stopping before the ICANN public suffix (`gitlab.io` is a valid last step;
10-
* `io` and `co.uk` are not). Internal hosts — IP literals, single labels, and names outside
10+
* domain follows — at most three, stopping before the ICANN public suffix (`gitlab.io` is a valid
11+
* last step; `io` and `co.uk` are not). A host on a platform no provider can see even at the
12+
* parent (`x.awsapps.com`, a self-hosted `gitlab.<company>`) ends at the platform's own site, after
13+
* its own host and parents so a tenant's custom icon still wins. Internal hosts — IP literals, single labels, and names outside
1114
* the public DNS — yield nothing, so they never leave the server, and neither does a name with
1215
* characters DNS can't hold, which could otherwise reshape a provider's URL.
1316
*/
@@ -23,7 +26,9 @@ export function faviconHostCandidates(input: string): string[] {
2326
if (isIp || !isIcann || !publicSuffix || /(^|\.)arpa$/.test(publicSuffix)) return []
2427
const labels = hostname.split('.')
2528
const candidateCount = labels.length - publicSuffix.split('.').length
26-
return labels
29+
const candidates = labels
2730
.slice(0, Math.min(candidateCount, MAX_PARENT_HOSTS + 1))
2831
.map((_, index) => labels.slice(index).join('.'))
32+
const platform = platformSite(hostname)
33+
return platform && !candidates.includes(platform) ? [...candidates, platform] : candidates
2934
}

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* Platforms whose tenant hosts no icon provider can see — not even at the parent domain — mapped
3+
* to the platform's own site, so a tenant still shows the product it is. Generic hosting domains
4+
* (`herokuapp.com`, `azurewebsites.net`, `cloudfront.net`) are deliberately absent: they serve
5+
* other companies' apps, and the host's logo would misrepresent them.
6+
*/
7+
const PLATFORM_SITES: ReadonlyMap<string, string> = new Map([
8+
['awsapps.com', 'aws.amazon.com'],
9+
['azuredatabricks.net', 'databricks.com'],
10+
['cloud.databricks.com', 'databricks.com'],
11+
['coupahost.com', 'coupa.com'],
12+
['databricksapps.com', 'databricks.com'],
13+
['gitlab-dedicated.site', 'gitlab.com'],
14+
['jamacloud.com', 'jamasoftware.com'],
15+
['lightning.force.com', 'salesforce.com'],
16+
['my.salesforce.com', 'salesforce.com'],
17+
['oktapreview.com', 'okta.com'],
18+
['ultipro.com', 'ukg.com'],
19+
])
20+
21+
/** Self-hosted tools conventionally served at `<product>.<company domain>`. */
22+
const SELF_HOSTED_PRODUCTS: ReadonlyMap<string, string> = new Map([
23+
['confluence', 'atlassian.com'],
24+
['gitlab', 'gitlab.com'],
25+
['grafana', 'grafana.com'],
26+
['jenkins', 'jenkins.io'],
27+
['jira', 'atlassian.com'],
28+
['sentry', 'sentry.io'],
29+
['testrail', 'testrail.com'],
30+
])
31+
32+
/** The public site of the platform a host belongs to, matched on whole labels, or `null`. */
33+
export function platformSite(hostname: string): string | null {
34+
const labels = hostname.split('.')
35+
for (let index = 0; index < labels.length; index++) {
36+
const site = PLATFORM_SITES.get(labels.slice(index).join('.'))
37+
if (site) return site
38+
}
39+
return SELF_HOSTED_PRODUCTS.get(labels[0]) ?? null
40+
}

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ const PROVIDER_TIMEOUT_MS = 1500
1212
const RESOLVE_DEADLINE_MS = 4000
1313
const HIT_TTL_SECONDS = 30 * 24 * 60 * 60
1414
const MISS_TTL_SECONDS = 24 * 60 * 60
15+
/** A parent's icon chosen because the host's own lookup timed out: retried a day later, not a month. */
16+
const PROVISIONAL_HIT_TTL_SECONDS = 24 * 60 * 60
1517
const UNAVAILABLE_TTL_SECONDS = 5 * 60
1618
/** Cached values: an icon's bytes, or one of these markers. */
1719
const MISS_MARKER = ''
@@ -28,7 +30,7 @@ const PROVIDERS: readonly ((host: string) => string)[] = [
2830
]
2931

3032
type FaviconResolution =
31-
| { kind: 'hit'; contentType: string; bytes: Buffer }
33+
| { kind: 'hit'; contentType: string; bytes: Buffer; provisional?: true }
3234
| { kind: 'miss' }
3335
| { kind: 'unavailable' }
3436

@@ -57,7 +59,10 @@ async function writeCache(host: string, resolution: FaviconResolution): Promise<
5759
if (!redis) return
5860
const [value, ttl] =
5961
resolution.kind === 'hit'
60-
? ([resolution.bytes, HIT_TTL_SECONDS] as const)
62+
? ([
63+
resolution.bytes,
64+
resolution.provisional ? PROVISIONAL_HIT_TTL_SECONDS : HIT_TTL_SECONDS,
65+
] as const)
6166
: resolution.kind === 'miss'
6267
? ([MISS_MARKER, MISS_TTL_SECONDS] as const)
6368
: ([UNAVAILABLE_MARKER, UNAVAILABLE_TTL_SECONDS] as const)
@@ -101,18 +106,21 @@ async function askProviders(host: string, signal: AbortSignal): Promise<FaviconR
101106

102107
/**
103108
* The most specific host first, so a tenant's own icon beats its platform's. A parent already
104-
* resolved for a sibling subdomain is reused from the cache instead of asked again.
109+
* resolved for a sibling subdomain is reused from the cache instead of asked again. A parent's
110+
* icon reached only because a more specific lookup was unavailable is `provisional`: the host's
111+
* own icon may exist, so it is not remembered for long.
105112
*/
106113
async function walk(candidates: string[], signal: AbortSignal): Promise<FaviconResolution> {
107114
let unavailable = false
108115
for (const [index, host] of candidates.entries()) {
109116
const parent = index > 0
110117
const cached = parent ? await readCache(host) : null
111-
if (cached) return cached.kind === 'miss' && unavailable ? { kind: 'unavailable' } : cached
118+
if (cached?.kind === 'hit') return unavailable ? { ...cached, provisional: true } : cached
119+
if (cached) return unavailable ? { kind: 'unavailable' } : cached
112120
const result = await askProviders(host, signal)
113121
if (result.kind === 'hit') {
114122
if (parent) await writeCache(host, result)
115-
return result
123+
return unavailable ? { ...result, provisional: true } : result
116124
}
117125
if (result.kind === 'unavailable') unavailable = true
118126
}

0 commit comments

Comments
 (0)