diff --git a/packages/verified-fetch/src/utils/parse-url-string.ts b/packages/verified-fetch/src/utils/parse-url-string.ts index 115b250d..6b5ff903 100644 --- a/packages/verified-fetch/src/utils/parse-url-string.ts +++ b/packages/verified-fetch/src/utils/parse-url-string.ts @@ -33,7 +33,7 @@ export interface ParsedUrlStringResults { const URL_REGEX = /^(?ip[fn]s):\/\/(?[^/?]+)\/?(?[^?]*)\??(?.*)$/ const PATH_REGEX = /^\/(?ip[fn]s)\/(?[^/?]+)\/?(?[^?]*)\??(?.*)$/ const PATH_GATEWAY_REGEX = /^https?:\/\/(.*[^/])\/(?ip[fn]s)\/(?[^/?]+)\/?(?[^?]*)\??(?.*)$/ -const SUBDOMAIN_GATEWAY_REGEX = /^https?:\/\/(?[^/.?]+)\.(?ip[fn]s)\.([^/?]+)\/?(?[^?]*)\??(?.*)$/ +const SUBDOMAIN_GATEWAY_REGEX = /^https?:\/\/(?[^/?]+)\.(?ip[fn]s)\.([^/?]+)\/?(?[^?]*)\??(?.*)$/ function matchURLString (urlString: string): Record { for (const pattern of [URL_REGEX, PATH_REGEX, PATH_GATEWAY_REGEX, SUBDOMAIN_GATEWAY_REGEX]) { @@ -47,6 +47,34 @@ function matchURLString (urlString: string): Record { throw new TypeError(`Invalid URL: ${urlString}, please use ipfs://, ipns://, or gateway URLs only`) } +/** + * 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])?$/ + +/** + * Checks if label looks like inlined DNSLink. + * (https://specs.ipfs.tech/http-gateways/subdomain-gateway/#host-request-header) + */ +function isInlinedDnsLink (label: string): boolean { + return dnsLabelRegex.test(label) && label.includes('-') && !label.includes('.') +} + +/** + * DNSLink label decoding + * * Every standalone - is replaced with . + * * Every remaining -- is replaced with - + * + * @example en-wikipedia--on--ipfs-org.ipns.example.net -> example.net/ipns/en.wikipedia-on-ipfs.org + */ +function dnsLinkLabelDecoder (linkLabel: string): string { + return linkLabel.replace(/--/g, '%').replace(/-/g, '.').replace(/%/g, '-') +} + /** * A function that parses ipfs:// and ipns:// URLs, returning an object with easily recognizable properties. * @@ -80,7 +108,6 @@ export async function parseUrlString ({ urlString, ipns, logger }: ParseUrlStrin // protocol is ipns log.trace('Attempting to resolve PeerId for %s', cidOrPeerIdOrDnsLink) let peerId = null - try { peerId = peerIdFromString(cidOrPeerIdOrDnsLink) resolveResult = await ipns.resolve(peerId, { onProgress: options?.onProgress }) @@ -99,13 +126,18 @@ export async function parseUrlString ({ urlString, ipns, logger }: ParseUrlStrin } if (cid == null) { - log.trace('Attempting to resolve DNSLink for %s', cidOrPeerIdOrDnsLink) + let decodedDnsLinkLabel = cidOrPeerIdOrDnsLink + if (isInlinedDnsLink(cidOrPeerIdOrDnsLink)) { + decodedDnsLinkLabel = dnsLinkLabelDecoder(cidOrPeerIdOrDnsLink) + log.trace('decoded dnslink from "%s" to "%s"', cidOrPeerIdOrDnsLink, decodedDnsLinkLabel) + } + log.trace('Attempting to resolve DNSLink for %s', decodedDnsLinkLabel) try { - resolveResult = await ipns.resolveDns(cidOrPeerIdOrDnsLink, { onProgress: options?.onProgress }) + resolveResult = await ipns.resolveDns(decodedDnsLinkLabel, { onProgress: options?.onProgress }) cid = resolveResult?.cid resolvedPath = resolveResult?.path - log.trace('resolved %s to %c', cidOrPeerIdOrDnsLink, cid) + log.trace('resolved %s to %c', decodedDnsLinkLabel, cid) ipnsCache.set(cidOrPeerIdOrDnsLink, resolveResult, 60 * 1000 * 2) } catch (err: any) { log.error('Could not resolve DnsLink for "%s"', cidOrPeerIdOrDnsLink, err) diff --git a/packages/verified-fetch/test/utils/parse-url-string.spec.ts b/packages/verified-fetch/test/utils/parse-url-string.spec.ts index 454fc1c5..ad750224 100644 --- a/packages/verified-fetch/test/utils/parse-url-string.spec.ts +++ b/packages/verified-fetch/test/utils/parse-url-string.spec.ts @@ -3,6 +3,7 @@ import { defaultLogger } from '@libp2p/logger' import { createEd25519PeerId } from '@libp2p/peer-id-factory' import { expect } from 'aegir/chai' import { CID } from 'multiformats/cid' +import { match } from 'sinon' import { stubInterface } from 'sinon-ts' import { parseUrlString } from '../../src/utils/parse-url-string.js' import type { IPNS } from '@helia/ipns' @@ -751,23 +752,48 @@ describe('parseUrlString', () => { }) }) - HTTP_PROTOCOLS.forEach(proto => { - describe(`${proto}://.ipns.example.com URLs`, () => { - let peerId: PeerId + const IPNS_TYPES = [ + ['dnslink-encoded', (i: number) => `${i}-example-com`], + ['dnslink-decoded', (i: number) => `${i}.example.com`], + ['peerid', async () => createEd25519PeerId()] + ] as const + + IPNS_TYPES.flatMap(([type, fn]) => { + // merge IPNS_TYPES with HTTP_PROTOCOLS + return HTTP_PROTOCOLS.reduce string | Promise]>>((acc, proto) => { + acc.push([proto, type, fn]) + return acc + }, []) + }, []).forEach(([proto, type, getVal]) => { + describe(`${proto}://<${type}>.ipns.example.com URLs`, () => { + let value: PeerId | string let cid: CID - + let i = 0 beforeEach(async () => { - peerId = await createEd25519PeerId() + value = await getVal(i++) cid = CID.parse('QmdmQXB2mzChmMeKY47C43LxUdg1NDJ5MWcKMKxDu7RgQm') - ipns.resolve.withArgs(matchPeerId(peerId)).resolves({ - cid, - path: '' - }) + if (type === 'peerid') { + ipns.resolve.withArgs(matchPeerId(value as PeerId)).resolves({ + cid, + path: '' + }) + } else if (type === 'dnslink-encoded') { + const matchValue = (value as string).replace(/-/g, '.') + ipns.resolveDns.withArgs(match(matchValue)).resolves({ + cid, + path: '' + }) + } else { + ipns.resolveDns.withArgs(match(value as string)).resolves({ + cid, + path: '' + }) + } }) it('should parse a IPNS Subdomain Gateway URL with a CID only', async () => { await assertMatchUrl( - `${proto}://${peerId}.ipns.example.com`, { + `${proto}://${value.toString()}.ipns.example.com`, { protocol: 'ipns', cid: cid.toString(), path: '', @@ -778,7 +804,7 @@ describe('parseUrlString', () => { it('can parse a IPNS Subdomain Gateway URL with CID+path', async () => { await assertMatchUrl( - `${proto}://${peerId}.ipns.example.com/1 - Barrel - Part 1/1 - Barrel - Part 1 - alt.txt`, { + `${proto}://${value.toString()}.ipns.example.com/1 - Barrel - Part 1/1 - Barrel - Part 1 - alt.txt`, { protocol: 'ipns', cid: cid.toString(), path: '1 - Barrel - Part 1/1 - Barrel - Part 1 - alt.txt', @@ -789,7 +815,7 @@ describe('parseUrlString', () => { it('can parse a IPNS Subdomain Gateway URL with CID+directoryPath', async () => { await assertMatchUrl( - `${proto}://${peerId}.ipns.example.com/path/to/dir/`, { + `${proto}://${value.toString()}.ipns.example.com/path/to/dir/`, { protocol: 'ipns', cid: cid.toString(), path: 'path/to/dir/', @@ -800,7 +826,7 @@ describe('parseUrlString', () => { it('can parse a IPNS Subdomain Gateway URL with CID+queryString', async () => { await assertMatchUrl( - `${proto}://${peerId}.ipns.example.com?format=car`, { + `${proto}://${value.toString()}.ipns.example.com?format=car`, { protocol: 'ipns', cid: cid.toString(), path: '', @@ -813,7 +839,7 @@ describe('parseUrlString', () => { it('can parse a IPNS Subdomain Gateway URL with CID+path+queryString', async () => { await assertMatchUrl( - `${proto}://${peerId}.ipns.example.com/1 - Barrel - Part 1/1 - Barrel - Part 1 - alt.txt?format=tar`, { + `${proto}://${value.toString()}.ipns.example.com/1 - Barrel - Part 1/1 - Barrel - Part 1 - alt.txt?format=tar`, { protocol: 'ipns', cid: cid.toString(), path: '1 - Barrel - Part 1/1 - Barrel - Part 1 - alt.txt', @@ -826,7 +852,7 @@ describe('parseUrlString', () => { it('can parse a IPNS Subdomain Gateway URL with CID+directoryPath+queryString', async () => { await assertMatchUrl( - `${proto}://${peerId}.ipns.example.com/path/to/dir/?format=tar`, { + `${proto}://${value.toString()}.ipns.example.com/path/to/dir/?format=tar`, { protocol: 'ipns', cid: cid.toString(), path: 'path/to/dir/',