diff --git a/README.md b/README.md index 88c3ea6..fae9366 100644 --- a/README.md +++ b/README.md @@ -46,15 +46,19 @@ isIPFS.path('/ipfs/js-ipfs/blob/master/README.md') // false isIPFS.urlOrPath('https://ipfs.io/ipfs/QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o') // true isIPFS.urlOrPath('https://ipfs.io/ipns/github.com') // true isIPFS.urlOrPath('/ipfs/QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o') // true +isIPFS.urlOrPath('ipfs://QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o') // true isIPFS.urlOrPath('/ipns/github.com') // true +isIPFS.urlOrPath('ipns://github.com') // true isIPFS.urlOrPath('https://bafybeie5gq4jxvzmsym6hjlwxej4rwdoxt7wadqvmmwbqi7r27fclha2va.ipfs.dweb.link') // true isIPFS.urlOrPath('https://google.com') // false isIPFS.ipfsUrl('https://ipfs.io/ipfs/QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o') // true isIPFS.ipfsUrl('https://ipfs.io/ipfs/invalid-hash') // false +isIPFS.ipfsUrl('ipfs://ipfs.io/ipfs/QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o') // true isIPFS.ipnsUrl('https://ipfs.io/ipfs/QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o') // false isIPFS.ipnsUrl('https://ipfs.io/ipns/github.com') // true +isIPFS.ipnsUrl('ipns://ipfs.io/ipns/github.com') // true isIPFS.ipfsPath('/ipfs/QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o') // true isIPFS.ipfsPath('/ipfs/invalid-hash') // false diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..9492dcb --- /dev/null +++ b/src/index.js @@ -0,0 +1,182 @@ +'use strict' + +const multihash = require('multihashes') +const multibase = require('multibase') +const Multiaddr = require('multiaddr') +const mafmt = require('mafmt') +const CID = require('cids') +const { URL } = require('iso-url') +const uint8ArrayToString = require('uint8arrays/to-string') + +const pathGatewayPattern = /^https?:\/\/[^/]+\/(ip[fn]s)\/([^/?#]+)/ +const pathPattern = /^\/(ip[fn]s)\/([^/?#]+)/ +const defaultProtocolMatch = 1 +const defaultHashMath = 2 + +// CID, libp2p-key or DNSLink +const subdomainGatewayPattern = /^https?:\/\/([^/]+)\.(ip[fn]s)\.[^/?]+/ +const subdomainIdMatch = 1 +const subdomainProtocolMatch = 2 + +// Fully qualified domain name (FQDN) that has an explicit .tld suffix +const fqdnWithTld = /^(([a-z0-9]|[a-z0-9][a-z0-9-]*[a-z0-9])\.)+([a-z0-9]|[a-z0-9][a-z0-9-]*[a-z0-9])$/ + +function isMultihash (hash) { + const formatted = convertToString(hash) + try { + multihash.decode(multibase.decode('z' + formatted)) + return true + } catch (e) { + return false + } +} + +function isMultibase (hash) { + try { + return multibase.isEncoded(hash) + } catch (e) { + return false + } +} + +function isCID (hash) { + try { + new CID(hash) // eslint-disable-line no-new + return true + } catch (e) { + return false + } +} + +function isMultiaddr (input) { + if (!input) return false + if (Multiaddr.isMultiaddr(input)) return true + try { + new Multiaddr(input) // eslint-disable-line no-new + return true + } catch (e) { + return false + } +} + +function isPeerMultiaddr (input) { + return isMultiaddr(input) && mafmt.IPFS.matches(input) +} + +function isIpfs (input, pattern, protocolMatch = defaultProtocolMatch, hashMatch = defaultHashMath) { + const formatted = convertToString(input) + if (!formatted) { + return false + } + + const match = formatted.match(pattern) + if (!match) { + return false + } + + if (match[protocolMatch] !== 'ipfs') { + return false + } + + let hash = match[hashMatch] + + if (hash && pattern === subdomainGatewayPattern) { + // when doing checks for subdomain context + // ensure hash is case-insensitive + // (browsers force-lowercase authority compotent anyway) + hash = hash.toLowerCase() + } + + return isCID(hash) +} + +function isIpns (input, pattern, protocolMatch = defaultProtocolMatch, hashMatch) { + const formatted = convertToString(input) + if (!formatted) { + return false + } + const match = formatted.match(pattern) + if (!match) { + return false + } + + if (match[protocolMatch] !== 'ipns') { + return false + } + + let ipnsId = match[hashMatch] + + if (ipnsId && pattern === subdomainGatewayPattern) { + // when doing checks for subdomain context + // ensure ipnsId is case-insensitive + // (browsers force-lowercase authority compotent anyway) + ipnsId = ipnsId.toLowerCase() + // Check if it is cidv1 + if (isCID(ipnsId)) return true + // Check if it looks like FQDN + try { + if (!ipnsId.includes('.') && ipnsId.includes('-')) { + // name without tld, assuming its inlined into a single DNS label + // (https://github.com/ipfs/in-web-browsers/issues/169) + // en-wikipedia--on--ipfs-org → en.wikipedia-on-ipfs.org + ipnsId = ipnsId.replace(/--/g, '@').replace(/-/g, '.').replace(/@/g, '-') + } + // URL implementation in web browsers forces lowercase of the hostname + const { hostname } = new URL(`http://${ipnsId}`) // eslint-disable-line no-new + // Check if potential FQDN has an explicit TLD + return fqdnWithTld.test(hostname) + } catch (e) { + return false + } + } + + return true +} + +function isString (input) { + return typeof input === 'string' +} + +function convertToString (input) { + if (input instanceof Uint8Array) { + return uint8ArrayToString(input, 'base58btc') + } + + if (isString(input)) { + return input + } + + return false +} + +const ipfsSubdomain = (url) => isIpfs(url, subdomainGatewayPattern, subdomainProtocolMatch, subdomainIdMatch) +const ipnsSubdomain = (url) => isIpns(url, subdomainGatewayPattern, subdomainProtocolMatch, subdomainIdMatch) +const subdomain = (url) => ipfsSubdomain(url) || ipnsSubdomain(url) + +const ipfsUrl = (url) => isIpfs(url, pathGatewayPattern) || ipfsSubdomain(url) +const ipnsUrl = (url) => isIpns(url, pathGatewayPattern) || ipnsSubdomain(url) +const url = (url) => ipfsUrl(url) || ipnsUrl(url) || subdomain(url) + +const path = (path) => isIpfs(path, pathPattern) || isIpns(path, pathPattern) + +module.exports = { + multihash: isMultihash, + multiaddr: isMultiaddr, + peerMultiaddr: isPeerMultiaddr, + cid: isCID, + base32cid: (cid) => (isMultibase(cid) === 'base32' && isCID(cid)), + ipfsSubdomain, + ipnsSubdomain, + subdomain, + subdomainGatewayPattern, + ipfsUrl, + ipnsUrl, + url, + pathGatewayPattern: pathGatewayPattern, + ipfsPath: (path) => isIpfs(path, pathPattern), + ipnsPath: (path) => isIpns(path, pathPattern), + path, + pathPattern, + urlOrPath: (x) => url(x) || path(x), + cidPath: path => isString(path) && !isCID(path) && isIpfs(`/ipfs/${path}`, pathPattern) +} diff --git a/src/index.ts b/src/index.ts index 0d7b9be..2ba6120 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,15 +40,19 @@ * isIPFS.urlOrPath('https://ipfs.io/ipfs/QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o') // true * isIPFS.urlOrPath('https://ipfs.io/ipns/github.com') // true * isIPFS.urlOrPath('/ipfs/QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o') // true + * isIPFS.urlOrPath('ipfs://QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o') // true * isIPFS.urlOrPath('/ipns/github.com') // true + * isIPFS.urlOrPath('ipns://github.com') // true * isIPFS.urlOrPath('https://bafybeie5gq4jxvzmsym6hjlwxej4rwdoxt7wadqvmmwbqi7r27fclha2va.ipfs.dweb.link') // true * isIPFS.urlOrPath('https://google.com') // false * * isIPFS.ipfsUrl('https://ipfs.io/ipfs/QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o') // true * isIPFS.ipfsUrl('https://ipfs.io/ipfs/invalid-hash') // false + * isIPFS.ipfsUrl('ipfs://ipfs.io/ipfs/QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o') // true * * isIPFS.ipnsUrl('https://ipfs.io/ipfs/QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o') // false * isIPFS.ipnsUrl('https://ipfs.io/ipns/github.com') // true + * isIPFS.ipnsUrl('ipns://ipfs.io/ipns/github.com') // true * * isIPFS.ipfsPath('/ipfs/QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o') // true * isIPFS.ipfsPath('/ipfs/invalid-hash') // false @@ -116,6 +120,9 @@ const subdomainProtocolMatch = 2 // Fully qualified domain name (FQDN) that has an explicit .tld suffix const fqdnWithTld = /^(([a-z0-9]|[a-z0-9][a-z0-9-]*[a-z0-9])\.)+([a-z0-9]|[a-z0-9][a-z0-9-]*[a-z0-9])$/ +// URI IANA-scheme +const uriSchemePattern = /^(ip[fn]s):\/\/([^/?#]+)/ + function isMultihash (hash: Uint8Array | string): boolean { const formatted = convertToString(hash) @@ -320,13 +327,13 @@ export const subdomain = (url: string | Uint8Array): boolean => ipfsSubdomain(ur * Returns `true` if the provided string is a valid IPFS url or `false` * otherwise. */ -export const ipfsUrl = (url: string | Uint8Array): boolean => isIpfs(url, pathGatewayPattern) || ipfsSubdomain(url) +export const ipfsUrl = (url: string | Uint8Array): boolean => isIpfs(url, pathGatewayPattern) || ipfsSubdomain(url) || isIpfs(url, uriSchemePattern) /** * Returns `true` if the provided string is a valid IPNS url or `false` * otherwise. */ -export const ipnsUrl = (url: string | Uint8Array): boolean => isIpns(url, pathGatewayPattern) || ipnsSubdomain(url) +export const ipnsUrl = (url: string | Uint8Array): boolean => isIpns(url, pathGatewayPattern) || ipnsSubdomain(url) || isIpns(url, uriSchemePattern) /** * Returns `true` if the provided string is a valid IPFS or IPNS url or `false` diff --git a/test/test-path.spec.ts b/test/test-path.spec.ts index 6725ae1..e4c0c8e 100644 --- a/test/test-path.spec.ts +++ b/test/test-path.spec.ts @@ -107,6 +107,12 @@ describe('ipfs path', () => { done() }) + it('isIPFS.urlOrPath should match an IANA-schema compliant ipfs url', (done) => { + const actual = isIPFS.urlOrPath('ipfs://QmYHNYAaYK5hm3ZhZFx5W9H6xydKDGimjdgJMrMSdnctEm') + expect(actual).to.equal(true) + done() + }) + it('isIPFS.urlOrPath should match ipns url', (done) => { const actual = isIPFS.urlOrPath('http://ipfs.io/ipns/foo.bar.com') expect(actual).to.equal(true) @@ -153,7 +159,12 @@ describe('ipfs path', () => { }) it('isIPFS.cidPath should not match an IPFS path', () => { - const actual = isIPFS.cidPath('/ipfs/QmYHNYAaYK5hm3ZhZFx5W9H6xydKDGimjdgJMrMSdnctEm') - expect(actual).to.equal(false) + expect(isIPFS.cidPath('/ipfs/QmYHNYAaYK5hm3ZhZFx5W9H6xydKDGimjdgJMrMSdnctEm')).to.be.false() + }) +}) + +describe('ipns path', () => { + it('isIPFS.urlOrPath should match an IANA-schema compliant ipns url', () => { + expect(isIPFS.urlOrPath('ipns://QmYHNYAaYK5hm3ZhZFx5W9H6xydKDGimjdgJMrMSdnctEm')).to.be.true() }) }) diff --git a/test/test-url.spec.ts b/test/test-url.spec.ts index bd5bfb3..3523e3c 100644 --- a/test/test-url.spec.ts +++ b/test/test-url.spec.ts @@ -11,6 +11,12 @@ describe('ipfs url', () => { done() }) + it('isIPFS.ipfsUrl should match an IANA-schema compliant ipfs uri', (done) => { + const actual = isIPFS.ipfsUrl('ipfs://QmYHNYAaYK5hm3ZhZFx5W9H6xydKDGimjdgJMrMSdnctEm') + expect(actual).to.equal(true) + done() + }) + it('isIPFS.ipfsUrl should match a complex ipfs url', (done) => { const actual = isIPFS.ipfsUrl('http://ipfs.alexandria.media/ipfs/QmeWz9YZEeNFXQhHg4PnR5ZiNr5isttgi5n1tc1eD5EfGU/content/index.html?arg=val#hash') expect(actual).to.equal(true) @@ -71,12 +77,24 @@ describe('ipfs url', () => { done() }) + it('isIPFS.ipnsUrl should not match an IANA-schema compliant ipfs uri', (done) => { + const actual = isIPFS.ipnsUrl('ipfs://QmYHNYAaYK5hm3ZhZFx5W9H6xydKDGimjdgJMrMSdnctEm') + expect(actual).to.equal(false) + done() + }) + it('isIPFS.url should match an ipfs url', (done) => { const actual = isIPFS.url('http://ipfs.io/ipfs/QmYHNYAaYK5hm3ZhZFx5W9H6xydKDGimjdgJMrMSdnctEm') expect(actual).to.equal(true) done() }) + it('isIPFS.url should match an IANA-schema compliant ipfs uri', (done) => { + const actual = isIPFS.url('ipfs://QmYHNYAaYK5hm3ZhZFx5W9H6xydKDGimjdgJMrMSdnctEm') + expect(actual).to.equal(true) + done() + }) + it('isIPFS.url should match an ipns url', (done) => { const actual = isIPFS.url('http://ipfs.io/ipns/github.com/') expect(actual).to.equal(true) @@ -101,3 +119,17 @@ describe('ipfs url', () => { done() }) }) + +describe('ipns url', () => { + it('isIPFS.ipnsUrl should match an IANA-schema compliant ipns uri', () => { + expect(isIPFS.ipnsUrl('ipns://QmYHNYAaYK5hm3ZhZFx5W9H6xydKDGimjdgJMrMSdnctEm')).to.be.true() + }) + + it('isIPFS.ipnsUrl should not match an IANA-schema compliant ipfs uri', () => { + expect(isIPFS.ipnsUrl('ipfs://QmYHNYAaYK5hm3ZhZFx5W9H6xydKDGimjdgJMrMSdnctEm')).to.be.false() + }) + + it('isIPFS.url should match an IANA-schema compliant ipns uri', () => { + expect(isIPFS.url('ipns://QmYHNYAaYK5hm3ZhZFx5W9H6xydKDGimjdgJMrMSdnctEm')).to.be.true() + }) +})