diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..6313b56c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/package.json b/package.json index 93ce0604..57022382 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/_redirects b/public/_redirects index 5a3a60a0..3b2133ec 100644 --- a/public/_redirects +++ b/public/_redirects @@ -1,2 +1 @@ -/ipns/* /?helia-sw=/ipns/:splat 302 -/ipfs/* /?helia-sw=/ipfs/:splat 302 +/* /?helia-sw=/:splat 302 diff --git a/src/index.tsx b/src/index.tsx index 5ad27ed2..9c730db5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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() @@ -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( ) 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 diff --git a/src/lib/dns-link-labels.ts b/src/lib/dns-link-labels.ts index 04aabe40..1e65edcb 100644 --- a/src/lib/dns-link-labels.ts +++ b/src/lib/dns-link-labels.ts @@ -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('.') } /** diff --git a/src/lib/heliaFetch.ts b/src/lib/heliaFetch.ts index 62fa39de..4f4e14f6 100644 --- a/src/lib/heliaFetch.ts +++ b/src/lib/heliaFetch.ts @@ -1,6 +1,5 @@ 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 { @@ -8,7 +7,7 @@ export interface HeliaFetchOptions { helia: Helia signal?: AbortSignal headers?: Headers - origin?: string | null + id?: string | null protocol?: string | null } @@ -116,9 +115,9 @@ 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. @@ -126,19 +125,16 @@ function changeCssFontPath (path: string): string { * * 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 { +export async function heliaFetch ({ path, helia, signal, headers, id, protocol }: HeliaFetchOptions): Promise { 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 { diff --git a/src/lib/path-or-subdomain.ts b/src/lib/path-or-subdomain.ts index 9f7ac86a..8b868d61 100644 --- a/src/lib/path-or-subdomain.ts +++ b/src/lib/path-or-subdomain.ts @@ -1,8 +1,9 @@ -const subdomainRegex = /^(?[^/]+)\.(?ip[fn]s)?$/ +// TODO: dry, this is same regex code as in getSubdomainParts +const subdomainRegex = /^(?[^/]+)\.(?ip[fn]s)\.[^/]+$/ const pathRegex = /^\/(?ip[fn]s)\/(?.*)$/ -export const isPathOrSubdomainRequest = (baseUrl: string, location: Pick): boolean => { - const subdomain = location.hostname.replace(`.${baseUrl}`, '') +export const isPathOrSubdomainRequest = (location: Pick): boolean => { + const subdomain = location.hostname const subdomainMatch = subdomain.match(subdomainRegex) const pathMatch = location.pathname.match(pathRegex) diff --git a/src/lib/webpack-constants.ts b/src/lib/webpack-constants.ts deleted file mode 100644 index 77c23819..00000000 --- a/src/lib/webpack-constants.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ - -/** - * You can change the BASE_URL when deploying this app to a different domain. - */ -const BASE_URL = process.env.BASE_URL! - -export { - BASE_URL -} diff --git a/src/sw.ts b/src/sw.ts index 4947cbf0..bd5a126a 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -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 @@ -51,8 +51,8 @@ const fetchHandler = async ({ path, request }: FetchHandlerArg): Promise { 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 = /^(?[^/]+)\.(?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 => @@ -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 */ diff --git a/tests/path-or-subdomain.spec.ts b/tests/path-or-subdomain.spec.ts index 83fa105c..0c26a94a 100644 --- a/tests/path-or-subdomain.spec.ts +++ b/tests/path-or-subdomain.spec.ts @@ -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) diff --git a/webpack.config.js b/webpack.config.js index c1c65a14..c3a10fac 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -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: [