Skip to content
This repository was archived by the owner on Feb 12, 2024. It is now read-only.

Commit 43ac305

Browse files
lidelAlan Shaw
authored and
Alan Shaw
committed
feat: add HTTP Gateway support for /ipns/ paths (#2020)
> Part of an effort to run embedded js-ipfs in Brave 🦁 ipfs/ipfs-companion#716 > Fixes #1918 This PR will add support for `/ipns/` paths at HTTP Gateway. Smoke test: [/ipns/tr.wikipedia-on-ipfs.org](http://127.0.0.1:9090/ipns/tr.wikipedia-on-ipfs.org/wiki/Anasayfa.html) (IPNS+DNSLink+HAMT-sharded website) This PR depends on the following merged PRs: - Gateway Improvements from #1989 (after merging #1989 I will rebase this PR, which will remove first two commits) - PeerID eg. `/ipns/<PeerId-as-multihash-b58>` - requires #2002 to land first - `/ipns/<libp2p-key-in-cidv1>` - requires multiformats/js-multicodec#45 - DNSLink eg. `/ipns/<fqdn>/path/file` like `/ipns/docs.ipfs.io/assets/logo.svg` - requires #2002 to land first - HAMT shard support eg. `/ipns/tr.wikipedia-on-ipfs.org/wiki/Anasayfa.html` (`wiki` is a sharded directory) - requires ipfs/js-ipfs-http-response#22 and ipfs-inactive/js-ipfs-mfs#48 to land first - Tests for `/ipns/` License: MIT Signed-off-by: Marcin Rataj <[email protected]>
1 parent 438f70a commit 43ac305

File tree

6 files changed

+170
-53
lines changed

6 files changed

+170
-53
lines changed

package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@
9595
"ipfs-block": "~0.8.1",
9696
"ipfs-block-service": "~0.15.1",
9797
"ipfs-http-client": "^32.0.1",
98-
"ipfs-http-response": "~0.3.0",
99-
"ipfs-mfs": "~0.11.4",
98+
"ipfs-http-response": "~0.3.1",
99+
"ipfs-mfs": "~0.11.5",
100100
"ipfs-multipart": "~0.1.0",
101101
"ipfs-repo": "~0.26.6",
102102
"ipfs-unixfs": "~0.1.16",

src/http/api/routes/webui.js

+15-6
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
11
'use strict'
22

3+
const Joi = require('@hapi/joi')
34
const resources = require('../../gateway/resources')
45

56
module.exports = [
67
{
78
method: '*',
8-
path: '/ipfs/{cid*}',
9+
path: '/ipfs/{path*}',
910
options: {
10-
pre: [
11-
{ method: resources.gateway.checkCID, assign: 'args' }
12-
]
13-
},
14-
handler: resources.gateway.handler
11+
handler: resources.gateway.handler,
12+
validate: {
13+
params: {
14+
path: Joi.string().required()
15+
}
16+
},
17+
response: {
18+
ranges: false // disable built-in support, handler does it manually
19+
},
20+
ext: {
21+
onPostHandler: { method: resources.gateway.afterHandler }
22+
}
23+
}
1524
},
1625
{
1726
method: '*',

src/http/gateway/resources/gateway.js

+40-30
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,23 @@ const Boom = require('boom')
1111
const Ammo = require('@hapi/ammo') // HTTP Range processing utilities
1212
const peek = require('buffer-peek-stream')
1313

14+
const multibase = require('multibase')
1415
const { resolver } = require('ipfs-http-response')
1516
const PathUtils = require('../utils/path')
1617
const { cidToString } = require('../../../utils/cid')
18+
const isIPFS = require('is-ipfs')
1719

18-
function detectContentType (ref, chunk) {
20+
function detectContentType (path, chunk) {
1921
let fileSignature
2022

2123
// try to guess the filetype based on the first bytes
2224
// note that `file-type` doesn't support svgs, therefore we assume it's a svg if ref looks like it
23-
if (!ref.endsWith('.svg')) {
25+
if (!path.endsWith('.svg')) {
2426
fileSignature = fileType(chunk)
2527
}
2628

27-
// if we were unable to, fallback to the `ref` which might contain the extension
28-
const mimeType = mime.lookup(fileSignature ? fileSignature.ext : ref)
29+
// if we were unable to, fallback to the path which might contain the extension
30+
const mimeType = mime.lookup(fileSignature ? fileSignature.ext : path)
2931

3032
return mime.contentType(mimeType)
3133
}
@@ -45,44 +47,45 @@ class ResponseStream extends PassThrough {
4547
}
4648

4749
module.exports = {
48-
checkCID (request, h) {
49-
if (!request.params.cid) {
50-
throw Boom.badRequest('Path Resolve error: path must contain at least one component')
51-
}
52-
53-
return { ref: `/ipfs/${request.params.cid}` }
54-
},
5550

5651
async handler (request, h) {
57-
const { ref } = request.pre.args
5852
const { ipfs } = request.server.app
53+
const path = request.path
54+
55+
// The resolver from ipfs-http-response supports only immutable /ipfs/ for now,
56+
// so we convert /ipns/ to /ipfs/ before passing it to the resolver ¯\_(ツ)_/¯
57+
// This could be removed if a solution proposed in
58+
// https://github.com/ipfs/js-ipfs-http-response/issues/22 lands upstream
59+
const ipfsPath = decodeURI(path.startsWith('/ipns/')
60+
? await ipfs.name.resolve(path, { recursive: true })
61+
: path)
5962

6063
let data
6164
try {
62-
data = await resolver.cid(ipfs, ref)
65+
data = await resolver.cid(ipfs, ipfsPath)
6366
} catch (err) {
6467
const errorToString = err.toString()
6568
log.error('err: ', errorToString, ' fileName: ', err.fileName)
6669

6770
// switch case with true feels so wrong.
6871
switch (true) {
6972
case (errorToString === 'Error: This dag node is a directory'):
70-
data = await resolver.directory(ipfs, ref, err.cid)
73+
data = await resolver.directory(ipfs, ipfsPath, err.cid)
7174

7275
if (typeof data === 'string') {
7376
// no index file found
74-
if (!ref.endsWith('/')) {
77+
if (!path.endsWith('/')) {
7578
// for a directory, if URL doesn't end with a /
7679
// append / and redirect permanent to that URL
77-
return h.redirect(`${ref}/`).permanent(true)
80+
return h.redirect(`${path}/`).permanent(true)
7881
}
7982
// send directory listing
8083
return h.response(data)
8184
}
8285

8386
// found index file
8487
// redirect to URL/<found-index-file>
85-
return h.redirect(PathUtils.joinURLParts(ref, data[0].Name))
88+
return h.redirect(PathUtils.joinURLParts(path, data[0].Name))
8689
case (errorToString.startsWith('Error: no link named')):
8790
throw Boom.boomify(err, { statusCode: 404 })
8891
case (errorToString.startsWith('Error: multihash length inconsistent')):
@@ -94,9 +97,9 @@ module.exports = {
9497
}
9598
}
9699

97-
if (ref.endsWith('/')) {
100+
if (path.endsWith('/')) {
98101
// remove trailing slash for files
99-
return h.redirect(PathUtils.removeTrailingSlash(ref)).permanent(true)
102+
return h.redirect(PathUtils.removeTrailingSlash(path)).permanent(true)
100103
}
101104

102105
// Support If-None-Match & Etag (Conditional Requests from RFC7232)
@@ -108,7 +111,7 @@ module.exports = {
108111
}
109112

110113
// Immutable content produces 304 Not Modified for all values of If-Modified-Since
111-
if (ref.startsWith('/ipfs/') && request.headers['if-modified-since']) {
114+
if (path.startsWith('/ipfs/') && request.headers['if-modified-since']) {
112115
return h.response().code(304) // Not Modified
113116
}
114117

@@ -150,7 +153,7 @@ module.exports = {
150153
log.error(err)
151154
return reject(err)
152155
}
153-
resolve({ peekedStream, contentType: detectContentType(ref, streamHead) })
156+
resolve({ peekedStream, contentType: detectContentType(path, streamHead) })
154157
})
155158
})
156159

@@ -163,11 +166,11 @@ module.exports = {
163166
res.header('etag', etag)
164167

165168
// Set headers specific to the immutable namespace
166-
if (ref.startsWith('/ipfs/')) {
169+
if (path.startsWith('/ipfs/')) {
167170
res.header('Cache-Control', 'public, max-age=29030400, immutable')
168171
}
169172

170-
log('ref ', ref)
173+
log('path ', path)
171174
log('content-type ', contentType)
172175

173176
if (contentType) {
@@ -200,18 +203,25 @@ module.exports = {
200203
const { response } = request
201204
// Add headers to successfult responses (regular or range)
202205
if (response.statusCode === 200 || response.statusCode === 206) {
203-
const { ref } = request.pre.args
204-
response.header('X-Ipfs-Path', ref)
205-
if (ref.startsWith('/ipfs/')) {
206+
const path = request.path
207+
response.header('X-Ipfs-Path', path)
208+
if (path.startsWith('/ipfs/')) {
206209
// "set modtime to a really long time ago, since files are immutable and should stay cached"
207210
// Source: https://github.com/ipfs/go-ipfs/blob/v0.4.20/core/corehttp/gateway_handler.go#L228-L229
208211
response.header('Last-Modified', 'Thu, 01 Jan 1970 00:00:01 GMT')
209-
// Suborigins: https://github.com/ipfs/in-web-browsers/issues/66
210-
const rootCid = ref.split('/')[2]
212+
// Suborigin for /ipfs/: https://github.com/ipfs/in-web-browsers/issues/66
213+
const rootCid = path.split('/')[2]
211214
const ipfsOrigin = cidToString(rootCid, { base: 'base32' })
212-
response.header('Suborigin', 'ipfs000' + ipfsOrigin)
215+
response.header('Suborigin', `ipfs000${ipfsOrigin}`)
216+
} else if (path.startsWith('/ipns/')) {
217+
// Suborigin for /ipns/: https://github.com/ipfs/in-web-browsers/issues/66
218+
const root = path.split('/')[2]
219+
// encode CID/FQDN in base32 (Suborigin allows only a-z)
220+
const ipnsOrigin = isIPFS.cid(root)
221+
? cidToString(root, { base: 'base32' })
222+
: multibase.encode('base32', Buffer.from(root)).toString()
223+
response.header('Suborigin', `ipns000${ipnsOrigin}`)
213224
}
214-
// TODO: we don't have case-insensitive solution for /ipns/ yet (https://github.com/ipfs/go-ipfs/issues/5287)
215225
}
216226
return h.continue
217227
}

src/http/gateway/routes/gateway.js

+37-14
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,43 @@
11
'use strict'
22

3+
const Joi = require('@hapi/joi')
34
const resources = require('../resources')
45

5-
module.exports = {
6-
method: '*',
7-
path: '/ipfs/{cid*}',
8-
options: {
9-
handler: resources.gateway.handler,
10-
pre: [
11-
{ method: resources.gateway.checkCID, assign: 'args' }
12-
],
13-
response: {
14-
ranges: false // disable built-in support, we do it manually
15-
},
16-
ext: {
17-
onPostHandler: { method: resources.gateway.afterHandler }
6+
module.exports = [
7+
{
8+
method: '*',
9+
path: '/ipfs/{path*}',
10+
options: {
11+
handler: resources.gateway.handler,
12+
validate: {
13+
params: {
14+
path: Joi.string().required()
15+
}
16+
},
17+
response: {
18+
ranges: false // disable built-in support, handler does it manually
19+
},
20+
ext: {
21+
onPostHandler: { method: resources.gateway.afterHandler }
22+
}
23+
}
24+
},
25+
{
26+
method: '*',
27+
path: '/ipns/{path*}',
28+
options: {
29+
handler: resources.gateway.handler,
30+
validate: {
31+
params: {
32+
path: Joi.string().required()
33+
}
34+
},
35+
response: {
36+
ranges: false // disable built-in support, handler does it manually
37+
},
38+
ext: {
39+
onPostHandler: { method: resources.gateway.afterHandler }
40+
}
1841
}
1942
}
20-
}
43+
]

src/http/gateway/routes/index.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
'use strict'
22

3-
module.exports = [require('./gateway')]
3+
module.exports = require('./gateway')

test/gateway/index.js

+75
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const os = require('os')
1212
const path = require('path')
1313
const hat = require('hat')
1414
const fileType = require('file-type')
15+
const CID = require('cids')
1516

1617
const bigFile = loadFixture('test/fixtures/15mb.random', 'interface-ipfs-core')
1718
const directoryContent = {
@@ -20,6 +21,7 @@ const directoryContent = {
2021
'nested-folder/ipfs.txt': loadFixture('test/gateway/test-folder/nested-folder/ipfs.txt'),
2122
'nested-folder/nested.html': loadFixture('test/gateway/test-folder/nested-folder/nested.html'),
2223
'cat-folder/cat.jpg': loadFixture('test/gateway/test-folder/cat-folder/cat.jpg'),
24+
'utf8/cat-with-óąśśł-and-أعظم._.jpg': loadFixture('test/gateway/test-folder/cat-folder/cat.jpg'),
2325
'unsniffable-folder/hexagons-xml.svg': loadFixture('test/gateway/test-folder/unsniffable-folder/hexagons-xml.svg'),
2426
'unsniffable-folder/hexagons.svg': loadFixture('test/gateway/test-folder/unsniffable-folder/hexagons.svg')
2527
}
@@ -84,6 +86,10 @@ describe('HTTP Gateway', function () {
8486
content('unsniffable-folder/hexagons-xml.svg'),
8587
content('unsniffable-folder/hexagons.svg')
8688
])
89+
// QmaRdtkDark8TgXPdDczwBneadyF44JvFGbrKLTkmTUhHk
90+
await http.api._ipfs.add([content('utf8/cat-with-óąśśł-and-أعظم._.jpg')])
91+
// Publish QmW2WQi7j6c7UgJTarActp7tDNikE4B2qXtFCfLPdsgaTQ to IPNS using self key
92+
await http.api._ipfs.name.publish('QmW2WQi7j6c7UgJTarActp7tDNikE4B2qXtFCfLPdsgaTQ', { resolve: false })
8793
})
8894

8995
after(() => http.api.stop())
@@ -526,4 +532,73 @@ describe('HTTP Gateway', function () {
526532
expect(res.headers.location).to.equal('/ipfs/QmbQD7EMEL1zeebwBsWEfA3ndgSS6F7S6iTuwuqasPgVRi/index.html')
527533
expect(res.headers['x-ipfs-path']).to.equal(undefined)
528534
})
535+
536+
it('test(gateway): load from URI-encoded path', async () => {
537+
// non-ascii characters will be URI-encoded by the browser
538+
const utf8path = '/ipfs/QmaRdtkDark8TgXPdDczwBneadyF44JvFGbrKLTkmTUhHk/cat-with-óąśśł-and-أعظم._.jpg'
539+
const escapedPath = encodeURI(utf8path) // this is what will be actually requested
540+
const res = await gateway.inject({
541+
method: 'GET',
542+
url: escapedPath
543+
})
544+
545+
expect(res.statusCode).to.equal(200)
546+
expect(res.headers['content-type']).to.equal('image/jpeg')
547+
expect(res.headers['x-ipfs-path']).to.equal(escapedPath)
548+
expect(res.headers['cache-control']).to.equal('public, max-age=29030400, immutable')
549+
expect(res.headers['last-modified']).to.equal('Thu, 01 Jan 1970 00:00:01 GMT')
550+
expect(res.headers['content-length']).to.equal(res.rawPayload.length)
551+
expect(res.headers.etag).to.equal('"Qmd286K6pohQcTKYqnS1YhWrCiS4gz7Xi34sdwMe9USZ7u"')
552+
expect(res.headers.suborigin).to.equal('ipfs000bafybeiftsm4u7cn24bn2suwg3x7sldx2uplvfylsk3e4bgylyxwjdevhqm')
553+
})
554+
555+
it('load a file from IPNS', async () => {
556+
const { id } = await http.api._ipfs.id()
557+
const ipnsPath = `/ipns/${id}/cat.jpg`
558+
559+
const res = await gateway.inject({
560+
method: 'GET',
561+
url: ipnsPath
562+
})
563+
564+
const kittyDirectCid = 'Qmd286K6pohQcTKYqnS1YhWrCiS4gz7Xi34sdwMe9USZ7u'
565+
566+
expect(res.statusCode).to.equal(200)
567+
expect(res.headers['content-type']).to.equal('image/jpeg')
568+
expect(res.headers['content-length']).to.equal(res.rawPayload.length).to.equal(443230)
569+
expect(res.headers['x-ipfs-path']).to.equal(ipnsPath)
570+
expect(res.headers['etag']).to.equal(`"${kittyDirectCid}"`)
571+
expect(res.headers['cache-control']).to.equal('no-cache') // TODO: should be record TTL
572+
expect(res.headers['last-modified']).to.equal(undefined)
573+
expect(res.headers.etag).to.equal('"Qmd286K6pohQcTKYqnS1YhWrCiS4gz7Xi34sdwMe9USZ7u"')
574+
expect(res.headers.suborigin).to.equal(`ipns000${new CID(id).toV1().toBaseEncodedString('base32')}`)
575+
576+
let fileSignature = fileType(res.rawPayload)
577+
expect(fileSignature.mime).to.equal('image/jpeg')
578+
expect(fileSignature.ext).to.equal('jpg')
579+
})
580+
581+
it('load a directory from IPNS', async () => {
582+
const { id } = await http.api._ipfs.id()
583+
const ipnsPath = `/ipns/${id}/`
584+
585+
const res = await gateway.inject({
586+
method: 'GET',
587+
url: ipnsPath
588+
})
589+
590+
expect(res.statusCode).to.equal(200)
591+
expect(res.headers['content-type']).to.equal('text/html; charset=utf-8')
592+
expect(res.headers['x-ipfs-path']).to.equal(ipnsPath)
593+
expect(res.headers['cache-control']).to.equal('no-cache')
594+
expect(res.headers['last-modified']).to.equal(undefined)
595+
expect(res.headers['content-length']).to.equal(res.rawPayload.length)
596+
expect(res.headers.etag).to.equal(undefined)
597+
expect(res.headers.suborigin).to.equal(`ipns000${new CID(id).toV1().toBaseEncodedString('base32')}`)
598+
599+
// check if the cat picture is in the payload as a way to check
600+
// if this is an index of this directory
601+
let listedFile = res.payload.match(/\/cat\.jpg/g)
602+
expect(listedFile).to.have.lengthOf(1)
603+
})
529604
})

0 commit comments

Comments
 (0)