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

feat: add HTTP Gateway support for /ipns/ paths #2020

Merged
merged 8 commits into from
Jul 4, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@
"ipfs-block": "~0.8.1",
"ipfs-block-service": "~0.15.1",
"ipfs-http-client": "^32.0.1",
"ipfs-http-response": "~0.3.0",
"ipfs-mfs": "~0.11.4",
"ipfs-http-response": "~0.3.1",
"ipfs-mfs": "~0.11.5",
"ipfs-multipart": "~0.1.0",
"ipfs-repo": "~0.26.6",
"ipfs-unixfs": "~0.1.16",
Expand Down
21 changes: 15 additions & 6 deletions src/http/api/routes/webui.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
'use strict'

const Joi = require('@hapi/joi')
const resources = require('../../gateway/resources')

module.exports = [
{
method: '*',
path: '/ipfs/{cid*}',
path: '/ipfs/{path*}',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need an /ipns handler also?

Copy link
Member Author

@lidel lidel Jul 4, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, this is only for /webui and hardcoded /ipfs/{cid_of_webui} on API port.
(/ipns/ is only available on the Gateway port)

options: {
pre: [
{ method: resources.gateway.checkCID, assign: 'args' }
]
},
handler: resources.gateway.handler
handler: resources.gateway.handler,
validate: {
params: {
path: Joi.string().required()
}
},
response: {
ranges: false // disable built-in support, handler does it manually
},
ext: {
onPostHandler: { method: resources.gateway.afterHandler }
}
}
},
{
method: '*',
Expand Down
70 changes: 40 additions & 30 deletions src/http/gateway/resources/gateway.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,23 @@ const Boom = require('boom')
const Ammo = require('@hapi/ammo') // HTTP Range processing utilities
const peek = require('buffer-peek-stream')

const multibase = require('multibase')
const { resolver } = require('ipfs-http-response')
const PathUtils = require('../utils/path')
const { cidToString } = require('../../../utils/cid')
const isIPFS = require('is-ipfs')

function detectContentType (ref, chunk) {
function detectContentType (path, chunk) {
let fileSignature

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

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

return mime.contentType(mimeType)
}
Expand All @@ -45,44 +47,45 @@ class ResponseStream extends PassThrough {
}

module.exports = {
checkCID (request, h) {
if (!request.params.cid) {
throw Boom.badRequest('Path Resolve error: path must contain at least one component')
}

return { ref: `/ipfs/${request.params.cid}` }
},

async handler (request, h) {
const { ref } = request.pre.args
const { ipfs } = request.server.app
const path = request.path

// The resolver from ipfs-http-response supports only immutable /ipfs/ for now,
// so we convert /ipns/ to /ipfs/ before passing it to the resolver ¯\_(ツ)_/¯
// This could be removed if a solution proposed in
// https://github.com/ipfs/js-ipfs-http-response/issues/22 lands upstream
const ipfsPath = decodeURI(path.startsWith('/ipns/')
? await ipfs.name.resolve(path, { recursive: true })
: path)

let data
try {
data = await resolver.cid(ipfs, ref)
data = await resolver.cid(ipfs, ipfsPath)
} catch (err) {
const errorToString = err.toString()
log.error('err: ', errorToString, ' fileName: ', err.fileName)

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

if (typeof data === 'string') {
// no index file found
if (!ref.endsWith('/')) {
if (!path.endsWith('/')) {
// for a directory, if URL doesn't end with a /
// append / and redirect permanent to that URL
return h.redirect(`${ref}/`).permanent(true)
return h.redirect(`${path}/`).permanent(true)
}
// send directory listing
return h.response(data)
}

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

if (ref.endsWith('/')) {
if (path.endsWith('/')) {
// remove trailing slash for files
return h.redirect(PathUtils.removeTrailingSlash(ref)).permanent(true)
return h.redirect(PathUtils.removeTrailingSlash(path)).permanent(true)
}

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

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

Expand Down Expand Up @@ -150,7 +153,7 @@ module.exports = {
log.error(err)
return reject(err)
}
resolve({ peekedStream, contentType: detectContentType(ref, streamHead) })
resolve({ peekedStream, contentType: detectContentType(path, streamHead) })
})
})

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

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

log('ref ', ref)
log('path ', path)
log('content-type ', contentType)

if (contentType) {
Expand Down Expand Up @@ -200,18 +203,25 @@ module.exports = {
const { response } = request
// Add headers to successfult responses (regular or range)
if (response.statusCode === 200 || response.statusCode === 206) {
const { ref } = request.pre.args
response.header('X-Ipfs-Path', ref)
if (ref.startsWith('/ipfs/')) {
const path = request.path
response.header('X-Ipfs-Path', path)
if (path.startsWith('/ipfs/')) {
// "set modtime to a really long time ago, since files are immutable and should stay cached"
// Source: https://github.com/ipfs/go-ipfs/blob/v0.4.20/core/corehttp/gateway_handler.go#L228-L229
response.header('Last-Modified', 'Thu, 01 Jan 1970 00:00:01 GMT')
// Suborigins: https://github.com/ipfs/in-web-browsers/issues/66
const rootCid = ref.split('/')[2]
// Suborigin for /ipfs/: https://github.com/ipfs/in-web-browsers/issues/66
const rootCid = path.split('/')[2]
const ipfsOrigin = cidToString(rootCid, { base: 'base32' })
response.header('Suborigin', 'ipfs000' + ipfsOrigin)
response.header('Suborigin', `ipfs000${ipfsOrigin}`)
} else if (path.startsWith('/ipns/')) {
// Suborigin for /ipns/: https://github.com/ipfs/in-web-browsers/issues/66
const root = path.split('/')[2]
// encode CID/FQDN in base32 (Suborigin allows only a-z)
const ipnsOrigin = isIPFS.cid(root)
? cidToString(root, { base: 'base32' })
: multibase.encode('base32', Buffer.from(root)).toString()
response.header('Suborigin', `ipns000${ipnsOrigin}`)
}
// TODO: we don't have case-insensitive solution for /ipns/ yet (https://github.com/ipfs/go-ipfs/issues/5287)
}
return h.continue
}
Expand Down
51 changes: 37 additions & 14 deletions src/http/gateway/routes/gateway.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,43 @@
'use strict'

const Joi = require('@hapi/joi')
const resources = require('../resources')

module.exports = {
method: '*',
path: '/ipfs/{cid*}',
options: {
handler: resources.gateway.handler,
pre: [
{ method: resources.gateway.checkCID, assign: 'args' }
],
response: {
ranges: false // disable built-in support, we do it manually
},
ext: {
onPostHandler: { method: resources.gateway.afterHandler }
module.exports = [
{
method: '*',
path: '/ipfs/{path*}',
options: {
handler: resources.gateway.handler,
validate: {
params: {
path: Joi.string().required()
}
},
response: {
ranges: false // disable built-in support, handler does it manually
},
ext: {
onPostHandler: { method: resources.gateway.afterHandler }
}
}
},
{
method: '*',
path: '/ipns/{path*}',
options: {
handler: resources.gateway.handler,
validate: {
params: {
path: Joi.string().required()
}
},
response: {
ranges: false // disable built-in support, handler does it manually
},
ext: {
onPostHandler: { method: resources.gateway.afterHandler }
}
}
}
}
]
2 changes: 1 addition & 1 deletion src/http/gateway/routes/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
'use strict'

module.exports = [require('./gateway')]
module.exports = require('./gateway')
75 changes: 75 additions & 0 deletions test/gateway/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const os = require('os')
const path = require('path')
const hat = require('hat')
const fileType = require('file-type')
const CID = require('cids')

const bigFile = loadFixture('test/fixtures/15mb.random', 'interface-ipfs-core')
const directoryContent = {
Expand All @@ -20,6 +21,7 @@ const directoryContent = {
'nested-folder/ipfs.txt': loadFixture('test/gateway/test-folder/nested-folder/ipfs.txt'),
'nested-folder/nested.html': loadFixture('test/gateway/test-folder/nested-folder/nested.html'),
'cat-folder/cat.jpg': loadFixture('test/gateway/test-folder/cat-folder/cat.jpg'),
'utf8/cat-with-óąśśł-and-أعظم._.jpg': loadFixture('test/gateway/test-folder/cat-folder/cat.jpg'),
'unsniffable-folder/hexagons-xml.svg': loadFixture('test/gateway/test-folder/unsniffable-folder/hexagons-xml.svg'),
'unsniffable-folder/hexagons.svg': loadFixture('test/gateway/test-folder/unsniffable-folder/hexagons.svg')
}
Expand Down Expand Up @@ -84,6 +86,10 @@ describe('HTTP Gateway', function () {
content('unsniffable-folder/hexagons-xml.svg'),
content('unsniffable-folder/hexagons.svg')
])
// QmaRdtkDark8TgXPdDczwBneadyF44JvFGbrKLTkmTUhHk
await http.api._ipfs.add([content('utf8/cat-with-óąśśł-and-أعظم._.jpg')])
// Publish QmW2WQi7j6c7UgJTarActp7tDNikE4B2qXtFCfLPdsgaTQ to IPNS using self key
await http.api._ipfs.name.publish('QmW2WQi7j6c7UgJTarActp7tDNikE4B2qXtFCfLPdsgaTQ', { resolve: false })
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note for the future: unless we're trying to validate particular content creates a particular CID we should add the content and store the returned CID for use in future tests. Hard coding CIDs like this is a maintenance nightmare when we want to change defaults.

Copy link
Member Author

@lidel lidel Jul 4, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am aware, but did not want to rewrite existing tests, so as a compromise I reused existing CID of cat-folder/cat.jpg (was already present a few lines above)

})

after(() => http.api.stop())
Expand Down Expand Up @@ -526,4 +532,73 @@ describe('HTTP Gateway', function () {
expect(res.headers.location).to.equal('/ipfs/QmbQD7EMEL1zeebwBsWEfA3ndgSS6F7S6iTuwuqasPgVRi/index.html')
expect(res.headers['x-ipfs-path']).to.equal(undefined)
})

it('test(gateway): load from URI-encoded path', async () => {
// non-ascii characters will be URI-encoded by the browser
const utf8path = '/ipfs/QmaRdtkDark8TgXPdDczwBneadyF44JvFGbrKLTkmTUhHk/cat-with-óąśśł-and-أعظم._.jpg'
const escapedPath = encodeURI(utf8path) // this is what will be actually requested
const res = await gateway.inject({
method: 'GET',
url: escapedPath
})

expect(res.statusCode).to.equal(200)
expect(res.headers['content-type']).to.equal('image/jpeg')
expect(res.headers['x-ipfs-path']).to.equal(escapedPath)
expect(res.headers['cache-control']).to.equal('public, max-age=29030400, immutable')
expect(res.headers['last-modified']).to.equal('Thu, 01 Jan 1970 00:00:01 GMT')
expect(res.headers['content-length']).to.equal(res.rawPayload.length)
expect(res.headers.etag).to.equal('"Qmd286K6pohQcTKYqnS1YhWrCiS4gz7Xi34sdwMe9USZ7u"')
expect(res.headers.suborigin).to.equal('ipfs000bafybeiftsm4u7cn24bn2suwg3x7sldx2uplvfylsk3e4bgylyxwjdevhqm')
})

it('load a file from IPNS', async () => {
const { id } = await http.api._ipfs.id()
const ipnsPath = `/ipns/${id}/cat.jpg`

const res = await gateway.inject({
method: 'GET',
url: ipnsPath
})

const kittyDirectCid = 'Qmd286K6pohQcTKYqnS1YhWrCiS4gz7Xi34sdwMe9USZ7u'

expect(res.statusCode).to.equal(200)
expect(res.headers['content-type']).to.equal('image/jpeg')
expect(res.headers['content-length']).to.equal(res.rawPayload.length).to.equal(443230)
expect(res.headers['x-ipfs-path']).to.equal(ipnsPath)
expect(res.headers['etag']).to.equal(`"${kittyDirectCid}"`)
expect(res.headers['cache-control']).to.equal('no-cache') // TODO: should be record TTL
expect(res.headers['last-modified']).to.equal(undefined)
expect(res.headers.etag).to.equal('"Qmd286K6pohQcTKYqnS1YhWrCiS4gz7Xi34sdwMe9USZ7u"')
expect(res.headers.suborigin).to.equal(`ipns000${new CID(id).toV1().toBaseEncodedString('base32')}`)

let fileSignature = fileType(res.rawPayload)
expect(fileSignature.mime).to.equal('image/jpeg')
expect(fileSignature.ext).to.equal('jpg')
})

it('load a directory from IPNS', async () => {
const { id } = await http.api._ipfs.id()
const ipnsPath = `/ipns/${id}/`

const res = await gateway.inject({
method: 'GET',
url: ipnsPath
})

expect(res.statusCode).to.equal(200)
expect(res.headers['content-type']).to.equal('text/html; charset=utf-8')
expect(res.headers['x-ipfs-path']).to.equal(ipnsPath)
expect(res.headers['cache-control']).to.equal('no-cache')
expect(res.headers['last-modified']).to.equal(undefined)
expect(res.headers['content-length']).to.equal(res.rawPayload.length)
expect(res.headers.etag).to.equal(undefined)
expect(res.headers.suborigin).to.equal(`ipns000${new CID(id).toV1().toBaseEncodedString('base32')}`)

// check if the cat picture is in the payload as a way to check
// if this is an index of this directory
let listedFile = res.payload.match(/\/cat\.jpg/g)
expect(listedFile).to.have.lengthOf(1)
})
})