Skip to content

Commit d267863

Browse files
committed
fix(release): forward sim/ queries verbatim through the proxy, budget favicon lookups per caller, refresh oversized cached icons
1 parent 9589d02 commit d267863

12 files changed

Lines changed: 158 additions & 58 deletions

File tree

‎apps/extension/src/omnibox.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,18 +39,22 @@ function readTyped(text: string): { name: string; rest: string } {
3939
: { name: typed.slice(0, nameEnd), rest: typed.slice(nameEnd) }
4040
}
4141

42+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g
43+
44+
/** Code points XML forbids even as references, besides lone surrogates. */
45+
const NON_XML_CHARACTER = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g
46+
4247
/**
43-
* Suggestion text is XML, where these five characters are markup and control characters are not
44-
* allowed even as references, so one bad description can't make Chrome drop every suggestion.
48+
* Suggestion text is XML, where these five characters are markup and some code points aren't
49+
* allowed at all, so one bad description can't make Chrome drop every suggestion.
4550
*/
4651
function escapeXml(text: string): string {
4752
return text
48-
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
53+
.replace(NON_XML_CHARACTER, '')
54+
.replace(LONE_SURROGATE, '')
4955
.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`)
5056
}
5157

52-
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g
53-
5458
/** `encodeURIComponent`, except a lone surrogate (which it refuses) is dropped. */
5559
function encodeSegment(segment: string): string {
5660
return encodeURIComponent(segment.replace(LONE_SURROGATE, ''))

‎apps/sim/app/sim/[[...path]]/page.tsx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { ChipLink, StatusPageContent } from '@sim/emcn'
22
import type { Metadata } from 'next'
3+
import { headers } from 'next/headers'
34
import { notFound, redirect } from 'next/navigation'
45
import { getSession } from '@/lib/auth'
56
import { organizationRoutes, simLinkPath } from '@/lib/navigation/paths'
67
import { resolveSimLinkOrganization } from '@/lib/sim-links/application/organization'
78
import { resolveSimLink, type SimLinkResolution } from '@/lib/sim-links/application/use-cases'
8-
import { typedQueryString } from '@/lib/sim-links/destination'
9+
import { SIM_LINK_QUERY_HEADER } from '@/lib/sim-links/typed-query'
910
import { parseTypedSimLink, simLinkNameSchema } from '@/lib/sim-links/validation'
1011
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
1112
import { LogoShell } from '@/app/(landing)/components/logo-shell'
@@ -18,7 +19,6 @@ export const metadata: Metadata = {
1819

1920
interface SimLinkPageProps {
2021
params: Promise<{ path?: string[] }>
21-
searchParams: Promise<Record<string, string | string[] | undefined>>
2222
}
2323

2424
/**
@@ -50,8 +50,9 @@ function describeUnresolved(result: SimLinkResolution, validName: boolean, name:
5050
* Where `sim/<name>/<values…>?<query>` lands: the viewer's organization link's destination,
5151
* or a page that offers to create the link when nobody has yet.
5252
*/
53-
export default async function SimLinkPage({ params, searchParams }: SimLinkPageProps) {
54-
const [{ path = [] }, query] = await Promise.all([params, searchParams.then(typedQueryString)])
53+
export default async function SimLinkPage({ params }: SimLinkPageProps) {
54+
const [{ path = [] }, requestHeaders] = await Promise.all([params, headers()])
55+
const query = requestHeaders.get(SIM_LINK_QUERY_HEADER) ?? ''
5556
const segments = decodeSimLinkPath(path)
5657
const session = await getSession()
5758
if (!session?.user)

‎apps/sim/lib/api/server/routes/definition.test.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
requireBinaryRouteDefinition,
77
requireJsonRouteDefinition,
88
} from '@/lib/api/server/routes/definition'
9+
import { defineOperation } from '@/lib/core/application'
910

1011
const renameOperation = {
1112
id: 'files.rename',
@@ -35,12 +36,16 @@ describe('declarative route definition invariants', () => {
3536
path: '/icon',
3637
response: { mode: 'binary', status },
3738
})
38-
expect(() =>
39-
requireBinaryRouteDefinition(contract([204, 200]), renameOperation, renameOperation)
40-
).toThrow('carries a body first')
39+
const operation = defineOperation({
40+
id: 'icons.get',
41+
principalKinds: ['session'],
42+
capability: 'none',
43+
})
44+
expect(() => requireBinaryRouteDefinition(contract([204, 200]), operation, operation)).toThrow(
45+
'carries a body first'
46+
)
4147
expect(
42-
requireBinaryRouteDefinition(contract([200, 204]), renameOperation, renameOperation)
43-
.successStatus
48+
requireBinaryRouteDefinition(contract([200, 204]), operation, operation).successStatus
4449
).toBe(200)
4550
})
4651
})

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
import { createMockRedis } from '@sim/testing/mocks/redis.mock'
66
import { redisConfigMockFns } from '@sim/testing/mocks/redis-config.mock'
77
import { beforeEach, describe, expect, it, vi } from 'vitest'
8+
import { MAX_ICON_BYTES } from '@/lib/favicon/probe'
89
import { resolveFavicon } from '@/lib/favicon/resolve.server'
910

1011
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
@@ -86,4 +87,47 @@ describe('resolveFavicon', () => {
8687
expect((await resolveFavicon('acme.example.com', admit)).kind).toBe('hit')
8788
expect(admit).not.toHaveBeenCalled()
8889
})
90+
91+
it('looks up an icon cached under an earlier, larger cap again instead of serving it', async () => {
92+
answers = { 'acme.example.com': 'icon' }
93+
const oversized = Buffer.concat([Buffer.from('h'), PNG, Buffer.alloc(MAX_ICON_BYTES)])
94+
stored.set('favicon:v3:acme.example.com', { value: oversized, ttl: 30 * DAY })
95+
const result = await resolveFavicon('acme.example.com')
96+
expect(result.kind === 'hit' && result.bytes.length).toBe(PNG.length)
97+
expect(inputValidationMockFns.mockSecureFetchWithValidation).toHaveBeenCalled()
98+
})
99+
100+
it("never lets one caller's refused budget answer another caller's lookup", async () => {
101+
answers = { 'acme.example.com': 'icon' }
102+
const [refused, admitted] = await Promise.all([
103+
resolveFavicon('acme.example.com', async () => false),
104+
resolveFavicon('acme.example.com', async () => true),
105+
])
106+
expect(refused.kind).toBe('unavailable')
107+
expect(admitted.kind).toBe('hit')
108+
})
109+
110+
it('does not charge a caller who joins a lookup already asking the providers', async () => {
111+
answers = { 'acme.example.com': 'icon' }
112+
const fetchProvider = inputValidationMockFns.mockSecureFetchWithValidation
113+
const answerProvider = fetchProvider.getMockImplementation()
114+
let release = () => {}
115+
const released = new Promise<void>((resolve) => {
116+
release = resolve
117+
})
118+
fetchProvider.mockImplementation(
119+
async (...args: Parameters<NonNullable<typeof answerProvider>>) => {
120+
await released
121+
return answerProvider?.(...args)
122+
}
123+
)
124+
const first = resolveFavicon('acme.example.com')
125+
await vi.waitFor(() => expect(fetchProvider).toHaveBeenCalled())
126+
const joinerAdmit = vi.fn(async () => true)
127+
const joined = resolveFavicon('acme.example.com', joinerAdmit)
128+
release()
129+
expect((await joined).kind).toBe('hit')
130+
expect((await first).kind).toBe('hit')
131+
expect(joinerAdmit).not.toHaveBeenCalled()
132+
})
89133
})

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ async function readCache(host: string): Promise<FaviconResolution | null> {
5353
if (cached.toString() === UNAVAILABLE_MARKER) return { kind: 'unavailable' }
5454
const prefix = cached.subarray(0, 1).toString()
5555
const bytes = cached.subarray(1)
56+
// Cached under an earlier, larger cap: look it up again rather than serve it or call it a miss.
57+
if (bytes.length > MAX_ICON_BYTES) return null
5658
const result = classifyProviderResponse(200, bytes)
5759
if (result.kind !== 'hit') return { kind: 'miss' }
5860
return prefix === PROVISIONAL_HIT_PREFIX
@@ -156,8 +158,9 @@ async function walk(candidates: string[], signal: AbortSignal): Promise<FaviconR
156158
* login still gets its platform's icon. Results are cached per host (30 days for an icon, a day
157159
* for none or for a parent's icon chosen after a timeout, minutes after an outage), and
158160
* concurrent lookups of one host in this process share a single resolution. `admitLookup` is
159-
* asked only when nothing is cached, before any provider is: refused, the host is reported
160-
* unavailable and nothing is cached, so cached icons stay free while new lookups are budgeted.
161+
* the caller's own budget, asked only when nothing is cached and no lookup of the host is already
162+
* running: refused, the host is reported unavailable to this caller alone and nothing is cached,
163+
* so cached icons and lookups shared with another caller stay free.
161164
*/
162165
export async function resolveFavicon(
163166
host: string,
@@ -166,13 +169,16 @@ export async function resolveFavicon(
166169
const candidates = faviconHostCandidates(host)
167170
const requested = candidates[0]
168171
if (!requested) return { kind: 'miss' }
172+
const joined = inFlight.get(requested)
173+
if (joined) return joined
174+
const cached = await readCache(requested)
175+
if (cached) return cached
176+
if (!(await admitLookup())) return { kind: 'unavailable' }
177+
// Another caller may have started the same lookup while this one read the cache and its budget.
169178
const pending = inFlight.get(requested)
170179
if (pending) return pending
171180

172181
const resolution = (async (): Promise<FaviconResolution> => {
173-
const cached = await readCache(requested)
174-
if (cached) return cached
175-
if (!(await admitLookup())) return { kind: 'unavailable' }
176182
const resolved = await runWithOutboundOrganization(null, () =>
177183
walk(candidates, AbortSignal.timeout(RESOLVE_DEADLINE_MS))
178184
).catch((): FaviconResolution => ({ kind: 'unavailable' }))

‎apps/sim/lib/navigation/paths.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,12 @@ export function organizationRoutes(organizationId: string) {
5959
} as const
6060
}
6161

62+
/** The path the `sim/<name>` resolver serves. */
63+
export const SIM_LINK_ROOT = '/sim'
64+
6265
/** Where `sim/<name>/<values…>` resolves in this app: the address a member types, kept verbatim. */
6366
export function simLinkPath(...segments: string[]): string {
64-
return ['/sim', ...segments.map(encodeURIComponent)].join('/')
67+
return [SIM_LINK_ROOT, ...segments.map(encodeURIComponent)].join('/')
6568
}
6669

6770
/** Whether `pathname` is `root` itself or a route beneath it. */

‎apps/sim/lib/sim-links/destination.test.ts‎

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest'
2-
import { resolveSimLinkTarget, typedQueryString } from '@/lib/sim-links/destination'
2+
import { resolveSimLinkTarget } from '@/lib/sim-links/destination'
33
import { parseTypedSimLink, simLinkFieldsSchema } from '@/lib/sim-links/validation'
44

55
const fields = {
@@ -145,6 +145,7 @@ describe('Sim link destinations', () => {
145145
'https://ex.com/p?a=1&b=two%20words&edit',
146146
],
147147
['https://ex.com/p', [], null, 'q=a b', 'https://ex.com/p?q=a%20b'],
148+
['https://ex.com/p', [], null, 'q=&flag', 'https://ex.com/p?q=&flag'],
148149
['https://ex.com/p?x=1#top', ['y'], null, 'x=2', 'https://ex.com/p/y?x=1&x=2#top'],
149150
[
150151
'https://ex.com/search?q={*}',
@@ -162,22 +163,6 @@ describe('Sim link destinations', () => {
162163
})
163164
})
164165

165-
describe('typedQueryString', () => {
166-
it('keeps the spelling a destination reads: spaces as %20 and bare flags bare', () => {
167-
expect(typedQueryString({ q: 'hello world', edit: '', tag: ['a', 'b&c'] })).toBe(
168-
'q=hello%20world&edit&tag=a&tag=b%26c'
169-
)
170-
})
171-
172-
it('carries a typed # as data, never a fragment, once on a destination', () => {
173-
const query = typedQueryString({ q: 'a#b' })
174-
expect(resolveSimLinkTarget({ url: 'https://ex.com/p', defaultUrl: null }, [], query)).toEqual({
175-
kind: 'redirect',
176-
url: 'https://ex.com/p?q=a%23b',
177-
})
178-
})
179-
})
180-
181166
describe('parseTypedSimLink', () => {
182167
it.each([
183168
[' onboarding', 'onboarding'],

‎apps/sim/lib/sim-links/destination.ts‎

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,6 @@ export type SimLinkTarget =
1515
| { kind: 'missing_values'; required: number }
1616
| { kind: 'invalid_values' }
1717

18-
/**
19-
* A typed query rebuilt from parsed parameters with its spelling kept: values percent-encoded as
20-
* a URL path would be (a space stays `%20`, not `+`), and a key typed without a value stays bare
21-
* (`?edit`, not `?edit=`). Parameters with the same key come out together, after the first one.
22-
*/
23-
export function typedQueryString(
24-
params: Readonly<Record<string, string | readonly string[] | undefined>>
25-
): string {
26-
return Object.entries(params)
27-
.flatMap(([key, value]) =>
28-
[value ?? []]
29-
.flat()
30-
.map((item) =>
31-
item ? `${encodeURIComponent(key)}=${encodeURIComponent(item)}` : encodeURIComponent(key)
32-
)
33-
)
34-
.join('&')
35-
}
36-
3718
/** URL parsers resolve `.` and `..` even when encoded, so a value could climb out of the link's path. */
3819
function isDotSegment(value: string): boolean {
3920
return value === '.' || value === '..'
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { typedQuery } from '@/lib/sim-links/typed-query'
3+
4+
describe('typedQuery', () => {
5+
it.each([
6+
['', ''],
7+
['?', ''],
8+
['?q=&flag', 'q=&flag'],
9+
['?a=hello%20world&a=b+c', 'a=hello%20world&a=b+c'],
10+
['?_rsc=abc&q=1', 'q=1'],
11+
['?q=1&_rsc', 'q=1'],
12+
['?_rscx=1', '_rscx=1'],
13+
])('keeps %j as typed, apart from Next’s own marker', (search, query) => {
14+
expect(typedQuery(search)).toBe(query)
15+
})
16+
})
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Request header the proxy sets on every `sim/` request to the query exactly as typed, which a
3+
* page can't recover from its parsed `searchParams` (`?q=` and `?q` both parse to an empty value,
4+
* and spaces may have been typed as `%20` or `+`). The proxy always overwrites it, so a client
5+
* can't supply its own.
6+
*/
7+
export const SIM_LINK_QUERY_HEADER = 'x-sim-link-query'
8+
9+
/** A request's `search` as typed, without its `?` or the `_rsc` marker Next adds to its own fetches. */
10+
export function typedQuery(search: string): string {
11+
return search
12+
.replace(/^\?/, '')
13+
.split('&')
14+
.filter((part) => part && part !== '_rsc' && !part.startsWith('_rsc='))
15+
.join('&')
16+
}

0 commit comments

Comments
 (0)