Skip to content

Commit

Permalink
fix: dynamic subdomain gateway detection (#53)
Browse files Browse the repository at this point in the history
* fix: route to ?helia-sw for all 404 paths

Subdomain gateway won't have any path prefix, namespace and root
identifier are in `Host` header.

Closes #28

* refactor: improved subdomain detection

- removed hardcoded BASE_URL
- improved detection based on matching from right to left
  to reduce false-positives related to dnslinks

* chore: fix test script

* chore: gitattributes

---------

Co-authored-by: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com>
  • Loading branch information
lidel and SgtPooki authored Feb 26, 2024
1 parent 306e19b commit 333ee9f
Show file tree
Hide file tree
Showing 11 changed files with 85 additions and 56 deletions.
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text=auto eol=lf
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"serve": "webpack serve --mode=development",
"serve:prod": "webpack serve --mode=production",
"start": "npm run serve",
"test:node": "webpack --env test && npx mocha dist/tests.js",
"test:node": "webpack --env test && npx mocha test-build/tests.js",
"postinstall": "patch-package"
},
"browser": "./dist/src/index.js",
Expand Down
3 changes: 1 addition & 2 deletions public/_redirects
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
/ipns/* /?helia-sw=/ipns/:splat 302
/ipfs/* /?helia-sw=/ipfs/:splat 302
/* /?helia-sw=/:splat 302
4 changes: 2 additions & 2 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import './app.css'
import App from './app.tsx'
import { loadConfigFromLocalStorage } from './lib/config-db.ts'
import { isPathOrSubdomainRequest } from './lib/path-or-subdomain.ts'
import { BASE_URL } from './lib/webpack-constants.ts'
import RedirectPage from './redirectPage.tsx'

await loadConfigFromLocalStorage()
Expand All @@ -15,13 +14,14 @@ const sw = await navigator.serviceWorker.register(new URL('sw.ts', import.meta.u
const root = ReactDOMClient.createRoot(container)

// SW did not trigger for this request
if (isPathOrSubdomainRequest(BASE_URL, window.location)) {
if (isPathOrSubdomainRequest(window.location)) {
// but the requested path is something it should, so show redirect and redirect to the same URL
root.render(
<RedirectPage />
)
window.location.replace(window.location.href)
} else {
// TODO: add detection of DNSLink gateways (alowing use with Host: en.wikipedia-on-ipfs.org)
// the requested path is not recognized as a path or subdomain request, so render the app UI
if (window.location.pathname !== '/') {
// pathname is not blank, but is invalid. redirect to the root
Expand Down
28 changes: 25 additions & 3 deletions src/lib/dns-link-labels.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,35 @@
import { CID } from 'multiformats/cid'

/**
* For dnslinks see https://specs.ipfs.tech/http-gateways/subdomain-gateway/#host-request-header
* DNSLink names include . which means they must be inlined into a single DNS label to provide unique origin and work with wildcard TLS certificates.
*/

// DNS label can have up to 63 characters, consisting of alphanumeric
// characters or hyphens -, but it must not start or end with a hyphen.
const dnsLabelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/

/**
* We can receive either IPNS Name string or DNSLink label string here.
* IPNS Names do not have dots or dashes.
*/
export function isValidDnsLabel (label: string): boolean {
// If string is not a valid IPNS Name (CID)
// then we assume it may be a valid DNSLabel.
try {
CID.parse(label)
return false
} catch {
return dnsLabelRegex.test(label)
}
}

/**
* We can receive either a peerId string or dnsLink label string here. PeerId strings do not have dots or dashes.
* Checks if label looks like inlined DNSLink.
* (https://specs.ipfs.tech/http-gateways/subdomain-gateway/#host-request-header)
*/
export function isDnsLabel (label: string): boolean {
return ['-', '.'].some((char) => label.includes(char))
export function isInlinedDnsLink (label: string): boolean {
return isValidDnsLabel(label) && label.includes('-') && !label.includes('.')
}

/**
Expand Down
22 changes: 9 additions & 13 deletions src/lib/heliaFetch.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { createVerifiedFetch, type ContentTypeParser } from '@helia/verified-fetch'
import { fileTypeFromBuffer } from '@sgtpooki/file-type'
import { dnsLinkLabelDecoder, isDnsLabel } from './dns-link-labels.ts'
import type { Helia } from '@helia/interface'

export interface HeliaFetchOptions {
path: string
helia: Helia
signal?: AbortSignal
headers?: Headers
origin?: string | null
id?: string | null
protocol?: string | null
}

Expand Down Expand Up @@ -116,29 +115,26 @@ function changeCssFontPath (path: string): string {
* heliaFetch should have zero awareness of whether it's being used inside a service worker or not.
*
* The `path` supplied should be either:
* * /ipfs/CID
* * /ipns/DNSLink
* * /ipns/key
* * /ipfs/CID (https://docs.ipfs.tech/concepts/content-addressing/)
* * /ipns/DNSLink (https://dnslink.dev/)
* * /ipns/IPNSName (https://specs.ipfs.tech/ipns/ipns-record/#ipns-name)
*
* Things to do:
* * TODO: implement as much of the gateway spec as possible.
* * TODO: why we would be better than ipfs.io/other-gateway
* * TODO: have error handling that renders 404/500/other if the request is bad.
*
*/
export async function heliaFetch ({ path, helia, signal, headers, origin, protocol }: HeliaFetchOptions): Promise<Response> {
export async function heliaFetch ({ path, helia, signal, headers, id, protocol }: HeliaFetchOptions): Promise<Response> {
const verifiedFetch = await createVerifiedFetch(helia, {
contentTypeParser
})

let verifiedFetchUrl: string
if (origin != null && protocol != null) {
if (protocol === 'ipns' && isDnsLabel(origin)) {
verifiedFetchUrl = `${protocol}://${dnsLinkLabelDecoder(origin)}/${path}`
} else {
// likely a peerId instead of a dnsLink label
verifiedFetchUrl = `${protocol}://${origin}${path}`
}

if (id != null && protocol != null) {
verifiedFetchUrl = `${protocol}://${id}${path}`

// eslint-disable-next-line no-console
console.log('subdomain fetch for ', verifiedFetchUrl)
} else {
Expand Down
7 changes: 4 additions & 3 deletions src/lib/path-or-subdomain.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
const subdomainRegex = /^(?<origin>[^/]+)\.(?<protocol>ip[fn]s)?$/
// TODO: dry, this is same regex code as in getSubdomainParts
const subdomainRegex = /^(?<id>[^/]+)\.(?<protocol>ip[fn]s)\.[^/]+$/
const pathRegex = /^\/(?<protocol>ip[fn]s)\/(?<path>.*)$/

export const isPathOrSubdomainRequest = (baseUrl: string, location: Pick<Location, 'hostname' | 'pathname'>): boolean => {
const subdomain = location.hostname.replace(`.${baseUrl}`, '')
export const isPathOrSubdomainRequest = (location: Pick<Location, 'hostname' | 'pathname'>): boolean => {
const subdomain = location.hostname
const subdomainMatch = subdomain.match(subdomainRegex)

const pathMatch = location.pathname.match(pathRegex)
Expand Down
10 changes: 0 additions & 10 deletions src/lib/webpack-constants.ts

This file was deleted.

39 changes: 24 additions & 15 deletions src/sw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
// import { clientsClaim } from 'workbox-core'
import mime from 'mime-types'
import { getHelia } from './get-helia.ts'
import { dnsLinkLabelDecoder, isInlinedDnsLink } from './lib/dns-link-labels.ts'
import { heliaFetch } from './lib/heliaFetch.ts'
import { BASE_URL } from './lib/webpack-constants.ts'
import type { Helia } from '@helia/interface'

declare let self: ServiceWorkerGlobalScope
Expand Down Expand Up @@ -51,8 +51,8 @@ const fetchHandler = async ({ path, request }: FetchHandlerArg): Promise<Respons
// 5 minute timeout
const abortController = AbortSignal.timeout(5 * 60 * 1000)
try {
const { origin, protocol } = getSubdomainParts(request)
return await heliaFetch({ path, helia, signal: abortController, headers: request.headers, origin, protocol })
const { id, protocol } = getSubdomainParts(request)
return await heliaFetch({ path, helia, signal: abortController, headers: request.headers, id, protocol })
} catch (err: unknown) {
const errorMessages: string[] = []
if (isAggregateError(err)) {
Expand Down Expand Up @@ -91,23 +91,34 @@ const isRootRequestForContent = (event: FetchEvent): boolean => {
return isRootRequest // && getCidFromUrl(event.request.url) != null
}

function getSubdomainParts (request: Request): { origin: string | null, protocol: string | null } {
function getSubdomainParts (request: Request): { id: string | null, protocol: string | null } {
const urlString = request.url
const url = new URL(urlString)
const subdomain = url.hostname.replace(`.${BASE_URL}`, '')
const subdomainRegex = /^(?<origin>[^/]+)\.(?<protocol>ip[fn]s)?$/
const subdomainMatch = subdomain.match(subdomainRegex)
const { origin, protocol } = subdomainMatch?.groups ?? { origin: null, protocol: null }
const labels = new URL(urlString).hostname.split('.')
let id: string | null = null; let protocol: string | null = null

// DNS label inspection happens from from right to left
// to work fine with edge cases like docs.ipfs.tech.ipns.foo.localhost
for (let i = labels.length - 1; i >= 0; i--) {
if (labels[i].startsWith('ipfs') || labels[i].startsWith('ipns')) {
protocol = labels[i]
id = labels.slice(0, i).join('.')
if (protocol === 'ipns' && isInlinedDnsLink(id)) {
// un-inline DNSLink names according to https://specs.ipfs.tech/http-gateways/subdomain-gateway/#host-request-header
id = dnsLinkLabelDecoder(id)
}
break
}
}

return { origin, protocol }
return { id, protocol }
}

function isSubdomainRequest (event: FetchEvent): boolean {
const { origin, protocol } = getSubdomainParts(event.request)
console.log('isSubdomainRequest.origin: ', origin)
const { id, protocol } = getSubdomainParts(event.request)
console.log('isSubdomainRequest.id: ', id)
console.log('isSubdomainRequest.protocol: ', protocol)

return origin != null && protocol != null
return id != null && protocol != null
}

const isValidRequestForSW = (event: FetchEvent): boolean =>
Expand Down Expand Up @@ -151,8 +162,6 @@ self.addEventListener('fetch', event => {
const newUrlString = newParts.join('/') + '/' + destinationParts.slice(index).join('/')
const newUrl = new URL(newUrlString)

// const { origin, protocol } = getSubdomainParts(event)

/**
* respond with redirect to newUrl
*/
Expand Down
23 changes: 17 additions & 6 deletions tests/path-or-subdomain.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,44 @@ import { isPathOrSubdomainRequest } from '../src/lib/path-or-subdomain.ts'

describe('isPathOrSubdomainRequest', () => {
it('returns true for path-based request', () => {
expect(isPathOrSubdomainRequest('example.com', {
expect(isPathOrSubdomainRequest({
hostname: 'example.com',
pathname: '/ipfs/bafyFoo'
})).to.equal(true)
expect(isPathOrSubdomainRequest('example.com', {
expect(isPathOrSubdomainRequest({
hostname: 'example.com',
pathname: '/ipns/specs.ipfs.tech'
})).to.equal(true)
})

it('returns true for subdomain request', () => {
expect(isPathOrSubdomainRequest('example.com', {
expect(isPathOrSubdomainRequest({
hostname: 'bafyFoo.ipfs.example.com',
pathname: '/'
})).to.equal(true)
expect(isPathOrSubdomainRequest('example.com', {
expect(isPathOrSubdomainRequest({
hostname: 'docs.ipfs.tech.ipns.example.com',
pathname: '/'
})).to.equal(true)
})

it('returns true for inlined dnslink subdomain request', () => {
expect(isPathOrSubdomainRequest({
hostname: 'bafyFoo.ipfs.example.com',
pathname: '/'
})).to.equal(true)
expect(isPathOrSubdomainRequest({
hostname: 'specs-ipfs-tech.ipns.example.com',
pathname: '/'
})).to.equal(true)
})

it('returns false for non-path and non-subdomain request', () => {
expect(isPathOrSubdomainRequest('example.com', {
expect(isPathOrSubdomainRequest({
hostname: 'example.com',
pathname: '/foo/bar'
})).to.equal(false)
expect(isPathOrSubdomainRequest('example.com', {
expect(isPathOrSubdomainRequest({
hostname: 'foo.bar.example.com',
pathname: '/'
})).to.equal(false)
Expand Down
2 changes: 1 addition & 1 deletion webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ const dev = {
// Only update what has changed on hot reload
hot: true,
port: 3000,
allowedHosts: [process.env.BASE_URL ?? 'helia-sw-gateway.localhost', 'localhost']
allowedHosts: ['helia-sw-gateway.localhost', 'localhost']
},

plugins: [
Expand Down

0 comments on commit 333ee9f

Please sign in to comment.