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

Commit 17712a4

Browse files
committed
feat(gateway): range and conditional requests
- Switched from deprecated `hapi` and `joi` to `@hapi/hapi` and `@hapi/joi` - Added support for Conditional Requests (RFC7232) - Returning `304 Not Modified` if `If-None-Match` is a CID matching `Etag` - Added `Last-Modified` to `/ipfs/` responses (improves client-side caching) - Always returning `304 Not Modified` when `If-Modified-Since` is present for immutable `/ipfs/` - Added support for Byte Range requests (RFC7233, Section-2.1) - Added support for `?filename=` parameter (improves downloads of raw cids) License: MIT Signed-off-by: Marcin Rataj <[email protected]>
1 parent 373f69e commit 17712a4

File tree

15 files changed

+324
-18
lines changed

15 files changed

+324
-18
lines changed

Diff for: package.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@
8282
"stream-to-promise": "^2.2.0"
8383
},
8484
"dependencies": {
85+
"@hapi/ammo": "^3.1.0",
86+
"@hapi/hapi": "^18.3.1",
87+
"@hapi/joi": "^15.0.0",
8588
"async": "^2.6.1",
8689
"bignumber.js": "^8.0.2",
8790
"binary-querystring": "~0.1.2",
@@ -103,7 +106,6 @@
103106
"fsm-event": "^2.1.0",
104107
"get-folder-size": "^2.0.0",
105108
"glob": "^7.1.3",
106-
"hapi": "^18.0.0",
107109
"hapi-pino": "^5.2.0",
108110
"human-to-milliseconds": "^1.0.0",
109111
"interface-datastore": "~0.6.0",
@@ -131,7 +133,6 @@
131133
"is-pull-stream": "~0.0.0",
132134
"is-stream": "^1.1.0",
133135
"iso-url": "~0.4.6",
134-
"joi": "^14.3.0",
135136
"just-flatten-it": "^2.1.0",
136137
"just-safe-set": "^2.1.0",
137138
"libp2p": "~0.25.0",

Diff for: src/http/api/resources/bitswap.js

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

3-
const Joi = require('joi')
3+
const Joi = require('@hapi/joi')
44
const multibase = require('multibase')
55
const { cidToString } = require('../../../utils/cid')
66
const { parseKey } = require('./block')

Diff for: src/http/api/resources/block.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
const CID = require('cids')
44
const multipart = require('ipfs-multipart')
5-
const Joi = require('joi')
5+
const Joi = require('@hapi/joi')
66
const multibase = require('multibase')
77
const Boom = require('boom')
88
const { cidToString } = require('../../../utils/cid')

Diff for: src/http/api/resources/dag.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const promisify = require('promisify-es6')
44
const CID = require('cids')
55
const multipart = require('ipfs-multipart')
66
const mh = require('multihashes')
7-
const Joi = require('joi')
7+
const Joi = require('@hapi/joi')
88
const multibase = require('multibase')
99
const Boom = require('boom')
1010
const debug = require('debug')

Diff for: src/http/api/resources/dht.js

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

3-
const Joi = require('joi')
3+
const Joi = require('@hapi/joi')
44
const Boom = require('boom')
55

66
const CID = require('cids')

Diff for: src/http/api/resources/files-regular.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const toPull = require('stream-to-pull-stream')
1010
const pushable = require('pull-pushable')
1111
const toStream = require('pull-stream-to-stream')
1212
const abortable = require('pull-abortable')
13-
const Joi = require('joi')
13+
const Joi = require('@hapi/joi')
1414
const Boom = require('boom')
1515
const ndjson = require('pull-ndjson')
1616
const { PassThrough } = require('readable-stream')

Diff for: src/http/api/resources/name.js

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

3-
const Joi = require('joi')
3+
const Joi = require('@hapi/joi')
44

55
exports.resolve = {
66
validate: {

Diff for: src/http/api/resources/object.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const { DAGNode, DAGLink } = dagPB
88
const calculateCid = promisify(dagPB.util.cid)
99
const deserialize = promisify(dagPB.util.deserialize)
1010
const createDagNode = promisify(DAGNode.create)
11-
const Joi = require('joi')
11+
const Joi = require('@hapi/joi')
1212
const multibase = require('multibase')
1313
const Boom = require('boom')
1414
const { cidToString } = require('../../../utils/cid')

Diff for: src/http/api/resources/pin.js

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

33
const multibase = require('multibase')
4-
const Joi = require('joi')
4+
const Joi = require('@hapi/joi')
55
const Boom = require('boom')
66
const isIpfs = require('is-ipfs')
77
const { cidToString } = require('../../../utils/cid')

Diff for: src/http/api/resources/ping.js

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

3-
const Joi = require('joi')
3+
const Joi = require('@hapi/joi')
44
const pull = require('pull-stream')
55
const ndjson = require('pull-ndjson')
66
const { PassThrough } = require('readable-stream')

Diff for: src/http/api/resources/resolve.js

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

3-
const Joi = require('joi')
3+
const Joi = require('@hapi/joi')
44
const debug = require('debug')
55
const multibase = require('multibase')
66

Diff for: src/http/gateway/resources/gateway.js

+69-4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const fileType = require('file-type')
88
const mime = require('mime-types')
99
const { PassThrough } = require('readable-stream')
1010
const Boom = require('boom')
11+
const Ammo = require('@hapi/ammo') // HTTP Range processing utilities
1112
const peek = require('buffer-peek-stream')
1213

1314
const { resolver } = require('ipfs-http-response')
@@ -98,7 +99,47 @@ module.exports = {
9899
return h.redirect(PathUtils.removeTrailingSlash(ref)).permanent(true)
99100
}
100101

101-
const rawStream = ipfs.catReadableStream(data.cid)
102+
// Support If-None-Match & Etag (Conditional Requests from RFC7232)
103+
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
104+
const etag = `"${data.cid}"`
105+
const cachedEtag = request.headers['if-none-match']
106+
if (cachedEtag === etag || cachedEtag === `W/${etag}`) {
107+
return h.response().code(304) // Not Modified
108+
}
109+
110+
// Immutable content produces 304 Not Modified for all values of If-Modified-Since
111+
if (ref.startsWith('/ipfs/') && request.headers['if-modified-since']) {
112+
return h.response().code(304) // Not Modified
113+
}
114+
115+
// This necessary to set correct Content-Length and validate Range requests
116+
// Note: we need `size` (raw data), not `cumulativeSize` (data + DAGNodes)
117+
const { size } = await ipfs.files.stat(`/ipfs/${data.cid}`)
118+
119+
// Handle Byte Range requests (https://tools.ietf.org/html/rfc7233#section-2.1)
120+
const catOptions = {}
121+
let rangeResponse = false
122+
if (request.headers.range) {
123+
// If-Range is respected (when present), but we compare it only against Etag
124+
// (Last-Modified date is too weak for IPFS use cases)
125+
if (!request.headers['if-range'] || request.headers['if-range'] === etag) {
126+
const ranges = Ammo.header(request.headers.range, size)
127+
if (!ranges) {
128+
const error = Boom.rangeNotSatisfiable()
129+
error.output.headers['content-range'] = `bytes */${size}`
130+
throw error
131+
}
132+
133+
if (ranges.length === 1) { // Ignore requests for multiple ranges (hard to map to ipfs.cat and not used in practice)
134+
rangeResponse = true
135+
const range = ranges[0]
136+
catOptions.offset = range.from
137+
catOptions.length = (range.to - range.from + 1)
138+
}
139+
}
140+
}
141+
142+
const rawStream = ipfs.catReadableStream(data.cid, catOptions)
102143
const responseStream = new ResponseStream()
103144

104145
// Pass-through Content-Type sniffing over initial bytes
@@ -119,10 +160,11 @@ module.exports = {
119160
}
120161
})
121162

122-
const res = h.response(responseStream)
163+
const res = h.response(responseStream).code(rangeResponse ? 206 : 200)
123164

124165
// Etag maps directly to an identifier for a specific version of a resource
125-
res.header('Etag', `"${data.cid}"`)
166+
// and enables smart client-side caching thanks to If-None-Match
167+
res.header('etag', etag)
126168

127169
// Set headers specific to the immutable namespace
128170
if (ref.startsWith('/ipfs/')) {
@@ -137,15 +179,38 @@ module.exports = {
137179
res.header('Content-Type', contentType)
138180
}
139181

182+
if (rangeResponse) {
183+
const from = catOptions.offset
184+
const to = catOptions.offset + catOptions.length - 1
185+
res.header('Content-Range', `bytes ${from}-${to}/${size}`)
186+
res.header('Content-Length', catOptions.length)
187+
} else {
188+
// Announce support for Range requests
189+
res.header('Accept-Ranges', 'bytes')
190+
res.header('Content-Length', size)
191+
}
192+
193+
// Support Content-Disposition via ?filename=foo parameter
194+
// (useful for browser vendor to download raw CID into custom filename)
195+
// Source: https://github.com/ipfs/go-ipfs/blob/v0.4.20/core/corehttp/gateway_handler.go#L232-L236
196+
if (request.query.filename) {
197+
res.header('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(request.query.filename)}`)
198+
}
199+
140200
return res
141201
},
142202

143203
afterHandler (request, h) {
144204
const { response } = request
145-
if (response.statusCode === 200) {
205+
// Add headers to successfult responses (regular or range)
206+
if (response.statusCode === 200 || response.statusCode === 206) {
146207
const { ref } = request.pre.args
147208
response.header('X-Ipfs-Path', ref)
148209
if (ref.startsWith('/ipfs/')) {
210+
// "set modtime to a really long time ago, since files are immutable and should stay cached"
211+
// Source: https://github.com/ipfs/go-ipfs/blob/v0.4.20/core/corehttp/gateway_handler.go#L228-L229
212+
response.header('Last-Modified', 'Thu, 01 Jan 1970 00:00:01 GMT')
213+
// Suborigins: https://github.com/ipfs/in-web-browsers/issues/66
149214
const rootCid = ref.split('/')[2]
150215
const ipfsOrigin = cidToString(rootCid, { base: 'base32' })
151216
response.header('Suborigin', 'ipfs000' + ipfsOrigin)

Diff for: src/http/gateway/routes/gateway.js

+3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ module.exports = {
1010
pre: [
1111
{ method: resources.gateway.checkCID, assign: 'args' }
1212
],
13+
response: {
14+
ranges: false // disable built-in support, we do it manually
15+
},
1316
ext: {
1417
onPostHandler: { method: resources.gateway.afterHandler }
1518
}

Diff for: src/http/index.js

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

3-
const Hapi = require('hapi')
3+
const Hapi = require('@hapi/hapi')
44
const Pino = require('hapi-pino')
55
const debug = require('debug')
66
const multiaddr = require('multiaddr')

0 commit comments

Comments
 (0)