Skip to content

Commit a787aa7

Browse files
authored
feat: migrate to hash fragments (#760)
* tmp: fixing unrelated histories * chore: cleaning up merging of main * chore: cleaning up merge some more * chore: more merge fixes, tests pass * test: config page viewing on subdomain * chore: revert main.go changes, fix safari test
1 parent 4efa1ab commit a787aa7

File tree

8 files changed

+320
-42
lines changed

8 files changed

+320
-42
lines changed

src/lib/constants.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
11
/**
2-
* This file is an attempt to consolidate all the query params that are used in the service worker.
2+
* This file is an attempt to consolidate all the query params and hash fragments that are used in the service worker.
33
*
44
* This will allow us a single location to define and describe all the query params that are used in the service worker.
55
*/
66

77
export const QUERY_PARAMS = {
88
/**
9-
* The query param that is used to send the config to the subdomain service worker.
9+
* The path that is used to request content from the IPFS network.
10+
*/
11+
HELIA_SW: 'helia-sw'
12+
}
13+
14+
export const HASH_FRAGMENTS = {
15+
/**
16+
* The hash fragment that is used to send the config to the subdomain service worker.
1017
*/
1118
IPFS_SW_CFG: 'ipfs-sw-cfg',
1219

1320
/**
14-
* The query param that is used to request the config from the root domain service worker.
21+
* The hash fragment that is used to request the config from the root domain service worker.
1522
*/
1623
IPFS_SW_SUBDOMAIN_REQUEST: 'ipfs-sw-subdomain-request',
1724

1825
/**
19-
* The path that is used to request content from the IPFS network.
26+
* The hash fragment that is used to request the origin isolation warning page.
2027
*/
21-
HELIA_SW: 'helia-sw'
28+
IPFS_SW_ORIGIN_ISOLATION_WARNING: 'ipfs-sw-origin-isolation-warning'
2229
}

src/lib/first-hit-helpers.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { checkSubdomainSupport } from './check-subdomain-support.js'
22
import { isConfigSet } from './config-db.js'
3-
import { QUERY_PARAMS } from './constants.js'
3+
import { HASH_FRAGMENTS, QUERY_PARAMS } from './constants.js'
44
import { getSubdomainParts } from './get-subdomain-parts.js'
5+
import { deleteHashFragment, getHashFragment, hasHashFragment, setHashFragment } from './hash-fragments.js'
56
import { uiLogger } from './logger.js'
67
import { findOriginIsolationRedirect, isPathGatewayRequest, isPathOrSubdomainRequest, isSubdomainGatewayRequest } from './path-or-subdomain.js'
78
import type { UrlParts } from './get-subdomain-parts.js'
@@ -150,7 +151,7 @@ function isRequestForContentAddressedData (url: URL): boolean {
150151
export async function getStateFromUrl (url: URL): Promise<NavigationState> {
151152
const { parentDomain, id, protocol } = getSubdomainParts(url.href)
152153
const isIsolatedOrigin = isSubdomainGatewayRequest(url)
153-
const urlHasSubdomainConfigRequest = url.searchParams.get(QUERY_PARAMS.IPFS_SW_SUBDOMAIN_REQUEST) != null && url.searchParams.get(QUERY_PARAMS.HELIA_SW) != null
154+
const urlHasSubdomainConfigRequest = hasHashFragment(url, HASH_FRAGMENTS.IPFS_SW_SUBDOMAIN_REQUEST) && url.searchParams.get(QUERY_PARAMS.HELIA_SW) != null
154155
let hasConfig = false
155156
const supportsSubdomains = await checkSubdomainSupport(url)
156157

@@ -165,7 +166,7 @@ export async function getStateFromUrl (url: URL): Promise<NavigationState> {
165166
urlHasSubdomainConfigRequest,
166167
url,
167168
subdomainParts: { parentDomain, id, protocol },
168-
compressedConfig: url.searchParams.get(QUERY_PARAMS.IPFS_SW_CFG),
169+
compressedConfig: getHashFragment(url, HASH_FRAGMENTS.IPFS_SW_CFG),
169170
supportsSubdomains,
170171
requestForContentAddressedData: isRequestForContentAddressedData(url)
171172
} satisfies NavigationState
@@ -185,7 +186,7 @@ export async function getConfigRedirectUrl ({ url, isIsolatedOrigin, urlHasSubdo
185186
targetUrl.pathname = '/'
186187
targetUrl.hash = url.hash
187188
targetUrl.search = url.search
188-
targetUrl.searchParams.set(QUERY_PARAMS.IPFS_SW_SUBDOMAIN_REQUEST, 'true')
189+
setHashFragment(targetUrl, HASH_FRAGMENTS.IPFS_SW_SUBDOMAIN_REQUEST, 'true')
189190

190191
// helia-sw may already be in the query parameters from the go binary or cloudflare or other service, so we need to add it to the target URL
191192
const heliaSw = url.searchParams.get(QUERY_PARAMS.HELIA_SW)
@@ -212,10 +213,10 @@ export async function getUrlWithConfig ({ url, isIsolatedOrigin, urlHasSubdomain
212213
const { translateIpfsRedirectUrl } = await import('./translate-ipfs-redirect-url.js')
213214
// we are on the root domain, and have been requested by a subdomain to fetch the config and pass it back to them.
214215
const redirectUrl = url
215-
redirectUrl.searchParams.delete(QUERY_PARAMS.IPFS_SW_SUBDOMAIN_REQUEST)
216+
deleteHashFragment(redirectUrl, HASH_FRAGMENTS.IPFS_SW_SUBDOMAIN_REQUEST)
216217
const config = await getConfig(uiLogger)
217218
const compressedConfig = await compressConfig(config)
218-
redirectUrl.searchParams.set(QUERY_PARAMS.IPFS_SW_CFG, compressedConfig)
219+
setHashFragment(redirectUrl, HASH_FRAGMENTS.IPFS_SW_CFG, compressedConfig)
219220

220221
// translate the url with helia-sw to a path based URL, and then to the proper subdomain URL
221222
return toSubdomainRequest(translateIpfsRedirectUrl(redirectUrl))
@@ -237,7 +238,7 @@ export async function loadConfigFromUrl ({ url, compressedConfig }: Pick<Navigat
237238

238239
try {
239240
const config = await decompressConfig(compressedConfig)
240-
url.searchParams.delete(QUERY_PARAMS.IPFS_SW_CFG)
241+
deleteHashFragment(url, HASH_FRAGMENTS.IPFS_SW_CFG)
241242
await setConfig(config, uiLogger)
242243
await registerServiceWorker()
243244
return translateIpfsRedirectUrl(url).toString()

src/lib/hash-fragments.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
interface HashFragments {
2+
[key: string]: string | null
3+
}
4+
5+
/**
6+
* Parse hash fragments from a URL hash string
7+
*
8+
* @returns An object with key-value pairs from the hash fragments
9+
*/
10+
export function parseHashFragments (hash: string): HashFragments {
11+
const fragments: HashFragments = {}
12+
13+
if (!hash || hash === '') {
14+
return fragments
15+
}
16+
17+
// remove the leading # if present
18+
const hashString = hash.startsWith('#') ? hash.slice(1) : hash
19+
20+
// split by & and parse each fragment
21+
const pairs = hashString.split('&')
22+
for (const pair of pairs) {
23+
const [key, value = null] = pair.split('=')
24+
if (key != null) {
25+
fragments[decodeURIComponent(key)] = value === null ? null : decodeURIComponent(value)
26+
}
27+
}
28+
29+
return fragments
30+
}
31+
32+
/**
33+
* Get a specific hash fragment value from a URL
34+
*
35+
* @returns The value of the hash fragment, or null if not found
36+
*/
37+
export function getHashFragment (url: URL, key: string): string | null {
38+
const fragments = parseHashFragments(url.hash)
39+
if (fragments[key] != null) {
40+
return decodeURIComponent(fragments[key])
41+
}
42+
43+
return null
44+
}
45+
46+
/**
47+
* Convert a hash fragment object to a string
48+
*/
49+
export function hashFragmentsToString (fragments: HashFragments): string {
50+
const pairs = Object.entries(fragments).map(([k, v]) => {
51+
if (v != null) {
52+
return `${k}=${encodeURIComponent(v)}`
53+
}
54+
55+
return `${k}`
56+
})
57+
return pairs.length > 0 ? `#${pairs.join('&')}` : ''
58+
}
59+
60+
/**
61+
* Set a hash fragment on a URL. Modifies the URL in place.
62+
*/
63+
export function setHashFragment (url: URL, key: string, value: string | null): void {
64+
const fragments = parseHashFragments(url.hash)
65+
fragments[key] = value
66+
67+
url.hash = hashFragmentsToString(fragments)
68+
}
69+
70+
/**
71+
* Delete a hash fragment from a URL. Modifies the URL in place.
72+
*/
73+
export function deleteHashFragment (url: URL, key: string): void {
74+
const fragments = parseHashFragments(url.hash)
75+
delete fragments[key]
76+
77+
url.hash = hashFragmentsToString(fragments)
78+
}
79+
80+
/**
81+
* Check if a hash fragment exists in a URL
82+
*
83+
* @returns true if the hash fragment exists
84+
*/
85+
export function hasHashFragment (url: URL, key: string): boolean {
86+
const fragments = parseHashFragments(url.hash)
87+
return key in fragments
88+
}

src/sw.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { getConfig } from './lib/config-db.js'
2-
import { QUERY_PARAMS } from './lib/constants.js'
2+
import { HASH_FRAGMENTS, QUERY_PARAMS } from './lib/constants.js'
33
import { getHeliaSwRedirectUrl } from './lib/first-hit-helpers.js'
44
import { GenericIDB } from './lib/generic-db.js'
55
import { getSubdomainParts } from './lib/get-subdomain-parts.js'
66
import { getVerifiedFetch } from './lib/get-verified-fetch.js'
7+
import { hasHashFragment } from './lib/hash-fragments.js'
78
import { isConfigPage } from './lib/is-config-page.js'
89
import { swLogger } from './lib/logger.js'
910
import { findOriginIsolationRedirect, isPathGatewayRequest, isSubdomainGatewayRequest } from './lib/path-or-subdomain.js'
@@ -323,7 +324,7 @@ function isConfigPageRequest (url: URL): boolean {
323324

324325
function isSubdomainConfigRequest (event: FetchEvent): boolean {
325326
const url = new URL(event.request.url)
326-
return url.searchParams.has(QUERY_PARAMS.IPFS_SW_SUBDOMAIN_REQUEST)
327+
return hasHashFragment(url, HASH_FRAGMENTS.IPFS_SW_SUBDOMAIN_REQUEST)
327328
}
328329

329330
function isValidRequestForSW (event: FetchEvent): boolean {

test-e2e/config-loading.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { compressConfig } from '../src/lib/config-db.js'
2+
import { HASH_FRAGMENTS } from '../src/lib/constants.js'
23
import { test, expect } from './fixtures/config-test-fixtures.js'
34
import { getConfig, setConfig } from './fixtures/set-sw-config.js'
45
import { waitForServiceWorker } from './fixtures/wait-for-service-worker.js'
@@ -76,6 +77,11 @@ test.describe('ipfs-sw configuration', () => {
7677
})
7778

7879
test('config can be injected from an untrusted source', async ({ page, baseURL, rootDomain, protocol }) => {
80+
if (['webkit', 'safari'].includes(test.info().project.name)) {
81+
// @see https://github.com/ipfs/in-web-browsers/issues/206
82+
test.skip()
83+
return
84+
}
7985
const newConfig: ConfigDbWithoutPrivateFields = {
8086
...testConfig,
8187
gateways: [
@@ -102,7 +108,7 @@ test.describe('ipfs-sw configuration', () => {
102108
page.on('response', (response) => {
103109
responses.push(response)
104110
})
105-
await page.goto(`${protocol}//bafkqablimvwgy3y.ipfs.${rootDomain}/?ipfs-sw-cfg=${compressedConfig}`)
111+
await page.goto(`${protocol}//bafkqablimvwgy3y.ipfs.${rootDomain}/#${HASH_FRAGMENTS.IPFS_SW_CFG}=${compressedConfig}`)
106112
await waitForServiceWorker(page, `${protocol}//bafkqablimvwgy3y.ipfs.${rootDomain}`)
107113
await page.waitForLoadState('networkidle')
108114

test-e2e/layout.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,12 @@ test.describe('smoketests', () => {
3737

3838
testSubdomainRouting.describe('smoketests', () => {
3939
testSubdomainRouting.describe('config section on subdomains', () => {
40+
// TODO: remove this test because we don't want to support config page on subdomains. See
4041
testSubdomainRouting('only config and header are visible on /#/ipfs-sw-config requests', async ({ page, baseURL, rootDomain, protocol }) => {
4142
await page.goto(baseURL, { waitUntil: 'networkidle' })
4243
await waitForServiceWorker(page, baseURL)
4344
await page.goto(`${protocol}//bafkqablimvwgy3y.ipfs.${rootDomain}/#/ipfs-sw-config`, { waitUntil: 'networkidle' })
45+
await page.reload()
4446

4547
await waitForServiceWorker(page, baseURL)
4648

0 commit comments

Comments
 (0)