Skip to content

Commit

Permalink
feat: helper-ui input-validator update (#202)
Browse files Browse the repository at this point in the history
* feat: helper-ui input-validator update

the input validator now accepts the same urls as verified-fetch and provides more helpful information

* fix: support native ipfs/ipns urls

* fix: ux of input-validator help text

* fix: regex support for more urls

* fix: ux improvements to input-validator help text

* fix: helper-ui input label

* fix: preact import has no default

* feat: helper-ui input allows CID only
  • Loading branch information
SgtPooki authored Apr 17, 2024
1 parent 8c85b6f commit e95e70e
Show file tree
Hide file tree
Showing 7 changed files with 246 additions and 55 deletions.
2 changes: 1 addition & 1 deletion src/components/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'preact/compat'

export default ({ handleSubmit, requestPath, setRequestPath }): React.JSX.Element => (
<form id='add-file' onSubmit={handleSubmit}>
<label htmlFor='inputContent' className='f5 ma0 pb2 aqua fw4 db'>CID (and path)</label>
<label htmlFor='inputContent' className='f5 ma0 pb2 aqua fw4 db'>CID, Content Path, or URL</label>
<input
className='input-reset bn black-80 bg-white pa3 w-100 mb3'
id='inputContent'
Expand Down
102 changes: 55 additions & 47 deletions src/components/input-validator.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,44 @@
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
import { CID } from 'multiformats/cid'
import React from 'preact/compat'
import { nativeProtocolRegex, pathRegex, subdomainRegex, type IpfsUriParts } from '../lib/regex.js'

/**
* Test files:
* bafkreienxxjqg3jomg5b75k7547dgf7qlbd3qpxy2kbg537ck3rol4mcve - text - https://bafkreienxxjqg3jomg5b75k7547dgf7qlbd3qpxy2kbg537ck3rol4mcve.ipfs.w3s.link/?filename=test.txt
* bafkreicafxt3zr4cshf7qteztjzl62ouxqrofu647e44wt7s2iaqjn7bra - image/jpeg - http://127.0.0.1:8080/ipfs/bafkreicafxt3zr4cshf7qteztjzl62ouxqrofu647e44wt7s2iaqjn7bra?filename=bafkreicafxt3zr4cshf7qteztjzl62ouxqrofu647e44wt7s2iaqjn7bra
* bafkreif4ufrfpfcmqn5ltjvmeisgv4k7ykqz2mjygpngtwt4bijxosidqa - image/svg+xml - https://bafkreif4ufrfpfcmqn5ltjvmeisgv4k7ykqz2mjygpngtwt4bijxosidqa.ipfs.dweb.link/?filename=Web3.Storage-logo.svg
* bafybeiekildl23opzqcsufewlbadhbabs6pyqg35tzpfavgtjyhchyikxa - video/quicktime - https://bafybeiekildl23opzqcsufewlbadhbabs6pyqg35tzpfavgtjyhchyikxa.ipfs.dweb.link
* bafkreiezuss4xkt5gu256vjccx7vocoksxk77vwmdrpwoumfbbxcy2zowq - video/webm (147.78 KiB) - https://bafkreiezuss4xkt5gu256vjccx7vocoksxk77vwmdrpwoumfbbxcy2zowq.ipfs.dweb.link
* bafybeierkpfmf4vhtdiujgahfptiyriykoetxf3rmd3vcxsdemincpjoyu - video/mp4 (2.80 MiB) - https://bafybeierkpfmf4vhtdiujgahfptiyriykoetxf3rmd3vcxsdemincpjoyu.ipfs.dweb.link
* QmbGtJg23skhvFmu9mJiePVByhfzu5rwo74MEkVDYAmF5T - video (160MiB)
* /ipns/k51qzi5uqu5dlvj2baxnqndepeb86cbk3ng7n3i46uzyxzyqj2xjonzllnv0v8 -
* /ipns/libp2p.io/
*/

/**
*
* Test CIDs
* QmbGtJg23skhvFmu9mJiePVByhfzu5rwo74MEkVDYAmF5T
*
*/
function FormatHelp (): React.JSX.Element {
return (
<>
<p>Invalid address, correct it and try again. For reference, accepted formats are:</p>
<table>
<tbody>
<tr>
<td>UNIX-like Content Path</td>
<td><pre className="di">/ipfs/cid/..</pre></td>
</tr>
<tr>
<td>HTTP Gateway URL</td>
<td><pre className="di">https://ipfs.io/ipfs/cid..</pre></td>
</tr>
<tr>
<td>Native IPFS URL</td>
<td><pre className="di">ipfs://cid/..</pre></td>
</tr>
</tbody>
</table>
<p>Learn more at <a target="_blank" href="https://docs.ipfs.tech/how-to/address-ipfs-on-web">Addressing IPFS on the Web</a></p>
</>
)
}

function ValidationMessage ({ cid, requestPath, pathNamespacePrefix, children }): React.JSX.Element {
function ValidationMessage ({ cidOrPeerIdOrDnslink, requestPath, protocol, children }): React.JSX.Element {
let errorElement: React.JSX.Element | null = null
if (requestPath == null || requestPath === '') {
errorElement = <span>Nothing to render yet. Enter an IPFS Path</span> // bafkreiezuss4xkt5gu256vjccx7vocoksxk77vwmdrpwoumfbbxcy2zowq
} else if (pathNamespacePrefix !== 'ipfs' && pathNamespacePrefix !== 'ipns') {
errorElement = <span>Not a valid IPFS or IPNS path. Use the format <pre className="di">/ip(f|n)s/cid/path</pre>, where /path is optional</span>
} else if (cid == null || cid === '') {
errorElement = <span>Nothing to render yet. Add a CID to your path</span> // bafkreiezuss4xkt5gu256vjccx7vocoksxk77vwmdrpwoumfbbxcy2zowq
} else if (pathNamespacePrefix === 'ipfs') {
errorElement = <span>Enter a valid IPFS/IPNS path.</span>
} else if (protocol !== 'ipfs' && protocol !== 'ipns') {
errorElement = <FormatHelp />
} else if (cidOrPeerIdOrDnslink == null || cidOrPeerIdOrDnslink === '') {
const contentType = protocol === 'ipfs' ? 'CID' : 'PeerId or DnsLink'
errorElement = <span>Content identifier missing. Add a {contentType} to your path</span>
} else if (protocol === 'ipfs') {
try {
CID.parse(cid)
CID.parse(cidOrPeerIdOrDnslink)
} catch {
errorElement = <span>Invalid CID</span>
}
Expand All @@ -49,32 +55,34 @@ function ValidationMessage ({ cid, requestPath, pathNamespacePrefix, children })
</>
}

const parseInput = (uri: string): Partial<IpfsUriParts> => {
const uriMatch = uri.match(pathRegex) ?? uri.match(subdomainRegex) ?? uri.match(nativeProtocolRegex)
if (uriMatch?.groups != null) {
const { protocol, cidOrPeerIdOrDnslink, path } = uriMatch.groups as unknown as IpfsUriParts
return { protocol, cidOrPeerIdOrDnslink, path: path?.trim() ?? undefined }
}

// it may be just a CID
try {
CID.parse(uri)
return { protocol: 'ipfs', cidOrPeerIdOrDnslink: uri }
} catch (_) {
// ignore.
}

return {}
}

export default function InputValidator ({ requestPath }: { requestPath: string }): React.JSX.Element {
/**
* requestPath may be any of the following formats:
*
* * `/ipfs/${cid}[/${path}]`
* * `/ipns/${dnsLinkDomain}[/${path}]`
* * `/ipns/${peerId}[/${path}]`
* * `http[s]://${cid}.ipfs.example.com[/${path}]`
* * `http[s]://${dnsLinkDomain}.ipns.example.com[/${path}]`
* * `http[s]://${peerId}.ipns.example.com[/${path}]`
* TODO: https://github.com/ipfs-shipyard/service-worker-gateway/issues/66
*/
const requestPathParts = requestPath.split('/')
const pathNamespacePrefix = requestPathParts[1]
const cid = requestPathParts[2]
const cidPath = requestPathParts[3] ? `/${requestPathParts.slice(3).join('/')}` : ''
const swPath = `/${pathNamespacePrefix}/${cid ?? ''}${cidPath ?? ''}`
const { protocol, cidOrPeerIdOrDnslink, path } = parseInput(requestPath)
const swPath = `/${protocol}/${cidOrPeerIdOrDnslink}${path ?? ''}`

return (
<div>
<ValidationMessage pathNamespacePrefix={pathNamespacePrefix} cid={cid} requestPath={requestPath}>

<ValidationMessage protocol={protocol} cidOrPeerIdOrDnslink={cidOrPeerIdOrDnslink} requestPath={requestPath}>
<a className="db" href={swPath} target="_blank">
<button id="load-directly" className='button-reset pv3 tc bn bg-animate bg-black-80 hover-bg-aqua white pointer w-100'>Load content</button>
</a>

</ValidationMessage>
</div>
)
Expand Down
5 changes: 1 addition & 4 deletions src/lib/path-or-subdomain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@ import { base32 } from 'multiformats/bases/base32'
import { base36 } from 'multiformats/bases/base36'
import { CID } from 'multiformats/cid'
import { dnsLinkLabelEncoder } from './dns-link-labels.js'

// TODO: dry, this is same regex code as in getSubdomainParts
const subdomainRegex = /^(?<id>[^/]+)\.(?<protocol>ip[fn]s)\.[^/]+$/
const pathRegex = /^\/(?<protocol>ip[fn]s)\/(?<path>.*)$/
import { pathRegex, subdomainRegex } from './regex.js'

export const isPathOrSubdomainRequest = (location: Pick<Location, 'host' | 'pathname'>): boolean => {
return isPathGatewayRequest(location) || isSubdomainGatewayRequest(location)
Expand Down
30 changes: 30 additions & 0 deletions src/lib/regex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export interface IpfsUriParts {
protocol: 'ipfs' | 'ipns'
cidOrPeerIdOrDnslink: string
path?: string
parentDomain?: string
}

/**
* `http[s]://${cid}.ipfs.example.com[/${path}]`
* `http[s]://${dnsLinkDomain}.ipns.example.com[/${path}]`
* `http[s]://${peerId}.ipns.example.com[/${path}]`
*
* @see https://regex101.com/r/EcY028/4
*/
export const subdomainRegex = /^(?:https?:\/\/|\/\/)?(?<cidOrPeerIdOrDnslink>[^/]+)\.(?<protocol>ip[fn]s)\.(?<parentDomain>[^/?#]*)(?<path>.*)$/

/**
* `http[s]://example.com/ipfs/${cid}[/${path}]`
* `/ipfs/${cid}[/${path}]`
* `/ipns/${dnsLinkDomain}[/${path}]`
* `/ipns/${peerId}[/${path}]`
*
* @see https://regex101.com/r/zdDp0i/1
*/
export const pathRegex = /^.*\/(?<protocol>ip[fn]s)\/(?<cidOrPeerIdOrDnslink>[^/?#]*)(?<path>.*)$/

/**
* `ip[fn]s://${cidOrPeerIdOrDnslink}${path}`
*/
export const nativeProtocolRegex = /^(?<protocol>ip[fn]s):\/\/(?<cidOrPeerIdOrDnslink>[^/?#]*)(?<path>.*)$/
2 changes: 1 addition & 1 deletion src/pages/redirects-interstitial.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'preact'
import React from 'preact/compat'

/**
* This page is only used to capture the ?helia-sw=/ip[fn]s/blah query parameter that
Expand Down
4 changes: 2 additions & 2 deletions test/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Tests

These tests are ran using `aegir test`.
Tests with the name `*.spec.ts` are ran using `npm run test:iso`.

Since we don't use aegir for building our dist folder, we need all tests in this directory to be javascript files. This is a temporary solution until we can use aegir for building our dist folder.
There is one file without a `.spec.ts` suffix that verifies our dist folder ran with `npm run test:node`, and no further test files without `.spec.ts` suffix should be added.
156 changes: 156 additions & 0 deletions test/regex.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/* eslint-env mocha */
import { expect } from 'aegir/chai'
import { nativeProtocolRegex, pathRegex, subdomainRegex } from '../src/lib/regex.js'

const validPathUrls = [
// IPFS paths without domain
['/ipfs/bafyFoo', 'ipfs', 'bafyFoo', ''],
['/ipfs/bafyFoo/', 'ipfs', 'bafyFoo', '/'],
['/ipfs/bafyFoo/path/to/file', 'ipfs', 'bafyFoo', '/path/to/file'],

// IPFS paths with domain
['http://example.com/ipfs/bafyFoo', 'ipfs', 'bafyFoo', ''],
['http://example.com/ipfs/bafyFoo/', 'ipfs', 'bafyFoo', '/'],
['http://example.com/ipfs/bafyFoo/path/to/file', 'ipfs', 'bafyFoo', '/path/to/file'],
['http://example.com/ipfs/bafyFoo/path/to/file/', 'ipfs', 'bafyFoo', '/path/to/file/'],

// IPNS paths without domain
['/ipns/specs.ipfs.tech', 'ipns', 'specs.ipfs.tech', ''],
['/ipns/specs.ipfs.tech/', 'ipns', 'specs.ipfs.tech', '/'],
['/ipns/specs.ipfs.tech/path/to/file', 'ipns', 'specs.ipfs.tech', '/path/to/file'],

// IPNS paths with domain
['http://example.com/ipns/specs.ipfs.tech', 'ipns', 'specs.ipfs.tech', ''],
['http://example.com/ipns/specs.ipfs.tech/', 'ipns', 'specs.ipfs.tech', '/'],
['http://example.com/ipns/specs.ipfs.tech/path/to/file', 'ipns', 'specs.ipfs.tech', '/path/to/file'],
['http://example.com/ipns/specs.ipfs.tech/path/to/file/', 'ipns', 'specs.ipfs.tech', '/path/to/file/'],

// schemeless
['//example.com/ipns/specs.ipfs.tech', 'ipns', 'specs.ipfs.tech', ''],
['//example.com/ipns/specs.ipfs.tech/', 'ipns', 'specs.ipfs.tech', '/'],
['//example.com/ipns/specs.ipfs.tech/path/to/file', 'ipns', 'specs.ipfs.tech', '/path/to/file'],
['//example.com/ipns/specs.ipfs.tech/path/to/file/', 'ipns', 'specs.ipfs.tech', '/path/to/file/'],

// with different path starts (hash, query, etc)
['/ipfs/bafyFoo#hash', 'ipfs', 'bafyFoo', '#hash'],
['/ipfs/bafyFoo?query', 'ipfs', 'bafyFoo', '?query'],
['/ipfs/bafyFoo?query#hash', 'ipfs', 'bafyFoo', '?query#hash'],
['/ipfs/bafyFoo#hash?query', 'ipfs', 'bafyFoo', '#hash?query']
]

const validSubdomainUrls = [
// IPFS subdomains
['http://bafyFoo.ipfs.example.com', 'ipfs', 'bafyFoo', ''],
['http://bafyFoo.ipfs.example.com/', 'ipfs', 'bafyFoo', '/'],
['http://bafyFoo.ipfs.example.com/path/to/file', 'ipfs', 'bafyFoo', '/path/to/file'],

// IPNS subdomains
['http://bafyFoo.ipns.example.com', 'ipns', 'bafyFoo', ''],
['http://bafyFoo.ipns.example.com/', 'ipns', 'bafyFoo', '/'],
['http://bafyFoo.ipns.example.com/path/to/file', 'ipns', 'bafyFoo', '/path/to/file'],

// IPNS subdomains with dnslink
['http://specs-ipfs-tech.ipns.example.com', 'ipns', 'specs-ipfs-tech', ''],
['http://specs-ipfs-tech.ipns.example.com/', 'ipns', 'specs-ipfs-tech', '/'],
['http://specs-ipfs-tech.ipns.example.com/path/to/file', 'ipns', 'specs-ipfs-tech', '/path/to/file'],

// schemeless
['//bafyFoo.ipfs.example.com', 'ipfs', 'bafyFoo', ''],
['//bafyFoo.ipfs.example.com/', 'ipfs', 'bafyFoo', '/'],
['//bafyFoo.ipfs.example.com/path/to/file', 'ipfs', 'bafyFoo', '/path/to/file'],

['//bafyFoo.ipns.example.com', 'ipns', 'bafyFoo', ''],
['//bafyFoo.ipns.example.com/', 'ipns', 'bafyFoo', '/'],
['//bafyFoo.ipns.example.com/path/to/file', 'ipns', 'bafyFoo', '/path/to/file'],

['//specs-ipfs-tech.ipns.example.com', 'ipns', 'specs-ipfs-tech', ''],
['//specs-ipfs-tech.ipns.example.com/', 'ipns', 'specs-ipfs-tech', '/'],
['//specs-ipfs-tech.ipns.example.com/path/to/file', 'ipns', 'specs-ipfs-tech', '/path/to/file'],

// hostname only
['bafyFoo.ipfs.example.com', 'ipfs', 'bafyFoo', ''],
['bafyFoo.ipfs.example.com/', 'ipfs', 'bafyFoo', '/'],
['bafyFoo.ipfs.example.com/path/to/file', 'ipfs', 'bafyFoo', '/path/to/file'],

['bafyFoo.ipns.example.com', 'ipns', 'bafyFoo', ''],
['bafyFoo.ipns.example.com/', 'ipns', 'bafyFoo', '/'],
['bafyFoo.ipns.example.com/path/to/file', 'ipns', 'bafyFoo', '/path/to/file'],

['specs-ipfs-tech.ipns.example.com', 'ipns', 'specs-ipfs-tech', ''],
['specs-ipfs-tech.ipns.example.com/', 'ipns', 'specs-ipfs-tech', '/'],
['specs-ipfs-tech.ipns.example.com/path/to/file', 'ipns', 'specs-ipfs-tech', '/path/to/file'],

// with different path starts (hash, query, etc)
['bafyFoo.ipfs.example.com#hash', 'ipfs', 'bafyFoo', '#hash'],
['bafyFoo.ipfs.example.com?query', 'ipfs', 'bafyFoo', '?query'],
['bafyFoo.ipfs.example.com?query#hash', 'ipfs', 'bafyFoo', '?query#hash'],
['bafyFoo.ipfs.example.com#hash?query', 'ipfs', 'bafyFoo', '#hash?query']
]

const validNativeProtocolUrls = [
['ipfs://bafyFoo', 'ipfs', 'bafyFoo', ''],
['ipfs://bafyFoo/', 'ipfs', 'bafyFoo', '/'],
['ipfs://bafyFoo/path/to/file', 'ipfs', 'bafyFoo', '/path/to/file'],

['ipns://specs.ipfs.tech', 'ipns', 'specs.ipfs.tech', ''],
['ipns://specs.ipfs.tech/', 'ipns', 'specs.ipfs.tech', '/'],
['ipns://specs.ipfs.tech/path/to/file', 'ipns', 'specs.ipfs.tech', '/path/to/file']
]

const invalidUrls = [
'http://localhost/notipfs/bafyFoo/path/to/file'
]

describe('regex', () => {
describe('paths', () => {
validPathUrls.forEach(([url, protocol, cidOrPeerIdOrDnslink, path]) => {
it(`should correctly match "${url}"`, () => {
const match = url.match(pathRegex)

expect(match).not.to.be.null()
expect(match?.groups).to.be.ok()
expect(match?.groups?.protocol).to.equal(protocol)
expect(match?.groups?.cidOrPeerIdOrDnslink).to.equal(cidOrPeerIdOrDnslink)
expect(match?.groups?.path).to.equal(path)
})
})
})

describe('subdomains', () => {
validSubdomainUrls.forEach(([url, protocol, cidOrPeerIdOrDnslink, path]) => {
it(`should correctly match "${url}"`, () => {
const match = url.match(subdomainRegex)

expect(match).not.to.be.null()
expect(match?.groups).to.be.ok()
expect(match?.groups?.protocol).to.equal(protocol)
expect(match?.groups?.cidOrPeerIdOrDnslink).to.equal(cidOrPeerIdOrDnslink)
expect(match?.groups?.path).to.equal(path)
})
})
})

describe('native protocols', () => {
validNativeProtocolUrls.forEach(([url, protocol, cidOrPeerIdOrDnslink, path]) => {
it(`should correctly match "${url}"`, () => {
const match = url.match(nativeProtocolRegex)

expect(match).not.to.be.null()
expect(match?.groups).to.be.ok()
expect(match?.groups?.protocol).to.equal(protocol)
expect(match?.groups?.cidOrPeerIdOrDnslink).to.equal(cidOrPeerIdOrDnslink)
expect(match?.groups?.path).to.equal(path)
})
})
})

describe('invalid urls', () => {
invalidUrls.forEach(url => {
it(`should return null for "${url}"`, () => {
const match = url.match(pathRegex)

expect(match).to.be.null()
})
})
})
})

0 comments on commit e95e70e

Please sign in to comment.