3
3
const debug = require ( 'debug' )
4
4
const log = debug ( 'ipfs:http-gateway' )
5
5
log . error = debug ( 'ipfs:http-gateway:error' )
6
- const pull = require ( 'pull-stream' )
7
- const pushable = require ( 'pull-pushable' )
8
- const toStream = require ( 'pull-stream-to-stream' )
6
+
9
7
const fileType = require ( 'file-type' )
10
8
const mime = require ( 'mime-types' )
11
9
const { PassThrough } = require ( 'readable-stream' )
12
10
const Boom = require ( 'boom' )
11
+ const Ammo = require ( '@hapi/ammo' ) // HTTP Range processing utilities
12
+ const peek = require ( 'buffer-peek-stream' )
13
13
14
14
const { resolver } = require ( 'ipfs-http-response' )
15
15
const PathUtils = require ( '../utils/path' )
@@ -30,6 +30,20 @@ function detectContentType (ref, chunk) {
30
30
return mime . contentType ( mimeType )
31
31
}
32
32
33
+ // Enable streaming of compressed payload
34
+ // https://github.com/hapijs/hapi/issues/3599
35
+ class ResponseStream extends PassThrough {
36
+ _read ( size ) {
37
+ super . _read ( size )
38
+ if ( this . _compressor ) {
39
+ this . _compressor . flush ( )
40
+ }
41
+ }
42
+ setCompressor ( compressor ) {
43
+ this . _compressor = compressor
44
+ }
45
+ }
46
+
33
47
module . exports = {
34
48
checkCID ( request , h ) {
35
49
if ( ! request . params . cid ) {
@@ -85,66 +99,114 @@ module.exports = {
85
99
return h . redirect ( PathUtils . removeTrailingSlash ( ref ) ) . permanent ( true )
86
100
}
87
101
88
- return new Promise ( ( resolve , reject ) => {
89
- let pusher
90
- let started = false
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
+ }
91
109
92
- pull (
93
- ipfs . catPullStream ( data . cid ) ,
94
- pull . drain (
95
- chunk => {
96
- if ( ! started ) {
97
- started = true
98
- pusher = pushable ( )
99
- const res = h . response ( toStream . source ( pusher ) . pipe ( new PassThrough ( ) ) )
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
+ }
100
114
101
- // Etag maps directly to an identifier for a specific version of a resource
102
- res . header ( 'Etag' , `"${ data . cid } "` )
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
+ }
103
141
104
- // Set headers specific to the immutable namespace
105
- if ( ref . startsWith ( '/ipfs/' ) ) {
106
- res . header ( 'Cache-Control' , 'public, max-age=29030400, immutable' )
107
- }
142
+ const rawStream = ipfs . catReadableStream ( data . cid , catOptions )
143
+ const responseStream = new ResponseStream ( )
108
144
109
- const contentType = detectContentType ( ref , chunk )
145
+ // Pass-through Content-Type sniffing over initial bytes
146
+ const { peekedStream, contentType } = await new Promise ( ( resolve , reject ) => {
147
+ const peekBytes = fileType . minimumBytes
148
+ peek ( rawStream , peekBytes , ( err , streamHead , peekedStream ) => {
149
+ if ( err ) {
150
+ log . error ( err )
151
+ return reject ( err )
152
+ }
153
+ resolve ( { peekedStream, contentType : detectContentType ( ref , streamHead ) } )
154
+ } )
155
+ } )
110
156
111
- log ( 'ref ' , ref )
112
- log ( 'mime-type ' , contentType )
157
+ peekedStream . pipe ( responseStream )
113
158
114
- if ( contentType ) {
115
- log ( 'writing content-type header' )
116
- res . header ( 'Content-Type' , contentType )
117
- }
159
+ const res = h . response ( responseStream ) . code ( rangeResponse ? 206 : 200 )
118
160
119
- resolve ( res )
120
- }
121
- pusher . push ( chunk )
122
- } ,
123
- err => {
124
- if ( err ) {
125
- log . error ( err )
126
-
127
- // We already started flowing, abort the stream
128
- if ( started ) {
129
- return pusher . end ( err )
130
- }
131
-
132
- return reject ( err )
133
- }
161
+ // Etag maps directly to an identifier for a specific version of a resource
162
+ // and enables smart client-side caching thanks to If-None-Match
163
+ res . header ( 'etag' , etag )
134
164
135
- pusher . end ( )
136
- }
137
- )
138
- )
139
- } )
165
+ // Set headers specific to the immutable namespace
166
+ if ( ref . startsWith ( '/ipfs/' ) ) {
167
+ res . header ( 'Cache-Control' , 'public, max-age=29030400, immutable' )
168
+ }
169
+
170
+ log ( 'ref ' , ref )
171
+ log ( 'content-type ' , contentType )
172
+
173
+ if ( contentType ) {
174
+ log ( 'writing content-type header' )
175
+ res . header ( 'Content-Type' , contentType )
176
+ }
177
+
178
+ if ( rangeResponse ) {
179
+ const from = catOptions . offset
180
+ const to = catOptions . offset + catOptions . length - 1
181
+ res . header ( 'Content-Range' , `bytes ${ from } -${ to } /${ size } ` )
182
+ res . header ( 'Content-Length' , catOptions . length )
183
+ } else {
184
+ // Announce support for Range requests
185
+ res . header ( 'Accept-Ranges' , 'bytes' )
186
+ res . header ( 'Content-Length' , size )
187
+ }
188
+
189
+ // Support Content-Disposition via ?filename=foo parameter
190
+ // (useful for browser vendor to download raw CID into custom filename)
191
+ // Source: https://github.com/ipfs/go-ipfs/blob/v0.4.20/core/corehttp/gateway_handler.go#L232-L236
192
+ if ( request . query . filename ) {
193
+ res . header ( 'Content-Disposition' , `inline; filename*=UTF-8''${ encodeURIComponent ( request . query . filename ) } ` )
194
+ }
195
+
196
+ return res
140
197
} ,
141
198
142
199
afterHandler ( request , h ) {
143
200
const { response } = request
144
- if ( response . statusCode === 200 ) {
201
+ // Add headers to successfult responses (regular or range)
202
+ if ( response . statusCode === 200 || response . statusCode === 206 ) {
145
203
const { ref } = request . pre . args
146
204
response . header ( 'X-Ipfs-Path' , ref )
147
205
if ( ref . startsWith ( '/ipfs/' ) ) {
206
+ // "set modtime to a really long time ago, since files are immutable and should stay cached"
207
+ // Source: https://github.com/ipfs/go-ipfs/blob/v0.4.20/core/corehttp/gateway_handler.go#L228-L229
208
+ response . header ( 'Last-Modified' , 'Thu, 01 Jan 1970 00:00:01 GMT' )
209
+ // Suborigins: https://github.com/ipfs/in-web-browsers/issues/66
148
210
const rootCid = ref . split ( '/' ) [ 2 ]
149
211
const ipfsOrigin = cidToString ( rootCid , { base : 'base32' } )
150
212
response . header ( 'Suborigin' , 'ipfs000' + ipfsOrigin )
0 commit comments