Skip to content

Commit

Permalink
fix: support https?://<dnsLink>.ipns.<gateway> urls (#16)
Browse files Browse the repository at this point in the history
* fix: support https?://<dnsLink>.ipns.<gateway> urls

* chore: dont check for dnslink before trying peerId
  • Loading branch information
SgtPooki authored Mar 11, 2024
1 parent e008b20 commit 0ece19a
Show file tree
Hide file tree
Showing 2 changed files with 78 additions and 20 deletions.
42 changes: 37 additions & 5 deletions packages/verified-fetch/src/utils/parse-url-string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export interface ParsedUrlStringResults {
const URL_REGEX = /^(?<protocol>ip[fn]s):\/\/(?<cidOrPeerIdOrDnsLink>[^/?]+)\/?(?<path>[^?]*)\??(?<queryString>.*)$/
const PATH_REGEX = /^\/(?<protocol>ip[fn]s)\/(?<cidOrPeerIdOrDnsLink>[^/?]+)\/?(?<path>[^?]*)\??(?<queryString>.*)$/
const PATH_GATEWAY_REGEX = /^https?:\/\/(.*[^/])\/(?<protocol>ip[fn]s)\/(?<cidOrPeerIdOrDnsLink>[^/?]+)\/?(?<path>[^?]*)\??(?<queryString>.*)$/
const SUBDOMAIN_GATEWAY_REGEX = /^https?:\/\/(?<cidOrPeerIdOrDnsLink>[^/.?]+)\.(?<protocol>ip[fn]s)\.([^/?]+)\/?(?<path>[^?]*)\??(?<queryString>.*)$/
const SUBDOMAIN_GATEWAY_REGEX = /^https?:\/\/(?<cidOrPeerIdOrDnsLink>[^/?]+)\.(?<protocol>ip[fn]s)\.([^/?]+)\/?(?<path>[^?]*)\??(?<queryString>.*)$/

function matchURLString (urlString: string): Record<string, string> {
for (const pattern of [URL_REGEX, PATH_REGEX, PATH_GATEWAY_REGEX, SUBDOMAIN_GATEWAY_REGEX]) {
Expand All @@ -47,6 +47,34 @@ function matchURLString (urlString: string): Record<string, string> {
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.
*
Expand Down Expand Up @@ -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 })
Expand All @@ -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)
Expand Down
56 changes: 41 additions & 15 deletions packages/verified-fetch/test/utils/parse-url-string.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -751,23 +752,48 @@ describe('parseUrlString', () => {
})
})

HTTP_PROTOCOLS.forEach(proto => {
describe(`${proto}://<key>.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<Array<[string, string, (i: number) => string | Promise<PeerId>]>>((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: '',
Expand All @@ -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',
Expand All @@ -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/',
Expand All @@ -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: '',
Expand All @@ -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',
Expand All @@ -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/',
Expand Down

0 comments on commit 0ece19a

Please sign in to comment.