|
| 1 | +import parseRange from 'range-parser' |
| 2 | + |
| 3 | +/** |
| 4 | + * Attempt to locate the requested resource from a Fastly Object-Store, |
| 5 | + * If the request is a GET or HEAD request and a resource was found in the Object-Store, this will return a `Response`. |
| 6 | + * If request is not GET or HEAD, or no resource was found in the Object-Store, this will return `null` |
| 7 | + * @param {string} store_name The name of the Fastly Object-Store to search within. |
| 8 | + * @param {Request} request The request to attempt to match against a resource within the Object-Store. |
| 9 | + * @returns {Promise<Response | null>} Returns a `Response` if a resource was found, else returns `null`. |
| 10 | + */ |
| 11 | +export async function get(store_name, request) { |
| 12 | + const isHeadRequest = request.method === 'HEAD' |
| 13 | + // static files should only respond on HEAD and GET requests |
| 14 | + if (!isHeadRequest && request.method !== 'GET') { |
| 15 | + return null |
| 16 | + } |
| 17 | + |
| 18 | + // if path ends in / or does not have an extension |
| 19 | + // then append /index.html to the end so we can serve a page |
| 20 | + let path = new URL(request.url).pathname |
| 21 | + if (path.endsWith('/')) { |
| 22 | + path += 'index.html' |
| 23 | + } else if (!path.includes('.')) { |
| 24 | + path += '/index.html' |
| 25 | + } |
| 26 | + |
| 27 | + const metadataPath = path + '__metadata__' |
| 28 | + |
| 29 | + let metadata = await (new KVStore(store_name)).get(metadataPath) |
| 30 | + if (metadata == null) { |
| 31 | + return null |
| 32 | + } |
| 33 | + metadata = await metadata.json(); |
| 34 | + const responseHeaders = metadata; |
| 35 | + responseHeaders['accept-ranges'] = 'bytes' |
| 36 | + |
| 37 | + const response = checkPreconditions(request, responseHeaders); |
| 38 | + if (response) { |
| 39 | + return response; |
| 40 | + } |
| 41 | + |
| 42 | + const item = await (new KVStore(store_name)).get(path) |
| 43 | + |
| 44 | + if (item == null) { |
| 45 | + return null |
| 46 | + } |
| 47 | + |
| 48 | + let range = request.headers.get("range"); |
| 49 | + if (range == null) { |
| 50 | + return new Response(isHeadRequest ? null : item.body, { status: 200, headers: responseHeaders }) |
| 51 | + } else { |
| 52 | + return handleRangeRequest(item, range, responseHeaders, isHeadRequest) |
| 53 | + } |
| 54 | +} |
| 55 | + |
| 56 | +async function handleRangeRequest(item, range, headers, isHeadRequest) { |
| 57 | + /** |
| 58 | + * @type {Uint8Array} |
| 59 | + */ |
| 60 | + const itemBuffer = new Uint8Array(await item.arrayBuffer()) |
| 61 | + const total = itemBuffer.byteLength |
| 62 | + const subranges = parseRange(total, range) |
| 63 | + |
| 64 | + // -1 signals an unsatisfiable range |
| 65 | + if (subranges == -1) { |
| 66 | + headers['content-range'] = `bytes */${total}` |
| 67 | + return new Response(null, { status: 416, headers }) |
| 68 | + } |
| 69 | + // -2 signals a malformed header string |
| 70 | + if (subranges == -2) { |
| 71 | + headers['content-length'] = String(total) |
| 72 | + return new Response(isHeadRequest ? null : itemBuffer, { status: 200, headers }) |
| 73 | + } |
| 74 | + |
| 75 | + if (subranges.length == 1) { |
| 76 | + const { start, end } = subranges[0] |
| 77 | + headers['content-range'] = `bytes ${start}-${end}/${total}` |
| 78 | + headers['content-length'] = String(end - start + 1) |
| 79 | + |
| 80 | + return new Response(isHeadRequest ? null : itemBuffer.slice(start, end), { status: 206, headers }) |
| 81 | + } else { |
| 82 | + const mime = headers['Content-Type'] |
| 83 | + headers['Content-Type'] = 'multipart/byteranges; boundary=3d6b6a416f9b5' |
| 84 | + const enc = new TextEncoder(); |
| 85 | + const boundaryString = '--3d6b6a416f9b5'; |
| 86 | + const type = mime ? enc.encode(`Content-Type: ${mime}\n`) : null |
| 87 | + const results = [] |
| 88 | + let bufferLength = 0 |
| 89 | + let boundary = enc.encode(`\n${boundaryString}\n`) |
| 90 | + subranges.forEach(function ({ start, end }) { |
| 91 | + { |
| 92 | + bufferLength += boundary.byteLength |
| 93 | + results.push(boundary) |
| 94 | + } |
| 95 | + if (type) { |
| 96 | + results.push(type) |
| 97 | + bufferLength += type.byteLength |
| 98 | + } |
| 99 | + { |
| 100 | + let content_range = enc.encode(`Content-Range: bytes ${start}-${end}/${total}\n\n`) |
| 101 | + bufferLength += content_range.byteLength |
| 102 | + results.push(content_range) |
| 103 | + } |
| 104 | + { |
| 105 | + let content = itemBuffer.slice(start, end) |
| 106 | + bufferLength += content.byteLength |
| 107 | + results.push(content) |
| 108 | + } |
| 109 | + }) |
| 110 | + { |
| 111 | + results.push(boundary) |
| 112 | + bufferLength += boundary.byteLength |
| 113 | + } |
| 114 | + const body = concat(results, bufferLength) |
| 115 | + const length = body.byteLength |
| 116 | + headers['content-length'] = String(length) |
| 117 | + return new Response(isHeadRequest ? null : body, { status: 206, headers }) |
| 118 | + } |
| 119 | +} |
| 120 | + |
| 121 | +function concat(views, length) { |
| 122 | + console.log({length}) |
| 123 | + const buf = new Uint8Array(length) |
| 124 | + let offset = 0 |
| 125 | + for (const v of views) { |
| 126 | + const uint8view = new Uint8Array(v.buffer, v.byteOffset, v.byteLength) |
| 127 | + buf.set(uint8view, offset) |
| 128 | + offset += uint8view.byteLength |
| 129 | + } |
| 130 | + |
| 131 | + return buf |
| 132 | +} |
| 133 | + |
| 134 | +function checkPreconditions(request, responseHeaders) { |
| 135 | + // https://httpwg.org/specs/rfc9110.html#rfc.section.13.2.2 |
| 136 | + // A recipient cache or origin server MUST evaluate the request preconditions defined by this specification in the following order: |
| 137 | + // 1. When recipient is the origin server and If-Match is present, evaluate the If-Match precondition: |
| 138 | + // - if true, continue to step 3 |
| 139 | + // - if false, respond 412 (Precondition Failed) unless it can be determined that the state-changing request has already succeeded (see Section 13.1.1) |
| 140 | + let header = request.headers.get("if-match"); |
| 141 | + if (typeof header === 'string') { |
| 142 | + console.log("!ifMatch(responseHeaders, header)", !ifMatch(responseHeaders, header)); |
| 143 | + if (!ifMatch(responseHeaders, header)) { |
| 144 | + return new Response(null, { status: 412 }); |
| 145 | + } |
| 146 | + // } else { |
| 147 | + // // 2. When recipient is the origin server, If-Match is not present, and If-Unmodified-Since is present, evaluate the If-Unmodified-Since precondition: |
| 148 | + // // - if true, continue to step 3 |
| 149 | + // // - if false, respond 412 (Precondition Failed) unless it can be determined that the state-changing request has already succeeded (see Section 13.1.4) |
| 150 | + // header = request.headers.get("if-unmodified-since"); |
| 151 | + // if (typeof header === 'string') { |
| 152 | + // // console.log("!ifUnmodifiedSince(responseHeaders, header)", !ifUnmodifiedSince(responseHeaders, header)); |
| 153 | + // if (!ifUnmodifiedSince(responseHeaders, header)) { |
| 154 | + // return new Response(null, { status: 412 }); |
| 155 | + // } |
| 156 | + // } |
| 157 | + } |
| 158 | + |
| 159 | + // 3. When If-None-Match is present, evaluate the If-None-Match precondition: |
| 160 | + // - if true, continue to step 5 |
| 161 | + // - if false for GET/HEAD, respond 304 (Not Modified) |
| 162 | + // - if false for other methods, respond 412 (Precondition Failed) |
| 163 | + header = request.headers.get("if-none-match"); |
| 164 | + const method = request.method; |
| 165 | + const get = "GET"; |
| 166 | + const head = "HEAD"; |
| 167 | + if (typeof header === 'string') { |
| 168 | + // console.log("!ifNoneMatch(responseHeaders, header)", !ifNoneMatch(responseHeaders, header)); |
| 169 | + if (!ifNoneMatch(responseHeaders, header)) { |
| 170 | + if (method === get || method === head) { |
| 171 | + return new Response(null, { status: 304, headers: responseHeaders }) |
| 172 | + } |
| 173 | + return new Response(null, { status: 412 }); |
| 174 | + } |
| 175 | + } else { |
| 176 | + // 4. When the method is GET or HEAD, If-None-Match is not present, and If-Modified-Since is present, evaluate the If-Modified-Since precondition: |
| 177 | + // - if true, continue to step 5 |
| 178 | + // - if false, respond 304 (Not Modified) |
| 179 | + if (method === get || method === head) { |
| 180 | + header = request.headers.get("if-modified-since"); |
| 181 | + if (typeof header === 'string') { |
| 182 | + // console.log("!ifModifiedSince(responseHeaders, header)", !ifModifiedSince(responseHeaders, header)); |
| 183 | + if (!ifModifiedSince(responseHeaders, header)) { |
| 184 | + return new Response(null, { status: 304, headers: responseHeaders }) |
| 185 | + } |
| 186 | + } |
| 187 | + } |
| 188 | + } |
| 189 | + |
| 190 | + // 5. When the method is GET and both Range and If-Range are present, evaluate the If-Range precondition: |
| 191 | + // - if true and the Range is applicable to the selected representation, respond 206 (Partial Content) |
| 192 | + // - otherwise, ignore the Range header field and respond 200 (OK) |
| 193 | + if (method === get) { |
| 194 | + if (request.headers.get("range")) { |
| 195 | + header = request.headers.get("if-range"); |
| 196 | + if (typeof header === 'string') { |
| 197 | + // console.log("!ifRange(responseHeaders, header)", !ifRange(responseHeaders, header)); |
| 198 | + if (!ifRange(responseHeaders, header)) { |
| 199 | + // We delete the range headers so that the `get` function will return the full body |
| 200 | + request.headers.delete("range") |
| 201 | + } |
| 202 | + } |
| 203 | + } |
| 204 | + } |
| 205 | + |
| 206 | + // 6. Otherwise, |
| 207 | + // - perform the requested method and respond according to its success or failure. |
| 208 | + return null; |
| 209 | +} |
| 210 | + |
| 211 | +function isWeak(etag) { |
| 212 | + return etag.startsWith("W/\""); |
| 213 | +} |
| 214 | + |
| 215 | +function isStrong(etag) { |
| 216 | + return etag.startsWith("\""); |
| 217 | +} |
| 218 | + |
| 219 | +function opaqueTag(etag) { |
| 220 | + if (isWeak(etag)) { |
| 221 | + return etag.substring(2); |
| 222 | + } |
| 223 | + return etag; |
| 224 | +} |
| 225 | +function weakMatch(a, b) { |
| 226 | + // https://httpwg.org/specs/rfc9110.html#entity.tag.comparison |
| 227 | + // two entity tags are equivalent if their opaque-tags match character-by-character, regardless of either or both being tagged as "weak". |
| 228 | + return opaqueTag(a) === opaqueTag(b); |
| 229 | +} |
| 230 | + |
| 231 | +function strongMatch(a, b) { |
| 232 | + // https://httpwg.org/specs/rfc9110.html#entity.tag.comparison |
| 233 | + // two entity tags are equivalent if both are not weak and their opaque-tags match character-by-character. |
| 234 | + return isStrong(a) && isStrong(b) && a === b; |
| 235 | +} |
| 236 | + |
| 237 | +function splitList(value) { |
| 238 | + return value.split(",").map(s => s.trim()); |
| 239 | +} |
| 240 | + |
| 241 | +// https://httpwg.org/specs/rfc9110.html#field.if-match |
| 242 | +function ifMatch(validationFields, header) { |
| 243 | + if (validationFields.ETag === undefined) { |
| 244 | + return true; |
| 245 | + } |
| 246 | + |
| 247 | + // 1. If the field value is "*", the condition is true if the origin server has a current representation for the target resource. |
| 248 | + if (header === "*") { |
| 249 | + if (validationFields.ETag !== undefined) { |
| 250 | + return true; |
| 251 | + } |
| 252 | + } else { |
| 253 | + // 2. If the field value is a list of entity tags, the condition is true if any of the listed tags match the entity tag of the selected representation. |
| 254 | + // An origin server MUST use the strong comparison function when comparing entity tags for If-Match (Section 8.8.3.2), |
| 255 | + // since the client intends this precondition to prevent the method from being applied if there have been any changes to the representation data. |
| 256 | + if (splitList(header).some(etag => { |
| 257 | + console.log(`strongMatch(${etag}, ${validationFields.ETag}) -- ${strongMatch(etag, validationFields.ETag)}`); |
| 258 | + return strongMatch(etag, validationFields.ETag) |
| 259 | + })) { |
| 260 | + return true; |
| 261 | + } |
| 262 | + } |
| 263 | + |
| 264 | + // 3. Otherwise, the condition is false. |
| 265 | + return false; |
| 266 | +} |
| 267 | + |
| 268 | +// https://httpwg.org/specs/rfc9110.html#field.if-none-match |
| 269 | +function ifNoneMatch(validationFields, header) { |
| 270 | + // 1. If the field value is "*", the condition is false if the origin server has a current representation for the target resource. |
| 271 | + if (header === "*") { |
| 272 | + if (validationFields.ETag !== undefined) { |
| 273 | + return false; |
| 274 | + } |
| 275 | + } else { |
| 276 | + // 2. If the field value is a list of entity tags, the condition is false if one of the listed tags matches the entity tag of the selected representation. |
| 277 | + // A recipient MUST use the weak comparison function when comparing entity tags for If-None-Match (Section 8.8.3.2), since weak entity tags can be used for cache validation even if there have been changes to the representation data. |
| 278 | + if (splitList(header).some(etag => weakMatch(etag, validationFields.ETag))) { |
| 279 | + return false; |
| 280 | + } |
| 281 | + } |
| 282 | + |
| 283 | + // 3. Otherwise, the condition is true. |
| 284 | + return true; |
| 285 | +} |
| 286 | + |
| 287 | +// https://httpwg.org/specs/rfc9110.html#field.if-modified-since |
| 288 | +function ifModifiedSince(validationFields, header) { |
| 289 | + // A recipient MUST ignore the If-Modified-Since header field if the received field value is not a valid HTTP-date, the field value has more than one member, or if the request method is neither GET nor HEAD. |
| 290 | + const date = new Date(header); |
| 291 | + if (isNaN(date)) { |
| 292 | + return true; |
| 293 | + } |
| 294 | + |
| 295 | + // 1. If the selected representation's last modification date is earlier or equal to the date provided in the field value, the condition is false. |
| 296 | + if (new Date(validationFields["Last-Modified"]) <= date) { |
| 297 | + return false; |
| 298 | + } |
| 299 | + // 2. Otherwise, the condition is true. |
| 300 | + return true; |
| 301 | +} |
| 302 | + |
| 303 | +// https://httpwg.org/specs/rfc9110.html#field.if-unmodified-since |
| 304 | +// function ifUnmodifiedSince(req, validationFields, header) { |
| 305 | +// // A recipient MUST ignore the If-Unmodified-Since header field if the received field value is not a valid HTTP-date (including when the field value appears to be a list of dates). |
| 306 | +// const date = new Date(header); |
| 307 | +// if (isNaN(date)) { |
| 308 | +// return true; |
| 309 | +// } |
| 310 | + |
| 311 | +// // 1. If the selected representation's last modification date is earlier than or equal to the date provided in the field value, the condition is true. |
| 312 | +// if (new Date(validationFields["Last-Modified"]) <= date) { |
| 313 | +// return true; |
| 314 | +// } |
| 315 | +// // 2. Otherwise, the condition is false. |
| 316 | +// return false; |
| 317 | +// } |
| 318 | + |
| 319 | +// https://httpwg.org/specs/rfc9110.html#field.if-range |
| 320 | +function ifRange(validationFields, header) { |
| 321 | + const date = new Date(header); |
| 322 | + console.log(new Date(validationFields["Last-Modified"]), date); |
| 323 | + console.log(new Date(validationFields["Last-Modified"]).getTime() === date.getTime()); |
| 324 | + if (!isNaN(date)) { |
| 325 | + // To evaluate a received If-Range header field containing an HTTP-date: |
| 326 | + // 1. If the HTTP-date validator provided is not a strong validator in the sense defined by Section 8.8.2.2, the condition is false. |
| 327 | + // 2. If the HTTP-date validator provided exactly matches the Last-Modified field value for the selected representation, the condition is true. |
| 328 | + if (new Date(validationFields["Last-Modified"]).getTime() === date.getTime()) { |
| 329 | + return true; |
| 330 | + } |
| 331 | + // 3. Otherwise, the condition is false. |
| 332 | + return false; |
| 333 | + } else { |
| 334 | + // To evaluate a received If-Range header field containing an entity-tag: |
| 335 | + // 1. If the entity-tag validator provided exactly matches the ETag field value for the selected representation using the strong comparison function (Section 8.8.3.2), the condition is true. |
| 336 | + if (strongMatch(header, validationFields.ETag)) { |
| 337 | + return true; |
| 338 | + } |
| 339 | + // 2. Otherwise, the condition is false. |
| 340 | + return false; |
| 341 | + } |
| 342 | +} |
0 commit comments