diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 62562b74..00000000 --- a/.eslintignore +++ /dev/null @@ -1,2 +0,0 @@ -coverage -node_modules diff --git a/.eslintrc.yml b/.eslintrc.yml deleted file mode 100644 index e4f03fba..00000000 --- a/.eslintrc.yml +++ /dev/null @@ -1,11 +0,0 @@ -root: true -extends: - - standard - - plugin:markdown/recommended -plugins: - - markdown -overrides: - - files: '**/*.md' - processor: 'markdown/markdown' -rules: - no-param-reassign: error diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..a0e7df93 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Set default behavior to automatically convert line endings +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e70c52e..5bd665dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: ci +name: CI on: push: @@ -45,7 +45,7 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] # Node.js release schedule: https://nodejs.org/en/about/releases/ - node-version: [18, 19, 20, 21, 22, 23, 24, 25] + node-version: [20, 22, 23, 24, 25] steps: - uses: actions/checkout@v6 @@ -62,7 +62,7 @@ jobs: run: npm install - name: Run tests - run: npm run test-ci + run: npm run test:ci - name: Upload code coverage uses: actions/upload-artifact@v5 diff --git a/.gitignore b/.gitignore index f15b98e2..7105678e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,152 @@ -.nyc_output/ -coverage/ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories node_modules/ -npm-debug.log +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# Vim swap files +*.swp + +# macOS files +.DS_Store + +# Clinic +.clinic + +# lock files +bun.lockb package-lock.json +pnpm-lock.yaml +yarn.lock + +# editor files +.vscode +.idea + +#tap files +.tap/ \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..5b4b8cc2 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +ignore-scripts=true +package-lock=false + diff --git a/HISTORY.md b/HISTORY.md index 7207f410..63c6084b 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -561,37 +561,37 @@ * update range-parser and fresh -0.1.4 / 2013-08-11 +0.1.4 / 2013-08-11 ================== * update fresh -0.1.3 / 2013-07-08 +0.1.3 / 2013-07-08 ================== * Revert "Fix fd leak" -0.1.2 / 2013-07-03 +0.1.2 / 2013-07-03 ================== * Fix fd leak -0.1.0 / 2012-08-25 +0.1.0 / 2012-08-25 ================== * add options parameter to send() that is passed to fs.createReadStream() [kanongil] -0.0.4 / 2012-08-16 +0.0.4 / 2012-08-16 ================== * allow custom "Accept-Ranges" definition -0.0.3 / 2012-07-16 +0.0.3 / 2012-07-16 ================== * fix normalization of the root directory. Closes #3 -0.0.2 / 2012-07-09 +0.0.2 / 2012-07-09 ================== * add passing of req explicitly for now (YUCK) diff --git a/LICENSE b/LICENSE index b6ea1c1f..9b43b7f4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,23 +1,24 @@ -(The MIT License) +MIT License Copyright (c) 2012 TJ Holowaychuk Copyright (c) 2014-2022 Douglas Christopher Wilson +Copyright The Fastify Contributors. +Copyright The Express Contributors. -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 350fccd5..7d72845e 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,12 @@ [![CI][github-actions-ci-image]][github-actions-ci-url] [![Test Coverage][coveralls-image]][coveralls-url] -Send is a library for streaming files from the file system as a http response +Send is a library for streaming files from the file system as an HTTP response supporting partial responses (Ranges), conditional-GET negotiation (If-Match, If-Unmodified-Since, If-None-Match, If-Modified-Since), high test coverage, and granular events which may be leveraged to take appropriate actions in your application or framework. -Looking to serve up entire folders mapped to URLs? Try [serve-static](https://www.npmjs.org/package/serve-static). - ## Installation This is a [Node.js](https://nodejs.org/en/) module available through the @@ -23,17 +21,26 @@ This is a [Node.js](https://nodejs.org/en/) module available through the $ npm install send ``` +### TypeScript + +`@types/mime@3` must be used if wanting to use TypeScript; +`@types/mime@4` removed the `mime` types. + +```bash +$ npm install -D @types/mime@3 +``` + ## API ```js -var send = require('send') +const send = require('send') ``` ### send(req, path, [options]) -Create a new `SendStream` for the given path to send to a `res`. The `req` is -the Node.js HTTP request and the `path` is a urlencoded path to send (urlencoded, -not the actual file-system path). +Provide `statusCode`, `headers`, and `stream` for the given path to send to a +`res`. The `req` is the Node.js HTTP request and the `path `is a urlencoded path +to send (urlencoded, not the actual file-system path). #### Options @@ -48,11 +55,19 @@ of the `Range` request header. Enable or disable setting `Cache-Control` response header, defaults to true. Disabling this will ignore the `immutable` and `maxAge` options. +##### contentType + +By default, this library uses the `mime` module to set the `Content-Type` +of the response based on the file extension of the requested file. + +To disable this functionality, set `contentType` to `false`. +The `Content-Type` header will need to be set manually if disabled. + ##### dotfiles Set how "dotfiles" are treated when encountered. A dotfile is a file or directory that begins with a dot ("."). Note this check is done on -the path itself without checking if the path actually exists on the +the path itself without checking if the path exists on the disk. If `root` is specified, only the dotfiles above the root are checked (i.e. the root itself can be within a dotfile when set to "deny"). @@ -103,10 +118,15 @@ system's last modified value. ##### maxAge -Provide a max-age in milliseconds for http caching, defaults to 0. +Provide a max-age in milliseconds for HTTP caching, defaults to 0. This can also be a string accepted by the [ms](https://www.npmjs.org/package/ms#readme) module. +##### maxContentRangeChunkSize + +Specify the maximum response content size, defaults to the entire file size. +This will be used when `acceptRanges` is true. + ##### root Serve files relative to `path`. @@ -116,21 +136,21 @@ Serve files relative to `path`. Byte offset at which the stream starts, defaults to 0. The start is inclusive, meaning `start: 2` will include the 3rd byte in the stream. -#### Events +##### highWaterMark -The `SendStream` is an event emitter and will emit the following events: +When provided, this option sets the maximum number of bytes that the internal +buffer will hold before pausing reads from the underlying resource. +If you omit this option (or pass undefined), Node.js falls back to +its built-in default for readable binary streams. - - `error` an error occurred `(err)` - - `directory` a directory was requested `(res, path)` - - `file` a file was requested `(path, stat)` - - `headers` the headers are about to be set on a file `(res, path, stat)` - - `stream` file streaming has started `(stream)` - - `end` streaming has completed +### .mime -#### .pipe +The `mime` export is the global instance of the +[`mime` npm module](https://www.npmjs.com/package/mime). -The `pipe` method is used to pipe the response into the Node.js HTTP response -object, typically `send(req, path, options).pipe(res)`. +This is used to configure the MIME types that are associated with file extensions +as well as other options for how to resolve the MIME type of a file (like the +default type to use for an unknown file extension). ## Error-handling @@ -138,6 +158,7 @@ By default when no `error` listeners are present an automatic response will be made, otherwise you have full control over the response, aka you may show a 5xx page etc. + ## Caching It does _not_ perform internal caching, you should use a reverse proxy cache @@ -147,10 +168,10 @@ caching, it's small enough that it does not need caching at all ;). ## Debugging -To enable `debug()` instrumentation output export __DEBUG__: +To enable `debug()` instrumentation output export __NODE_DEBUG__: ``` -$ DEBUG=send node app +$ NODE_DEBUG=send node app ``` ## Running tests @@ -167,12 +188,13 @@ $ npm test This simple example will send a specific file to all requests. ```js -var http = require('http') -var send = require('send') +const http = require('node:http') +const send = require('send') -var server = http.createServer(function onRequest (req, res) { - send(req, '/path/to/index.html') - .pipe(res) +const server = http.createServer(async function onRequest (req, res) { + const { statusCode, headers, stream } = await send(req, '/path/to/index.html') + res.writeHead(statusCode, headers) + stream.pipe(res) }) server.listen(3000) @@ -185,13 +207,14 @@ given directory as the top-level. For example, a request `GET /foo.txt` will send back `/www/public/foo.txt`. ```js -var http = require('http') -var parseUrl = require('parseurl') -var send = require('send') - -var server = http.createServer(function onRequest (req, res) { - send(req, parseUrl(req).pathname, { root: '/www/public' }) - .pipe(res) +const http = require('node:http') +const parseUrl = require('parseurl') +const send = require('send') + +const server = http.createServer(async function onRequest (req, res) { + const { statusCode, headers, stream } = await send(req, parseUrl(req).pathname, { root: '/www/public' }) + res.writeHead(statusCode, headers) + stream.pipe(res) }) server.listen(3000) @@ -200,23 +223,22 @@ server.listen(3000) ### Custom file types ```js -var extname = require('path').extname -var http = require('http') -var parseUrl = require('parseurl') -var send = require('send') - -var server = http.createServer(function onRequest (req, res) { - send(req, parseUrl(req).pathname, { root: '/www/public' }) - .on('headers', function (res, path) { - switch (extname(path)) { - case '.x-mt': - case '.x-mtt': - // custom type for these extensions - res.setHeader('Content-Type', 'application/x-my-type') - break - } - }) - .pipe(res) +const http = require('node:http') +const parseUrl = require('parseurl') +const send = require('send') + +// Default unknown types to text/plain +send.mime.default_type = 'text/plain' + +// Add a custom type +send.mime.define({ + 'application/x-my-type': ['x-mt', 'x-mtt'] +}) + +const server = http.createServer(function onRequest (req, res) { + const { statusCode, headers, stream } = await send(req, parseUrl(req).pathname, { root: '/www/public' }) + res.writeHead(statusCode, headers) + stream.pipe(res) }) server.listen(3000) @@ -228,75 +250,64 @@ This is an example of serving up a structure of directories with a custom function to render a listing of a directory. ```js -var http = require('http') -var fs = require('fs') -var parseUrl = require('parseurl') -var send = require('send') +const http = require('node:http') +const fs = require('node:fs') +const parseUrl = require('parseurl') +const send = require('send') // Transfer arbitrary files from within /www/example.com/public/* // with a custom handler for directory listing -var server = http.createServer(function onRequest (req, res) { - send(req, parseUrl(req).pathname, { index: false, root: '/www/public' }) - .once('directory', directory) - .pipe(res) +const server = http.createServer(async function onRequest (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, parseUrl(req).pathname, { index: false, root: '/www/public' }) + if(type === 'directory') { + // get directory list + const list = await readdir(metadata.path) + // render an index for the directory + res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }) + res.end(list.join('\n') + '\n') + } else { + res.writeHead(statusCode, headers) + stream.pipe(res) + } }) server.listen(3000) - -// Custom directory handler -function directory (res, path) { - var stream = this - - // redirect to trailing slash for consistent url - if (!stream.hasTrailingSlash()) { - return stream.redirect(path) - } - - // get directory list - fs.readdir(path, function onReaddir (err, list) { - if (err) return stream.error(err) - - // render an index for the directory - res.setHeader('Content-Type', 'text/plain; charset=UTF-8') - res.end(list.join('\n') + '\n') - }) -} ``` ### Serving from a root directory with custom error-handling ```js -var http = require('http') -var parseUrl = require('parseurl') -var send = require('send') - -var server = http.createServer(function onRequest (req, res) { - // your custom error-handling logic: - function error (err) { - res.statusCode = err.status || 500 - res.end(err.message) - } - - // your custom headers - function headers (res, path, stat) { - // serve all files for download - res.setHeader('Content-Disposition', 'attachment') - } - - // your custom directory handling logic: - function redirect () { - res.statusCode = 301 - res.setHeader('Location', req.url + '/') - res.end('Redirecting to ' + req.url + '/') - } +const http = require('node:http') +const parseUrl = require('parseurl') +const send = require('send') +const server = http.createServer(async function onRequest (req, res) { // transfer arbitrary files from within // /www/example.com/public/* - send(req, parseUrl(req).pathname, { root: '/www/public' }) - .on('error', error) - .on('directory', redirect) - .on('headers', headers) - .pipe(res) + const { statusCode, headers, stream, type, metadata } = await send(req, parseUrl(req).pathname, { root: '/www/public' }) + switch (type) { + case 'directory': { + // your custom directory handling logic: + res.writeHead(301, { + 'Location': metadata.requestPath + '/' + }) + res.end('Redirecting to ' + metadata.requestPath + '/') + break + } + case 'error': { + // your custom error-handling logic: + res.writeHead(metadata.error.status ?? 500, {}) + res.end(metadata.error.message) + break + } + default: { + // your custom headers + // serve all files for download + res.setHeader('Content-Disposition', 'attachment') + res.writeHead(statusCode, headers) + stream.pipe(res) + } + } }) server.listen(3000) @@ -314,4 +325,4 @@ server.listen(3000) [node-url]: https://nodejs.org/en/download/ [npm-downloads-image]: https://badgen.net/npm/dm/send [npm-url]: https://npmjs.org/package/send -[npm-version-image]: https://badgen.net/npm/v/send +[npm-version-image]: https://badgen.net/npm/v/send \ No newline at end of file diff --git a/benchmarks/collapseLeadingSlashes.js b/benchmarks/collapseLeadingSlashes.js new file mode 100644 index 00000000..2b56dd4a --- /dev/null +++ b/benchmarks/collapseLeadingSlashes.js @@ -0,0 +1,13 @@ +'use strict' + +const benchmark = require('benchmark') +const collapseLeadingSlashes = require('../lib/collapseLeadingSlashes').collapseLeadingSlashes + +const nonLeading = 'bla.json' +const hasLeading = '///./json' + +new benchmark.Suite() + .add(nonLeading, function () { collapseLeadingSlashes(nonLeading) }, { minSamples: 100 }) + .add(hasLeading, function () { collapseLeadingSlashes(hasLeading) }, { minSamples: 100 }) + .on('cycle', function onCycle (event) { console.log(String(event.target)) }) + .run({ async: false }) diff --git a/benchmarks/containsDotFile.js b/benchmarks/containsDotFile.js new file mode 100644 index 00000000..a9cbfc43 --- /dev/null +++ b/benchmarks/containsDotFile.js @@ -0,0 +1,15 @@ +'use strict' + +const benchmark = require('benchmark') +const { containsDotFile } = require('../lib/containsDotFile') + +const hasDotFileSimple = '.github'.split('/') +const hasDotFile = './.github'.split('/') +const noDotFile = './index.html'.split('/') + +new benchmark.Suite() + .add(hasDotFileSimple.join('/'), function () { containsDotFile(hasDotFileSimple) }, { minSamples: 100 }) + .add(noDotFile.join('/'), function () { containsDotFile(noDotFile) }, { minSamples: 100 }) + .add(hasDotFile.join('/'), function () { containsDotFile(hasDotFile) }, { minSamples: 100 }) + .on('cycle', function onCycle (event) { console.log(String(event.target)) }) + .run({ async: false }) diff --git a/benchmarks/isUtf8MimeType.js b/benchmarks/isUtf8MimeType.js new file mode 100644 index 00000000..a68a8bc0 --- /dev/null +++ b/benchmarks/isUtf8MimeType.js @@ -0,0 +1,23 @@ +'use strict' + +const benchmark = require('benchmark') +const isUtf8MimeType = require('../lib/isUtf8MimeType').isUtf8MimeType + +const applicationJson = 'application/json' +const applicationJavascript = 'application/javascript' +const textJson = 'text/json' +const textHtml = 'text/html' +const textJavascript = 'text/javascript' +const imagePng = 'image/png' + +new benchmark.Suite() + .add('isUtf8MimeType', function () { + isUtf8MimeType(applicationJson) + isUtf8MimeType(applicationJavascript) + isUtf8MimeType(imagePng) + isUtf8MimeType(textJson) + isUtf8MimeType(textHtml) + isUtf8MimeType(textJavascript) + }, { minSamples: 100 }) + .on('cycle', function onCycle (event) { console.log(String(event.target)) }) + .run({ async: false }) diff --git a/benchmarks/normalizeList.js b/benchmarks/normalizeList.js new file mode 100644 index 00000000..9926ca3c --- /dev/null +++ b/benchmarks/normalizeList.js @@ -0,0 +1,14 @@ +'use strict' + +const benchmark = require('benchmark') +const { normalizeList } = require('../lib/normalizeList') + +const validSingle = 'a' +const validArray = ['a', 'b', 'c'] + +new benchmark.Suite() + .add('false', function () { normalizeList(false) }, { minSamples: 100 }) + .add('valid single', function () { normalizeList(validSingle) }, { minSamples: 100 }) + .add('valid array', function () { normalizeList(validArray) }, { minSamples: 100 }) + .on('cycle', function onCycle (event) { console.log(String(event.target)) }) + .run({ async: false }) diff --git a/benchmarks/parseBytesRange.js b/benchmarks/parseBytesRange.js new file mode 100644 index 00000000..48d59aba --- /dev/null +++ b/benchmarks/parseBytesRange.js @@ -0,0 +1,15 @@ +'use strict' + +const benchmark = require('benchmark') +const { parseBytesRange } = require('../lib/parseBytesRange') + +const size150 = 150 + +const rangeSingle = 'bytes=0-100' +const rangeMultiple = 'bytes=0-4,90-99,5-75,100-199,101-102' + +new benchmark.Suite() + .add('size: 150, bytes=0-100', function () { parseBytesRange(size150, rangeSingle) }, { minSamples: 100 }) + .add('size: 150, bytes=0-4,90-99,5-75,100-199,101-102', function () { parseBytesRange(size150, rangeMultiple) }, { minSamples: 100 }) + .on('cycle', function onCycle (event) { console.log(String(event.target)) }) + .run({ async: false }) diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..89fd678f --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,6 @@ +'use strict' + +module.exports = require('neostandard')({ + ignores: require('neostandard').resolveIgnoresFromGitignore(), + ts: true +}) diff --git a/examples/index.html b/examples/index.html new file mode 100644 index 00000000..7f41cde5 --- /dev/null +++ b/examples/index.html @@ -0,0 +1 @@ +
Hello, World
diff --git a/examples/simple.js b/examples/simple.js new file mode 100644 index 00000000..5297b75b --- /dev/null +++ b/examples/simple.js @@ -0,0 +1,15 @@ +'use strict' + +const http = require('node:http') +const send = require('..') +const path = require('node:path') + +const indexPath = path.join(__dirname, 'index.html') + +const server = http.createServer(async function onRequest (req, res) { + const { statusCode, headers, stream } = await send(req, indexPath) + res.writeHead(statusCode, headers) + stream.pipe(res) +}) + +server.listen(3000) diff --git a/index.js b/index.js index 1655053d..c1c2b01b 100644 --- a/index.js +++ b/index.js @@ -11,54 +11,9 @@ * Module dependencies. * @private */ - -var createError = require('http-errors') -var debug = require('debug')('send') -var encodeUrl = require('encodeurl') -var escapeHtml = require('escape-html') -var etag = require('etag') -var fresh = require('fresh') -var fs = require('fs') -var mime = require('mime-types') -var ms = require('ms') -var onFinished = require('on-finished') -var parseRange = require('range-parser') -var path = require('path') -var statuses = require('statuses') -var Stream = require('stream') -var util = require('util') - -/** - * Path function references. - * @private - */ - -var extname = path.extname -var join = path.join -var normalize = path.normalize -var resolve = path.resolve -var sep = path.sep - -/** - * Regular expression for identifying a bytes Range header. - * @private - */ - -var BYTES_RANGE_REGEXP = /^ *bytes=/ - -/** - * Maximum value allowed for the max age. - * @private - */ - -var MAX_MAXAGE = 60 * 60 * 24 * 365 * 1000 // 1 year - -/** - * Regular expression to match a path with a directory up component. - * @private - */ - -var UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/ +const isUtf8MimeType = require('./lib/isUtf8MimeType').isUtf8MimeType +const mime = require('mime') +const send = require('./lib/send').send /** * Module exports. @@ -66,932 +21,8 @@ var UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/ */ module.exports = send +module.exports.default = send +module.exports.send = send -/** - * Return a `SendStream` for `req` and `path`. - * - * @param {object} req - * @param {string} path - * @param {object} [options] - * @return {SendStream} - * @public - */ - -function send (req, path, options) { - return new SendStream(req, path, options) -} - -/** - * Initialize a `SendStream` with the given `path`. - * - * @param {Request} req - * @param {String} path - * @param {object} [options] - * @private - */ - -function SendStream (req, path, options) { - Stream.call(this) - - var opts = options || {} - - this.options = opts - this.path = path - this.req = req - - this._acceptRanges = opts.acceptRanges !== undefined - ? Boolean(opts.acceptRanges) - : true - - this._cacheControl = opts.cacheControl !== undefined - ? Boolean(opts.cacheControl) - : true - - this._etag = opts.etag !== undefined - ? Boolean(opts.etag) - : true - - this._dotfiles = opts.dotfiles !== undefined - ? opts.dotfiles - : 'ignore' - - if (this._dotfiles !== 'ignore' && this._dotfiles !== 'allow' && this._dotfiles !== 'deny') { - throw new TypeError('dotfiles option must be "allow", "deny", or "ignore"') - } - - this._extensions = opts.extensions !== undefined - ? normalizeList(opts.extensions, 'extensions option') - : [] - - this._immutable = opts.immutable !== undefined - ? Boolean(opts.immutable) - : false - - this._index = opts.index !== undefined - ? normalizeList(opts.index, 'index option') - : ['index.html'] - - this._lastModified = opts.lastModified !== undefined - ? Boolean(opts.lastModified) - : true - - this._maxage = opts.maxAge || opts.maxage - this._maxage = typeof this._maxage === 'string' - ? ms(this._maxage) - : Number(this._maxage) - this._maxage = !isNaN(this._maxage) - ? Math.min(Math.max(0, this._maxage), MAX_MAXAGE) - : 0 - - this._root = opts.root - ? resolve(opts.root) - : null -} - -/** - * Inherits from `Stream`. - */ - -util.inherits(SendStream, Stream) - -/** - * Emit error with `status`. - * - * @param {number} status - * @param {Error} [err] - * @private - */ - -SendStream.prototype.error = function error (status, err) { - // emit if listeners instead of responding - if (hasListeners(this, 'error')) { - return this.emit('error', createHttpError(status, err)) - } - - var res = this.res - var msg = statuses.message[status] || String(status) - var doc = createHtmlDocument('Error', escapeHtml(msg)) - - // clear existing headers - clearHeaders(res) - - // add error headers - if (err && err.headers) { - setHeaders(res, err.headers) - } - - // send basic response - res.statusCode = status - res.setHeader('Content-Type', 'text/html; charset=UTF-8') - res.setHeader('Content-Length', Buffer.byteLength(doc)) - res.setHeader('Content-Security-Policy', "default-src 'none'") - res.setHeader('X-Content-Type-Options', 'nosniff') - res.end(doc) -} - -/** - * Check if the pathname ends with "/". - * - * @return {boolean} - * @private - */ - -SendStream.prototype.hasTrailingSlash = function hasTrailingSlash () { - return this.path[this.path.length - 1] === '/' -} - -/** - * Check if this is a conditional GET request. - * - * @return {Boolean} - * @api private - */ - -SendStream.prototype.isConditionalGET = function isConditionalGET () { - return this.req.headers['if-match'] || - this.req.headers['if-unmodified-since'] || - this.req.headers['if-none-match'] || - this.req.headers['if-modified-since'] -} - -/** - * Check if the request preconditions failed. - * - * @return {boolean} - * @private - */ - -SendStream.prototype.isPreconditionFailure = function isPreconditionFailure () { - var req = this.req - var res = this.res - - // if-match - var match = req.headers['if-match'] - if (match) { - var etag = res.getHeader('ETag') - return !etag || (match !== '*' && parseTokenList(match).every(function (match) { - return match !== etag && match !== 'W/' + etag && 'W/' + match !== etag - })) - } - - // if-unmodified-since - var unmodifiedSince = parseHttpDate(req.headers['if-unmodified-since']) - if (!isNaN(unmodifiedSince)) { - var lastModified = parseHttpDate(res.getHeader('Last-Modified')) - return isNaN(lastModified) || lastModified > unmodifiedSince - } - - return false -} - -/** - * Strip various content header fields for a change in entity. - * - * @private - */ - -SendStream.prototype.removeContentHeaderFields = function removeContentHeaderFields () { - var res = this.res - - res.removeHeader('Content-Encoding') - res.removeHeader('Content-Language') - res.removeHeader('Content-Length') - res.removeHeader('Content-Range') - res.removeHeader('Content-Type') -} - -/** - * Respond with 304 not modified. - * - * @api private - */ - -SendStream.prototype.notModified = function notModified () { - var res = this.res - debug('not modified') - this.removeContentHeaderFields() - res.statusCode = 304 - res.end() -} - -/** - * Raise error that headers already sent. - * - * @api private - */ - -SendStream.prototype.headersAlreadySent = function headersAlreadySent () { - var err = new Error('Can\'t set headers after they are sent.') - debug('headers already sent') - this.error(500, err) -} - -/** - * Check if the request is cacheable, aka - * responded with 2xx or 304 (see RFC 2616 section 14.2{5,6}). - * - * @return {Boolean} - * @api private - */ - -SendStream.prototype.isCachable = function isCachable () { - var statusCode = this.res.statusCode - return (statusCode >= 200 && statusCode < 300) || - statusCode === 304 -} - -/** - * Handle stat() error. - * - * @param {Error} error - * @private - */ - -SendStream.prototype.onStatError = function onStatError (error) { - switch (error.code) { - case 'ENAMETOOLONG': - case 'ENOENT': - case 'ENOTDIR': - this.error(404, error) - break - default: - this.error(500, error) - break - } -} - -/** - * Check if the cache is fresh. - * - * @return {Boolean} - * @api private - */ - -SendStream.prototype.isFresh = function isFresh () { - return fresh(this.req.headers, { - etag: this.res.getHeader('ETag'), - 'last-modified': this.res.getHeader('Last-Modified') - }) -} - -/** - * Check if the range is fresh. - * - * @return {Boolean} - * @api private - */ - -SendStream.prototype.isRangeFresh = function isRangeFresh () { - var ifRange = this.req.headers['if-range'] - - if (!ifRange) { - return true - } - - // if-range as etag - if (ifRange.indexOf('"') !== -1) { - var etag = this.res.getHeader('ETag') - return Boolean(etag && ifRange.indexOf(etag) !== -1) - } - - // if-range as modified date - var lastModified = this.res.getHeader('Last-Modified') - return parseHttpDate(lastModified) <= parseHttpDate(ifRange) -} - -/** - * Redirect to path. - * - * @param {string} path - * @private - */ - -SendStream.prototype.redirect = function redirect (path) { - var res = this.res - - if (hasListeners(this, 'directory')) { - this.emit('directory', res, path) - return - } - - if (this.hasTrailingSlash()) { - this.error(403) - return - } - - var loc = encodeUrl(collapseLeadingSlashes(this.path + '/')) - var doc = createHtmlDocument('Redirecting', 'Redirecting to ' + escapeHtml(loc)) - - // redirect - res.statusCode = 301 - res.setHeader('Content-Type', 'text/html; charset=UTF-8') - res.setHeader('Content-Length', Buffer.byteLength(doc)) - res.setHeader('Content-Security-Policy', "default-src 'none'") - res.setHeader('X-Content-Type-Options', 'nosniff') - res.setHeader('Location', loc) - res.end(doc) -} - -/** - * Pipe to `res. - * - * @param {Stream} res - * @return {Stream} res - * @api public - */ - -SendStream.prototype.pipe = function pipe (res) { - // root path - var root = this._root - - // references - this.res = res - - // decode the path - var path = decode(this.path) - if (path === -1) { - this.error(400) - return res - } - - // null byte(s) - if (~path.indexOf('\0')) { - this.error(400) - return res - } - - var parts - if (root !== null) { - // normalize - if (path) { - path = normalize('.' + sep + path) - } - - // malicious path - if (UP_PATH_REGEXP.test(path)) { - debug('malicious path "%s"', path) - this.error(403) - return res - } - - // explode path parts - parts = path.split(sep) - - // join / normalize from optional root dir - path = normalize(join(root, path)) - } else { - // ".." is malicious without "root" - if (UP_PATH_REGEXP.test(path)) { - debug('malicious path "%s"', path) - this.error(403) - return res - } - - // explode path parts - parts = normalize(path).split(sep) - - // resolve the path - path = resolve(path) - } - - // dotfile handling - if (containsDotFile(parts)) { - debug('%s dotfile "%s"', this._dotfiles, path) - switch (this._dotfiles) { - case 'allow': - break - case 'deny': - this.error(403) - return res - case 'ignore': - default: - this.error(404) - return res - } - } - - // index file support - if (this._index.length && this.hasTrailingSlash()) { - this.sendIndex(path) - return res - } - - this.sendFile(path) - return res -} - -/** - * Transfer `path`. - * - * @param {String} path - * @api public - */ - -SendStream.prototype.send = function send (path, stat) { - var len = stat.size - var options = this.options - var opts = {} - var res = this.res - var req = this.req - var ranges = req.headers.range - var offset = options.start || 0 - - if (res.headersSent) { - // impossible to send now - this.headersAlreadySent() - return - } - - debug('pipe "%s"', path) - - // set header fields - this.setHeader(path, stat) - - // set content-type - this.type(path) - - // conditional GET support - if (this.isConditionalGET()) { - if (this.isPreconditionFailure()) { - this.error(412) - return - } - - if (this.isCachable() && this.isFresh()) { - this.notModified() - return - } - } - - // adjust len to start/end options - len = Math.max(0, len - offset) - if (options.end !== undefined) { - var bytes = options.end - offset + 1 - if (len > bytes) len = bytes - } - - // Range support - if (this._acceptRanges && BYTES_RANGE_REGEXP.test(ranges)) { - // parse - ranges = parseRange(len, ranges, { - combine: true - }) - - // If-Range support - if (!this.isRangeFresh()) { - debug('range stale') - ranges = -2 - } - - // unsatisfiable - if (ranges === -1) { - debug('range unsatisfiable') - - // Content-Range - res.setHeader('Content-Range', contentRange('bytes', len)) - - // 416 Requested Range Not Satisfiable - return this.error(416, { - headers: { 'Content-Range': res.getHeader('Content-Range') } - }) - } - - // valid (syntactically invalid/multiple ranges are treated as a regular response) - if (ranges !== -2 && ranges.length === 1) { - debug('range %j', ranges) - - // Content-Range - res.statusCode = 206 - res.setHeader('Content-Range', contentRange('bytes', len, ranges[0])) - - // adjust for requested range - offset += ranges[0].start - len = ranges[0].end - ranges[0].start + 1 - } - } - - // clone options - for (var prop in options) { - opts[prop] = options[prop] - } - - // set read options - opts.start = offset - opts.end = Math.max(offset, offset + len - 1) - - // content-length - res.setHeader('Content-Length', len) - - // HEAD support - if (req.method === 'HEAD') { - res.end() - return - } - - this.stream(path, opts) -} - -/** - * Transfer file for `path`. - * - * @param {String} path - * @api private - */ -SendStream.prototype.sendFile = function sendFile (path) { - var i = 0 - var self = this - - debug('stat "%s"', path) - fs.stat(path, function onstat (err, stat) { - var pathEndsWithSep = path[path.length - 1] === sep - if (err && err.code === 'ENOENT' && !extname(path) && !pathEndsWithSep) { - // not found, check extensions - return next(err) - } - if (err) return self.onStatError(err) - if (stat.isDirectory()) return self.redirect(path) - if (pathEndsWithSep) return self.error(404) - self.emit('file', path, stat) - self.send(path, stat) - }) - - function next (err) { - if (self._extensions.length <= i) { - return err - ? self.onStatError(err) - : self.error(404) - } - - var p = path + '.' + self._extensions[i++] - - debug('stat "%s"', p) - fs.stat(p, function (err, stat) { - if (err) return next(err) - if (stat.isDirectory()) return next() - self.emit('file', p, stat) - self.send(p, stat) - }) - } -} - -/** - * Transfer index for `path`. - * - * @param {String} path - * @api private - */ -SendStream.prototype.sendIndex = function sendIndex (path) { - var i = -1 - var self = this - - function next (err) { - if (++i >= self._index.length) { - if (err) return self.onStatError(err) - return self.error(404) - } - - var p = join(path, self._index[i]) - - debug('stat "%s"', p) - fs.stat(p, function (err, stat) { - if (err) return next(err) - if (stat.isDirectory()) return next() - self.emit('file', p, stat) - self.send(p, stat) - }) - } - - next() -} - -/** - * Stream `path` to the response. - * - * @param {String} path - * @param {Object} options - * @api private - */ - -SendStream.prototype.stream = function stream (path, options) { - var self = this - var res = this.res - - // pipe - var stream = fs.createReadStream(path, options) - this.emit('stream', stream) - stream.pipe(res) - - // cleanup - function cleanup () { - stream.destroy() - } - - // response finished, cleanup - onFinished(res, cleanup) - - // error handling - stream.on('error', function onerror (err) { - // clean up stream early - cleanup() - - // error - self.onStatError(err) - }) - - // end - stream.on('end', function onend () { - self.emit('end') - }) -} - -/** - * Set content-type based on `path` - * if it hasn't been explicitly set. - * - * @param {String} path - * @api private - */ - -SendStream.prototype.type = function type (path) { - var res = this.res - - if (res.getHeader('Content-Type')) return - - var ext = extname(path) - var type = mime.contentType(ext) || 'application/octet-stream' - - debug('content-type %s', type) - res.setHeader('Content-Type', type) -} - -/** - * Set response header fields, most - * fields may be pre-defined. - * - * @param {String} path - * @param {Object} stat - * @api private - */ - -SendStream.prototype.setHeader = function setHeader (path, stat) { - var res = this.res - - this.emit('headers', res, path, stat) - - if (this._acceptRanges && !res.getHeader('Accept-Ranges')) { - debug('accept ranges') - res.setHeader('Accept-Ranges', 'bytes') - } - - if (this._cacheControl && !res.getHeader('Cache-Control')) { - var cacheControl = 'public, max-age=' + Math.floor(this._maxage / 1000) - - if (this._immutable) { - cacheControl += ', immutable' - } - - debug('cache-control %s', cacheControl) - res.setHeader('Cache-Control', cacheControl) - } - - if (this._lastModified && !res.getHeader('Last-Modified')) { - var modified = stat.mtime.toUTCString() - debug('modified %s', modified) - res.setHeader('Last-Modified', modified) - } - - if (this._etag && !res.getHeader('ETag')) { - var val = etag(stat) - debug('etag %s', val) - res.setHeader('ETag', val) - } -} - -/** - * Clear all headers from a response. - * - * @param {object} res - * @private - */ - -function clearHeaders (res) { - for (const header of res.getHeaderNames()) { - res.removeHeader(header) - } -} - -/** - * Collapse all leading slashes into a single slash - * - * @param {string} str - * @private - */ -function collapseLeadingSlashes (str) { - for (var i = 0; i < str.length; i++) { - if (str[i] !== '/') { - break - } - } - - return i > 1 - ? '/' + str.substr(i) - : str -} - -/** - * Determine if path parts contain a dotfile. - * - * @api private - */ - -function containsDotFile (parts) { - for (var i = 0; i < parts.length; i++) { - var part = parts[i] - if (part.length > 1 && part[0] === '.') { - return true - } - } - - return false -} - -/** - * Create a Content-Range header. - * - * @param {string} type - * @param {number} size - * @param {array} [range] - */ - -function contentRange (type, size, range) { - return type + ' ' + (range ? range.start + '-' + range.end : '*') + '/' + size -} - -/** - * Create a minimal HTML document. - * - * @param {string} title - * @param {string} body - * @private - */ - -function createHtmlDocument (title, body) { - return '\n' + - '\n' + - '\n' + - '\n' + - '' + body + '\n' + - '\n' + - '\n' -} - -/** - * Create a HttpError object from simple arguments. - * - * @param {number} status - * @param {Error|object} err - * @private - */ - -function createHttpError (status, err) { - if (!err) { - return createError(status) - } - - return err instanceof Error - ? createError(status, err, { expose: false }) - : createError(status, err) -} - -/** - * decodeURIComponent. - * - * Allows V8 to only deoptimize this fn instead of all - * of send(). - * - * @param {String} path - * @api private - */ - -function decode (path) { - try { - return decodeURIComponent(path) - } catch (err) { - return -1 - } -} - -/** - * Determine if emitter has listeners of a given type. - * - * The way to do this check is done three different ways in Node.js >= 0.10 - * so this consolidates them into a minimal set using instance methods. - * - * @param {EventEmitter} emitter - * @param {string} type - * @returns {boolean} - * @private - */ - -function hasListeners (emitter, type) { - var count = typeof emitter.listenerCount !== 'function' - ? emitter.listeners(type).length - : emitter.listenerCount(type) - - return count > 0 -} - -/** - * Normalize the index option into an array. - * - * @param {boolean|string|array} val - * @param {string} name - * @private - */ - -function normalizeList (val, name) { - var list = [].concat(val || []) - - for (var i = 0; i < list.length; i++) { - if (typeof list[i] !== 'string') { - throw new TypeError(name + ' must be array of strings or false') - } - } - - return list -} - -/** - * Parse an HTTP Date into a number. - * - * @param {string} date - * @private - */ - -function parseHttpDate (date) { - var timestamp = date && Date.parse(date) - - return typeof timestamp === 'number' - ? timestamp - : NaN -} - -/** - * Parse a HTTP token list. - * - * @param {string} str - * @private - */ - -function parseTokenList (str) { - var end = 0 - var list = [] - var start = 0 - - // gather tokens - for (var i = 0, len = str.length; i < len; i++) { - switch (str.charCodeAt(i)) { - case 0x20: /* */ - if (start === end) { - start = end = i + 1 - } - break - case 0x2c: /* , */ - if (start !== end) { - list.push(str.substring(start, end)) - } - start = end = i + 1 - break - default: - end = i + 1 - break - } - } - - // final token - if (start !== end) { - list.push(str.substring(start, end)) - } - - return list -} - -/** - * Set an object of headers on a response. - * - * @param {object} res - * @param {object} headers - * @private - */ - -function setHeaders (res, headers) { - var keys = Object.keys(headers) - - for (var i = 0; i < keys.length; i++) { - var key = keys[i] - res.setHeader(key, headers[key]) - } -} +module.exports.isUtf8MimeType = isUtf8MimeType +module.exports.mime = mime diff --git a/lib/collapseLeadingSlashes.js b/lib/collapseLeadingSlashes.js new file mode 100644 index 00000000..b611a9cd --- /dev/null +++ b/lib/collapseLeadingSlashes.js @@ -0,0 +1,25 @@ +'use strict' + +/** + * Collapse all leading slashes into a single slash + * + * @param {string} str + * @private + */ + +function collapseLeadingSlashes (str) { + if ( + str[0] !== '/' || + str[1] !== '/' + ) { + return str + } + for (let i = 2, il = str.length; i < il; ++i) { + if (str[i] !== '/') { + return str.slice(i - 1) + } + } + /* c8 ignore next */ +} + +module.exports.collapseLeadingSlashes = collapseLeadingSlashes diff --git a/lib/containsDotFile.js b/lib/containsDotFile.js new file mode 100644 index 00000000..c446aa0b --- /dev/null +++ b/lib/containsDotFile.js @@ -0,0 +1,23 @@ +/*! + * send + * Copyright(c) 2012 TJ Holowaychuk + * Copyright(c) 2014-2022 Douglas Christopher Wilson + * MIT Licensed + */ +'use strict' +/** + * Determine if path parts contain a dotfile. + * + * @api private + */ +function containsDotFile (parts) { + for (let i = 0, il = parts.length; i < il; ++i) { + if (parts[i].length !== 1 && parts[i][0] === '.') { + return true + } + } + + return false +} + +module.exports.containsDotFile = containsDotFile diff --git a/lib/contentRange.js b/lib/contentRange.js new file mode 100644 index 00000000..a2183edf --- /dev/null +++ b/lib/contentRange.js @@ -0,0 +1,18 @@ +/*! + * send + * Copyright(c) 2012 TJ Holowaychuk + * Copyright(c) 2014-2022 Douglas Christopher Wilson + * MIT Licensed + */ +'use strict' +/** + * Create a Content-Range header. + * + * @param {string} type + * @param {number} size + * @param {array} [range] + */ +function contentRange (type, size, range) { + return type + ' ' + (range ? range.start + '-' + range.end : '*') + '/' + size +} +exports.contentRange = contentRange diff --git a/lib/createHtmlDocument.js b/lib/createHtmlDocument.js new file mode 100644 index 00000000..d4d64d2b --- /dev/null +++ b/lib/createHtmlDocument.js @@ -0,0 +1,29 @@ +/*! + * send + * Copyright(c) 2012 TJ Holowaychuk + * Copyright(c) 2014-2022 Douglas Christopher Wilson + * MIT Licensed + */ +'use strict' +/** + * Create a minimal HTML document. + * + * @param {string} title + * @param {string} body + * @private + */ +function createHtmlDocument (title, body) { + const html = '\n' + + '\n' + + '\n' + + '\n' + + '
' + body + '\n' + + '\n' + + '\n' + + return [html, Buffer.byteLength(html)] +} +exports.createHtmlDocument = createHtmlDocument diff --git a/lib/createHttpError.js b/lib/createHttpError.js new file mode 100644 index 00000000..ba7bcca0 --- /dev/null +++ b/lib/createHttpError.js @@ -0,0 +1,23 @@ +'use strict' + +const createError = require('http-errors') + +/** + * Create a HttpError object from simple arguments. + * + * @param {number} status + * @param {Error|object} err + * @private + */ + +function createHttpError (status, err) { + if (!err) { + return createError(status) + } + + return err instanceof Error + ? createError(status, err, { expose: false }) + : createError(status, err) +} + +module.exports.createHttpError = createHttpError diff --git a/lib/isUtf8MimeType.js b/lib/isUtf8MimeType.js new file mode 100644 index 00000000..d24978a8 --- /dev/null +++ b/lib/isUtf8MimeType.js @@ -0,0 +1,12 @@ +'use strict' + +function isUtf8MimeType (value) { + const len = value.length + return ( + (len > 21 && value.indexOf('application/javascript') === 0) || + (len > 14 && value.indexOf('application/json') === 0) || + (len > 5 && value.indexOf('text/') === 0) + ) +} + +module.exports.isUtf8MimeType = isUtf8MimeType diff --git a/lib/normalizeList.js b/lib/normalizeList.js new file mode 100644 index 00000000..b18eac97 --- /dev/null +++ b/lib/normalizeList.js @@ -0,0 +1,28 @@ +'use strict' + +/** + * Normalize the index option into an array. + * + * @param {boolean|string|array} val + * @param {string} name + * @private + */ + +function normalizeList (val, name) { + if (typeof val === 'string') { + return [val] + } else if (val === false) { + return [] + } else if (Array.isArray(val)) { + for (let i = 0, il = val.length; i < il; ++i) { + if (typeof val[i] !== 'string') { + throw new TypeError(name + ' must be array of strings or false') + } + } + return val + } else { + throw new TypeError(name + ' must be array of strings or false') + } +} + +module.exports.normalizeList = normalizeList diff --git a/lib/parseBytesRange.js b/lib/parseBytesRange.js new file mode 100644 index 00000000..5235c3ed --- /dev/null +++ b/lib/parseBytesRange.js @@ -0,0 +1,133 @@ +'use strict' + +/*! + * Based on range-parser + * + * Copyright(c) 2012-2014 TJ Holowaychuk + * Copyright(c) 2015-2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/** + * Parse "Range" header `str` relative to the given file `size`. + * + * @param {Number} size + * @param {String} str + * @return {Array} + * @public + */ + +function parseBytesRange (size, str) { + // split the range string + const values = str.slice(str.indexOf('=') + 1) + const ranges = [] + + const len = values.length + let i = 0 + let il = 0 + let j = 0 + let start + let end + let commaIdx = values.indexOf(',') + let dashIdx = values.indexOf('-') + let prevIdx = -1 + + // parse all ranges + while (true) { + commaIdx === -1 && (commaIdx = len) + start = parseInt(values.slice(prevIdx + 1, dashIdx), 10) + end = parseInt(values.slice(dashIdx + 1, commaIdx), 10) + + // -nnn + // eslint-disable-next-line no-self-compare + if (start !== start) { // fast path of isNaN(number) + start = size - end + end = size - 1 + // nnn- + // eslint-disable-next-line no-self-compare + } else if (end !== end) { // fast path of isNaN(number) + end = size - 1 + // limit last-byte-pos to current length + } else if (end > size - 1) { + end = size - 1 + } + + // add range only on valid ranges + if ( + // eslint-disable-next-line no-self-compare + start === start && // fast path of isNaN(number) + // eslint-disable-next-line no-self-compare + end === end && // fast path of isNaN(number) + start > -1 && + start <= end + ) { + // add range + ranges.push({ + start, + end, + index: j++ + }) + } + + if (commaIdx === len) { + break + } + prevIdx = commaIdx++ + dashIdx = values.indexOf('-', commaIdx) + commaIdx = values.indexOf(',', commaIdx) + } + + // unsatisfiable + if ( + j < 2 + ) { + return ranges + } + + ranges.sort(sortByRangeStart) + + il = j + j = 0 + i = 1 + while (i < il) { + const range = ranges[i++] + const current = ranges[j] + + if (range.start > current.end + 1) { + // next range + ranges[++j] = range + } else if (range.end > current.end) { + // extend range + current.end = range.end + current.index > range.index && (current.index = range.index) + } + } + + // trim ordered array + ranges.length = j + 1 + + // generate combined range + ranges.sort(sortByRangeIndex) + + return ranges +} + +/** + * Sort function to sort ranges by index. + * @private + */ + +function sortByRangeIndex (a, b) { + return a.index - b.index +} + +/** + * Sort function to sort ranges by start position. + * @private + */ + +function sortByRangeStart (a, b) { + return a.start - b.start +} + +module.exports.parseBytesRange = parseBytesRange diff --git a/lib/parseTokenList.js b/lib/parseTokenList.js new file mode 100644 index 00000000..eb3e436b --- /dev/null +++ b/lib/parseTokenList.js @@ -0,0 +1,46 @@ +'use strict' + +/** + * Parse a HTTP token list. + * + * @param {string} str + * @private + */ + +const slice = String.prototype.slice + +function parseTokenList (str, cb) { + let end = 0 + let start = 0 + let result + + // gather tokens + for (let i = 0, len = str.length; i < len; i++) { + switch (str.charCodeAt(i)) { + case 0x20: /* */ + if (start === end) { + start = end = i + 1 + } + break + case 0x2c: /* , */ + if (start !== end) { + result = cb(slice.call(str, start, end)) + if (result !== undefined) { + return result + } + } + start = end = i + 1 + break + default: + end = i + 1 + break + } + } + + // final token + if (start !== end) { + return cb(slice.call(str, start, end)) + } +} + +module.exports.parseTokenList = parseTokenList diff --git a/lib/send.js b/lib/send.js new file mode 100644 index 00000000..a0f4a50c --- /dev/null +++ b/lib/send.js @@ -0,0 +1,729 @@ +'use strict' + +const fs = require('node:fs') +const path = require('node:path') +const stream = require('node:stream') +const debug = require('node:util').debuglog('send') + +const decode = require('fast-decode-uri-component') +const escapeHtml = require('escape-html') +const mime = require('mime') +const ms = require('@lukeed/ms') + +const { collapseLeadingSlashes } = require('./collapseLeadingSlashes') +const { containsDotFile } = require('../lib/containsDotFile') +const { contentRange } = require('../lib/contentRange') +const { createHtmlDocument } = require('../lib/createHtmlDocument') +const { isUtf8MimeType } = require('../lib/isUtf8MimeType') +const { normalizeList } = require('../lib/normalizeList') +const { parseBytesRange } = require('../lib/parseBytesRange') +const { parseTokenList } = require('./parseTokenList') +const { createHttpError } = require('./createHttpError') + +/** + * Path function references. + * @private + */ + +const extname = path.extname +const join = path.join +const normalize = path.normalize +const resolve = path.resolve +const sep = path.sep + +/** + * Stream function references. + * @private + */ +const Readable = stream.Readable + +/** + * Regular expression for identifying a bytes Range header. + * @private + */ + +const BYTES_RANGE_REGEXP = /^ *bytes=/ + +/** + * Maximum value allowed for the max age. + * @private + */ + +const MAX_MAXAGE = 60 * 60 * 24 * 365 * 1000 // 1 year + +/** + * Regular expression to match a path with a directory up component. + * @private + */ + +const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/ + +const ERROR_RESPONSES = { + 400: createHtmlDocument('Error', 'Bad Request'), + 403: createHtmlDocument('Error', 'Forbidden'), + 404: createHtmlDocument('Error', 'Not Found'), + 412: createHtmlDocument('Error', 'Precondition Failed'), + 416: createHtmlDocument('Error', 'Range Not Satisfiable'), + 500: createHtmlDocument('Error', 'Internal Server Error') +} + +const validDotFilesOptions = [ + 'allow', + 'ignore', + 'deny' +] + +function normalizeMaxAge (_maxage) { + let maxage + if (typeof _maxage === 'string') { + maxage = ms.parse(_maxage) + } else { + maxage = Number(_maxage) + } + + // eslint-disable-next-line no-self-compare + if (maxage !== maxage) { + // fast path of isNaN(number) + return 0 + } + + return Math.min(Math.max(0, maxage), MAX_MAXAGE) +} + +function normalizeOptions (options) { + options = options ?? {} + + const acceptRanges = options.acceptRanges !== undefined + ? Boolean(options.acceptRanges) + : true + + const cacheControl = options.cacheControl !== undefined + ? Boolean(options.cacheControl) + : true + + const contentType = options.contentType !== undefined + ? Boolean(options.contentType) + : true + + const etag = options.etag !== undefined + ? Boolean(options.etag) + : true + + const dotfiles = options.dotfiles !== undefined + ? validDotFilesOptions.indexOf(options.dotfiles) + : 1 // 'ignore' + if (dotfiles === -1) { + throw new TypeError('dotfiles option must be "allow", "deny", or "ignore"') + } + + const extensions = options.extensions !== undefined + ? normalizeList(options.extensions, 'extensions option') + : [] + + const immutable = options.immutable !== undefined + ? Boolean(options.immutable) + : false + + const index = options.index !== undefined + ? normalizeList(options.index, 'index option') + : ['index.html'] + + const lastModified = options.lastModified !== undefined + ? Boolean(options.lastModified) + : true + + const maxage = normalizeMaxAge(options.maxAge ?? options.maxage) + + const maxContentRangeChunkSize = options.maxContentRangeChunkSize !== undefined + ? Number(options.maxContentRangeChunkSize) + : null + + const root = options.root + ? resolve(options.root) + : null + + const highWaterMark = Number.isSafeInteger(options.highWaterMark) && options.highWaterMark > 0 + ? options.highWaterMark + : null + + return { + acceptRanges, + cacheControl, + contentType, + etag, + dotfiles, + extensions, + immutable, + index, + lastModified, + maxage, + maxContentRangeChunkSize, + root, + highWaterMark, + start: options.start, + end: options.end + } +} + +function normalizePath (_path, root) { + // decode the path + let path = decode(_path) + if (path == null) { + return { statusCode: 400 } + } + + // null byte(s) + if (~path.indexOf('\0')) { + return { statusCode: 400 } + } + + let parts + if (root !== null) { + // normalize + if (path) { + path = normalize('.' + sep + path) + } + + // malicious path + if (UP_PATH_REGEXP.test(path)) { + debug('malicious path "%s"', path) + return { statusCode: 403 } + } + + // explode path parts + parts = path.split(sep) + + // join / normalize from optional root dir + path = normalize(join(root, path)) + } else { + // ".." is malicious without "root" + if (UP_PATH_REGEXP.test(path)) { + debug('malicious path "%s"', path) + return { statusCode: 403 } + } + + // explode path parts + parts = normalize(path).split(sep) + + // resolve the path + path = resolve(path) + } + + return { path, parts } +} + +/** + * Check if the pathname ends with "/". + * + * @return {boolean} + * @private + */ + +function hasTrailingSlash (path) { + return path[path.length - 1] === '/' +} + +/** + * Check if this is a conditional GET request. + * + * @return {Boolean} + * @api private + */ + +function isConditionalGET (request) { + return request.headers['if-match'] || + request.headers['if-unmodified-since'] || + request.headers['if-none-match'] || + request.headers['if-modified-since'] +} + +function isNotModifiedFailure (request, headers) { + // Always return stale when Cache-Control: no-cache + // to support end-to-end reload requests + // https://tools.ietf.org/html/rfc2616#section-14.9.4 + if ( + 'cache-control' in request.headers && + request.headers['cache-control'].indexOf('no-cache') !== -1 + ) { + return false + } + + // if-none-match + if ('if-none-match' in request.headers) { + const ifNoneMatch = request.headers['if-none-match'] + + if (ifNoneMatch === '*') { + return true + } + + const etag = headers.ETag + + if (typeof etag !== 'string') { + return false + } + + const etagL = etag.length + const isMatching = parseTokenList(ifNoneMatch, function (match) { + const mL = match.length + + if ( + (etagL === mL && match === etag) || + (etagL > mL && 'W/' + match === etag) + ) { + return true + } + }) + + if (isMatching) { + return true + } + + /** + * A recipient MUST ignore If-Modified-Since if the request contains an + * If-None-Match header field; the condition in If-None-Match is considered + * to be a more accurate replacement for the condition in If-Modified-Since, + * and the two are only combined for the sake of interoperating with older + * intermediaries that might not implement If-None-Match. + * + * @see RFC 9110 section 13.1.3 + */ + return false + } + + // if-modified-since + if ('if-modified-since' in request.headers) { + const ifModifiedSince = request.headers['if-modified-since'] + const lastModified = headers['Last-Modified'] + + if (!lastModified || (Date.parse(lastModified) <= Date.parse(ifModifiedSince))) { + return true + } + } + + return false +} + +/** + * Check if the request preconditions failed. + * + * @return {boolean} + * @private + */ + +function isPreconditionFailure (request, headers) { + // if-match + const ifMatch = request.headers['if-match'] + if (ifMatch) { + const etag = headers.ETag + + if (ifMatch !== '*') { + const isMatching = parseTokenList(ifMatch, function (match) { + if ( + match === etag || + 'W/' + match === etag + ) { + return true + } + }) || false + + if (isMatching !== true) { + return true + } + } + } + + // if-unmodified-since + if ('if-unmodified-since' in request.headers) { + const ifUnmodifiedSince = request.headers['if-unmodified-since'] + const unmodifiedSince = Date.parse(ifUnmodifiedSince) + // eslint-disable-next-line no-self-compare + if (unmodifiedSince === unmodifiedSince) { // fast path of isNaN(number) + const lastModified = Date.parse(headers['Last-Modified']) + if ( + // eslint-disable-next-line no-self-compare + lastModified !== lastModified ||// fast path of isNaN(number) + lastModified > unmodifiedSince + ) { + return true + } + } + } + + return false +} + +/** + * Check if the range is fresh. + * + * @return {Boolean} + * @api private + */ + +function isRangeFresh (request, headers) { + if (!('if-range' in request.headers)) { + return true + } + + const ifRange = request.headers['if-range'] + + // if-range as etag + if (ifRange.indexOf('"') !== -1) { + const etag = headers.ETag + return (etag && ifRange.indexOf(etag) !== -1) || false + } + + const ifRangeTimestamp = Date.parse(ifRange) + // eslint-disable-next-line no-self-compare + if (ifRangeTimestamp !== ifRangeTimestamp) { // fast path of isNaN(number) + return false + } + + // if-range as modified date + const lastModified = Date.parse(headers['Last-Modified']) + + return ( + // eslint-disable-next-line no-self-compare + lastModified !== lastModified || // fast path of isNaN(number) + lastModified <= ifRangeTimestamp + ) +} + +// we provide stat function that will always resolve +// without throwing +function tryStat (path) { + return new Promise((resolve) => { + fs.stat(path, function onstat (error, stat) { + resolve({ error, stat }) + }) + }) +} + +function sendError (statusCode, err) { + const headers = {} + + // add error headers + if (err && err.headers) { + for (const headerName in err.headers) { + headers[headerName] = err.headers[headerName] + } + } + + const doc = ERROR_RESPONSES[statusCode] + + // basic response + headers['Content-Type'] = 'text/html; charset=utf-8' + headers['Content-Length'] = doc[1] + headers['Content-Security-Policy'] = "default-src 'none'" + headers['X-Content-Type-Options'] = 'nosniff' + + return { + statusCode, + headers, + stream: Readable.from(doc[0]), + // metadata + type: 'error', + metadata: { error: createHttpError(statusCode, err) } + } +} + +function sendStatError (err) { + // POSIX throws ENAMETOOLONG and ENOTDIR, Windows only ENOENT + /* c8 ignore start */ + switch (err.code) { + case 'ENAMETOOLONG': + case 'ENOTDIR': + case 'ENOENT': + return sendError(404, err) + default: + return sendError(500, err) + } + /* c8 ignore stop */ +} + +/** + * Respond with 304 not modified. + * + * @api private + */ + +function sendNotModified (headers, path, stat) { + debug('not modified') + + delete headers['Content-Encoding'] + delete headers['Content-Language'] + delete headers['Content-Length'] + delete headers['Content-Range'] + delete headers['Content-Type'] + + return { + statusCode: 304, + headers, + stream: Readable.from(''), + // metadata + type: 'file', + metadata: { path, stat } + } +} + +function sendFileDirectly (request, path, stat, options) { + let len = stat.size + let offset = options.start ?? 0 + + let statusCode = 200 + const headers = {} + + debug('send "%s"', path) + + // set header fields + if (options.acceptRanges) { + debug('accept ranges') + headers['Accept-Ranges'] = 'bytes' + } + + if (options.cacheControl) { + let cacheControl = 'public, max-age=' + Math.floor(options.maxage / 1000) + + if (options.immutable) { + cacheControl += ', immutable' + } + + debug('cache-control %s', cacheControl) + headers['Cache-Control'] = cacheControl + } + + if (options.lastModified) { + const modified = stat.mtime.toUTCString() + debug('modified %s', modified) + headers['Last-Modified'] = modified + } + + if (options.etag) { + const etag = 'W/"' + stat.size.toString(16) + '-' + stat.mtime.getTime().toString(16) + '"' + debug('etag %s', etag) + headers.ETag = etag + } + + // set content-type + if (options.contentType) { + let type = mime.getType(path) || mime.default_type + debug('content-type %s', type) + if (type && isUtf8MimeType(type)) { + type += '; charset=utf-8' + } + if (type) { + headers['Content-Type'] = type + } + } + + // conditional GET support + if (isConditionalGET(request)) { + if (isPreconditionFailure(request, headers)) { + return sendError(412) + } + + if (isNotModifiedFailure(request, headers)) { + return sendNotModified(headers, path, stat) + } + } + + // adjust len to start/end options + len = Math.max(0, len - offset) + if (options.end !== undefined) { + const bytes = options.end - offset + 1 + if (len > bytes) len = bytes + } + + // Range support + if (options.acceptRanges) { + const rangeHeader = request.headers.range + + if ( + rangeHeader !== undefined && + BYTES_RANGE_REGEXP.test(rangeHeader) + ) { + // If-Range support + if (isRangeFresh(request, headers)) { + // parse + const ranges = parseBytesRange(len, rangeHeader) + + // unsatisfiable + if (ranges.length === 0) { + debug('range unsatisfiable') + + // Content-Range + headers['Content-Range'] = contentRange('bytes', len) + + // 416 Requested Range Not Satisfiable + return sendError(416, { + headers: { 'Content-Range': headers['Content-Range'] } + }) + // valid (syntactically invalid/multiple ranges are treated as a regular response) + } else if (ranges.length === 1) { + debug('range %j', ranges) + + // Content-Range + statusCode = 206 + if (options.maxContentRangeChunkSize) { + ranges[0].end = Math.min(ranges[0].end, ranges[0].start + options.maxContentRangeChunkSize - 1) + } + headers['Content-Range'] = contentRange('bytes', len, ranges[0]) + + // adjust for requested range + offset += ranges[0].start + len = ranges[0].end - ranges[0].start + 1 + } + } else { + debug('range stale') + } + } + } + + // content-length + headers['Content-Length'] = len + + // HEAD support + if (request.method === 'HEAD') { + return { + statusCode, + headers, + stream: Readable.from(''), + // metadata + type: 'file', + metadata: { path, stat } + } + } + + const stream = fs.createReadStream(path, { + highWaterMark: options.highWaterMark, + start: offset, + end: Math.max(offset, offset + len - 1) + }) + + return { + statusCode, + headers, + stream, + // metadata + type: 'file', + metadata: { path, stat } + } +} + +function sendRedirect (path, options) { + if (hasTrailingSlash(options.path)) { + return sendError(403) + } + + const loc = encodeURI(collapseLeadingSlashes(options.path + '/')) + const doc = createHtmlDocument('Redirecting', 'Redirecting to ' + escapeHtml(loc)) + + const headers = {} + headers['Content-Type'] = 'text/html; charset=utf-8' + headers['Content-Length'] = doc[1] + headers['Content-Security-Policy'] = "default-src 'none'" + headers['X-Content-Type-Options'] = 'nosniff' + headers.Location = loc + + return { + statusCode: 301, + headers, + stream: Readable.from(doc[0]), + // metadata + type: 'directory', + metadata: { requestPath: options.path, path } + } +} + +async function sendIndex (request, path, options) { + let err + for (let i = 0; i < options.index.length; i++) { + const index = options.index[i] + const p = join(path, index) + const { error, stat } = await tryStat(p) + if (error) { + err = error + continue + } + if (stat.isDirectory()) continue + return sendFileDirectly(request, p, stat, options) + } + + if (err) { + return sendStatError(err) + } + + return sendError(404) +} + +async function sendFile (request, path, options) { + const { error, stat } = await tryStat(path) + if (error && error.code === 'ENOENT' && !extname(path) && path[path.length - 1] !== sep) { + let err = error + // not found, check extensions + for (let i = 0; i < options.extensions.length; i++) { + const extension = options.extensions[i] + const p = path + '.' + extension + const { error, stat } = await tryStat(p) + if (error) { + err = error + continue + } + if (stat.isDirectory()) { + err = null + continue + } + return sendFileDirectly(request, p, stat, options) + } + if (err) { + return sendStatError(err) + } + return sendError(404) + } + if (error) return sendStatError(error) + if (stat.isDirectory()) return sendRedirect(path, options) + return sendFileDirectly(request, path, stat, options) +} + +async function send (request, _path, options) { + const opts = normalizeOptions(options) + opts.path = _path + + const parsed = normalizePath(_path, opts.root) + const { path, parts } = parsed + if (parsed.statusCode !== undefined) { + return sendError(parsed.statusCode) + } + + // dotfile handling + if ( + ( + debug.enabled || // if debugging is enabled, then check for all cases to log allow case + opts.dotfiles !== 0 // if debugging is not enabled, then only check if 'deny' or 'ignore' is set + ) && + containsDotFile(parts) + ) { + switch (opts.dotfiles) { + /* c8 ignore start */ /* unreachable, because NODE_DEBUG can not be set after process is running */ + case 0: // 'allow' + debug('allow dotfile "%s"', path) + break + /* c8 ignore stop */ + case 2: // 'deny' + debug('deny dotfile "%s"', path) + return sendError(403) + case 1: // 'ignore' + default: + debug('ignore dotfile "%s"', path) + return sendError(404) + } + } + + // index file support + if (opts.index.length && hasTrailingSlash(_path)) { + return sendIndex(request, path, opts) + } + + return sendFile(request, path, opts) +} + +module.exports.send = send diff --git a/package.json b/package.json index e9d3cc29..2da6c9db 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,39 @@ "contributors": [ "Douglas Christopher Wilson
tobi
') + }) + + await t.test('should 404 if nothing found', async function (t) { + await request(createServer({ extensions: ['htm', 'html', 'txt'], root: fixtures })) + .get('/bob') + .expect(404) + }) + + await t.test('should skip directories', async function (t) { + await request(createServer({ extensions: ['file', 'dir'], root: fixtures })) + .get('/name') + .expect(404) + }) + + await t.test('should not search if file has extension', async function (t) { + await request(createServer({ extensions: 'html', root: fixtures })) + .get('/thing.html') + .expect(404) + }) + }) + + await t.test('lastModified', async function (t) { + t.plan(1) + + await t.test('should support disabling last-modified', async function (t) { + t.plan(1) + + await request(createServer({ lastModified: false, root: fixtures })) + .get('/name.txt') + .expect(shouldNotHaveHeader('Last-Modified', t)) + .expect(200) + }) + }) + + await t.test('dotfiles', async function (t) { + t.plan(5) + + await t.test('should default to "ignore"', async function (t) { + await request(createServer({ root: fixtures })) + .get('/.hidden.txt') + .expect(404) + }) + + await t.test('should reject bad value', async function (t) { + await request(createServer({ dotfiles: 'bogus' })) + .get('/name.txt') + .expect(500, /dotfiles/) + }) + + await t.test('when "allow"', async function (t) { + t.plan(3) + + await t.test('should send dotfile', async function (t) { + await request(createServer({ dotfiles: 'allow', root: fixtures })) + .get('/.hidden.txt') + .expect(200, 'secret') + }) + + await t.test('should send within dotfile directory', async function (t) { + await request(createServer({ dotfiles: 'allow', root: fixtures })) + .get('/.mine/name.txt') + .expect(200, /tobi/) + }) + + await t.test('should 404 for non-existent dotfile', async function (t) { + await request(createServer({ dotfiles: 'allow', root: fixtures })) + .get('/.nothere') + .expect(404) + }) + }) + + await t.test('when "deny"', async function (t) { + t.plan(10) + + await t.test('should 403 for dotfile', async function (t) { + await request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.hidden.txt') + .expect(403) + }) + + await t.test('should 403 for dotfile directory', async function (t) { + await request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.mine') + .expect(403) + }) + + await t.test('should 403 for dotfile directory with trailing slash', async function (t) { + await request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.mine/') + .expect(403) + }) + + await t.test('should 403 for file within dotfile directory', async function (t) { + await request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.mine/name.txt') + .expect(403) + }) + + await t.test('should 403 for non-existent dotfile', async function (t) { + await request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.nothere') + .expect(403) + }) + + await t.test('should 403 for non-existent dotfile directory', async function (t) { + await request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.what/name.txt') + .expect(403) + }) + + await t.test('should 403 for dotfile in directory', async function (t) { + await request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/pets/.hidden.txt') + .expect(403) + }) + + await t.test('should 403 for dotfile in dotfile directory', async function (t) { + await request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.mine/.hidden.txt') + .expect(403) + }) + + await t.test('should send files in root dotfile directory', async function (t) { + await request(createServer({ dotfiles: 'deny', root: path.join(fixtures, '.mine') })) + .get('/name.txt') + .expect(200, /tobi/) + }) + + await t.test('should 403 for dotfile without root', async function (t) { + const server = http.createServer(async function onRequest (req, res) { + const { statusCode, headers, stream } = await send(req, fixtures + '/.mine' + req.url, { dotfiles: 'deny' }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(server) + .get('/name.txt') + .expect(403) + }) + }) + + await t.test('when "ignore"', async function (t) { + t.plan(8) + + await t.test('should 404 for dotfile', async function (t) { + await request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.hidden.txt') + .expect(404) + }) + + await t.test('should 404 for dotfile directory', async function (t) { + await request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.mine') + .expect(404) + }) + + await t.test('should 404 for dotfile directory with trailing slash', async function (t) { + await request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.mine/') + .expect(404) + }) + + await t.test('should 404 for file within dotfile directory', async function (t) { + await request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.mine/name.txt') + .expect(404) + }) + + await t.test('should 404 for non-existent dotfile', async function (t) { + await request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.nothere') + .expect(404) + }) + + await t.test('should 404 for non-existent dotfile directory', async function (t) { + await request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.what/name.txt') + .expect(404) + }) + + await t.test('should send files in root dotfile directory', async function (t) { + await request(createServer({ dotfiles: 'ignore', root: path.join(fixtures, '.mine') })) + .get('/name.txt') + .expect(200, /tobi/) + }) + + await t.test('should 404 for dotfile without root', async function (t) { + const server = http.createServer(async function onRequest (req, res) { + const { statusCode, headers, stream } = await send(req, fixtures + '/.mine' + req.url, { dotfiles: 'ignore' }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(server) + .get('/name.txt') + .expect(404) + }) + }) + }) + + await t.test('immutable', async function (t) { + t.plan(2) + + await t.test('should default to false', async function (t) { + await request(createServer({ root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=0') + }) + + await t.test('should set immutable directive in Cache-Control', async function (t) { + await request(createServer({ immutable: true, maxAge: '1h', root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=3600, immutable') + }) + }) + + await t.test('maxAge', async function (t) { + t.plan(4) + + await t.test('should default to 0', async function (t) { + await request(createServer({ root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=0') + }) + + await t.test('should floor to integer', async function (t) { + await request(createServer({ maxAge: 123956, root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=123') + }) + + await t.test('should accept string', async function (t) { + await request(createServer({ maxAge: '30d', root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=2592000') + }) + + await t.test('should max at 1 year', async function (t) { + await request(createServer({ maxAge: '2y', root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=31536000') + }) + }) + + await t.test('index', async function (t) { + t.plan(10) + + await t.test('should reject numbers', async function (t) { + await request(createServer({ root: fixtures, index: 42 })) + .get('/pets/') + .expect(500, /TypeError: index option/) + }) + + await t.test('should reject true', async function (t) { + await request(createServer({ root: fixtures, index: true })) + .get('/pets/') + .expect(500, /TypeError: index option/) + }) + + await t.test('should default to index.html', async function (t) { + await request(createServer({ root: fixtures })) + .get('/pets/') + .expect(fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8')) + }) + + await t.test('should be configurable', async function (t) { + await request(createServer({ root: fixtures, index: 'tobi.html' })) + .get('/') + .expect(200, 'tobi
') + }) + + await t.test('should support disabling', async function (t) { + await request(createServer({ root: fixtures, index: false })) + .get('/pets/') + .expect(403) + }) + + await t.test('should support fallbacks', async function (t) { + await request(createServer({ root: fixtures, index: ['default.htm', 'index.html'] })) + .get('/pets/') + .expect(200, fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8')) + }) + + await t.test('should 404 if no index file found (file)', async function (t) { + await request(createServer({ root: fixtures, index: 'default.htm' })) + .get('/pets/') + .expect(404) + }) + + await t.test('should 404 if no index file found (dir)', async function (t) { + await request(createServer({ root: fixtures, index: 'pets' })) + .get('/') + .expect(404) + }) + + await t.test('should not follow directories', async function (t) { + await request(createServer({ root: fixtures, index: ['pets', 'name.txt'] })) + .get('/') + .expect(200, 'tobi') + }) + + await t.test('should work without root', async function (t) { + const server = http.createServer(async function (req, res) { + const p = path.join(fixtures, 'pets').replace(/\\/g, '/') + '/' + const { statusCode, headers, stream } = await send(req, p, { index: ['index.html'] }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(server) + .get('/') + .expect(200, /tobi/) + }) + }) + + await t.test('root', async function (t) { + t.plan(2) + + await t.test('when given', async function (t) { + t.plan(8) + + await t.test('should join root', async function (t) { + await request(createServer({ root: fixtures })) + .get('/pets/../name.txt') + .expect(200, 'tobi') + }) + + await t.test('should work with trailing slash', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures + '/' }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/name.txt') + .expect(200, 'tobi') + }) + + await t.test('should work with empty path', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, '', { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/name.txt') + .expect(301, /Redirecting to/) + }) + + // + // NOTE: This is not a real part of the API, but + // over time this has become something users + // are doing, so this will prevent unseen + // regressions around this use-case. + // + await t.test('should try as file with empty path', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, '', { root: path.join(fixtures, 'name.txt') }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/') + .expect(200, 'tobi') + }) + + await t.test('should restrict paths to within root', async function (t) { + await request(createServer({ root: fixtures })) + .get('/pets/../../send.js') + .expect(403) + }) + + await t.test('should allow .. in root', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures + '/../fixtures' }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/pets/../../send.js') + .expect(403) + }) + + await t.test('should not allow root transversal', async function (t) { + await request(createServer({ root: path.join(fixtures, 'name.d') })) + .get('/../name.dir/name.txt') + .expect(403) + }) + + await t.test('should not allow root path disclosure', async function (t) { + await request(createServer({ root: fixtures })) + .get('/pets/../../fixtures/name.txt') + .expect(403) + }) + }) + + await t.test('when missing', async function (t) { + t.plan(2) + + await t.test('should consider .. malicious', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, fixtures + req.url) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/../send.js') + .expect(403) + }) + + await t.test('should still serve files with dots in name', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, fixtures + req.url) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/do..ts.txt') + .expect(200, '...') + }) + }) + }) + + await t.test('highWaterMark', async function (t) { + t.plan(3) + + await t.test('should support highWaterMark', async function (t) { + t.plan(1) + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { highWaterMark: 512 * 1024, root: fixtures + '/' }) + res.writeHead(statusCode, headers) + t.assert.deepStrictEqual(stream.readableHighWaterMark, 524288) + stream.pipe(res) + }) + await request(app) + .get('/name.txt') + .expect(200, 'tobi') + }) + + await t.test('should use default value', async function (t) { + t.plan(1) + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures + '/' }) + res.writeHead(statusCode, headers) + t.assert.deepStrictEqual(stream.readableHighWaterMark, getDefaultHighWaterMark(false)) + stream.pipe(res) + }) + await request(app) + .get('/name.txt') + .expect(200, 'tobi') + }) + + await t.test('should ignore negative number', async function (t) { + t.plan(1) + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { highWaterMark: -54, root: fixtures + '/' }) + res.writeHead(statusCode, headers) + t.assert.deepStrictEqual(stream.readableHighWaterMark, getDefaultHighWaterMark(false)) + stream.pipe(res) + }) + await request(app) + .get('/name.txt') + .expect(200, 'tobi') + }) + }) +}) diff --git a/test/send.2.test.js b/test/send.2.test.js new file mode 100644 index 00000000..957d6f51 --- /dev/null +++ b/test/send.2.test.js @@ -0,0 +1,977 @@ +'use strict' + +const { test } = require('node:test') +const http = require('node:http') +const path = require('node:path') +const request = require('supertest') +const send = require('../lib/send').send +const { shouldNotHaveBody, createServer, shouldNotHaveHeader } = require('./utils') + +const dateRegExp = /^\w{3}, \d+ \w+ \d+ \d+:\d+:\d+ \w+$/ +const fixtures = path.join(__dirname, 'fixtures') + +test('send(file)', async function (t) { + t.plan(22) + + await t.test('should stream the file contents', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/name.txt') + .expect('Content-Length', '4') + .expect(200, 'tobi') + }) + + await t.test('should stream a zero-length file', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/empty.txt') + .expect('Content-Length', '0') + .expect(200, '') + }) + + await t.test('should decode the given path as a URI', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/some%20thing.txt') + .expect(200, 'hey') + }) + + await t.test('should serve files with dots in name', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/do..ts.txt') + .expect(200, '...') + }) + + await t.test('should treat a malformed URI as a bad request', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/some%99thing.txt') + .expect(400, /Bad Request/) + }) + + await t.test('should 400 on NULL bytes', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/some%00thing.txt') + .expect(400, /Bad Request/) + }) + + await t.test('should treat an ENAMETOOLONG as a 404', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const path = Array(100).join('foobar') + await request(app) + .get('/' + path) + .expect(404) + }) + + await t.test('should support HEAD', async function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .head('/name.txt') + .expect(200) + .expect('Content-Length', '4') + .expect(shouldNotHaveBody(t)) + }) + + await t.test('should add an ETag header field', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/name.txt') + .expect('etag', /^W\/"[^"]+"$/) + }) + + await t.test('should add a Date header field', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/name.txt') + .expect('date', dateRegExp) + }) + + await t.test('should add a Last-Modified header field', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/name.txt') + .expect('last-modified', dateRegExp) + }) + + await t.test('should add a Accept-Ranges header field', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/name.txt') + .expect('Accept-Ranges', 'bytes') + }) + + await t.test('should 404 if the file does not exist', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/meow') + .expect(404, /Not Found/) + }) + + await t.test('should 404 if the filename is too long', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const longFilename = new Array(512).fill('a').join('') + + await request(app) + .get('/' + longFilename) + .expect(404, /Not Found/) + }) + + await t.test('should 404 if the requested resource is not a directory', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/nums.txt/invalid') + .expect(404, /Not Found/) + }) + + await t.test('should not override content-type', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, { + ...headers, + 'Content-Type': 'application/x-custom' + }) + stream.pipe(res) + }) + await request(app) + .get('/name.txt') + .expect('Content-Type', 'application/x-custom') + }) + + await t.test('should set Content-Type via mime map', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/name.txt') + .expect('Content-Type', 'text/plain; charset=utf-8') + .expect(200) + + await request(app) + .get('/tobi.html') + .expect('Content-Type', 'text/html; charset=utf-8') + .expect(200) + }) + + await t.test('send directory', async function (t) { + t.plan(5) + + await t.test('should redirect directories to trailing slash', async function (t) { + await request(createServer({ root: fixtures })) + .get('/pets') + .expect('Location', '/pets/') + .expect(301) + }) + + await t.test('should respond with an HTML redirect', async function (t) { + await request(createServer({ root: fixtures })) + .get('/pets') + .expect('Location', '/pets/') + .expect('Content-Type', /html/) + .expect(301, />Redirecting to \/pets\/) + }) + + await t.test('should respond with default Content-Security-Policy', async function (t) { + await request(createServer({ root: fixtures })) + .get('/pets') + .expect('Location', '/pets/') + .expect('Content-Security-Policy', "default-src 'none'") + .expect(301) + }) + + await t.test('should not redirect to protocol-relative locations', async function (t) { + await request(createServer({ root: fixtures })) + .get('//pets') + .expect('Location', '/pets/') + .expect(301) + }) + + await t.test('should respond with an HTML redirect', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url.replace('/snow', '/snow ☃'), { root: 'test/fixtures' }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/snow') + .expect('Location', '/snow%20%E2%98%83/') + .expect('Content-Type', /html/) + .expect(301, />Redirecting to \/snow%20%E2%98%83\/) + }) + }) + + await t.test('send error', async function (t) { + t.plan(2) + + await t.test('should respond to errors directly', async function (t) { + await request(createServer({ root: fixtures })) + .get('/foobar') + .expect(404, />Not Found) + }) + + await t.test('should respond with default Content-Security-Policy', async function (t) { + await request(createServer({ root: fixtures })) + .get('/foobar') + .expect('Content-Security-Policy', "default-src 'none'") + .expect(404) + }) + }) + + await t.test('with conditional-GET', async function (t) { + t.plan(6) + + await t.test('should remove Content headers with 304', async function (t) { + const server = createServer({ root: fixtures }, function (_req, res) { + res.setHeader('Content-Language', 'en-US') + res.setHeader('Content-Location', 'http://localhost/name.txt') + res.setHeader('Contents', 'foo') + }) + + const res = await request(server) + .get('/name.txt') + .expect(200) + + await request(server) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .expect('Content-Location', 'http://localhost/name.txt') + .expect('Contents', 'foo') + .expect(304) + }) + + await t.test('should not remove all Content-* headers', async function (t) { + const server = createServer({ root: fixtures }, function (_req, res) { + res.setHeader('Content-Location', 'http://localhost/name.txt') + res.setHeader('Content-Security-Policy', 'default-src \'self\'') + }) + + const res = await request(server) + .get('/name.txt') + .expect(200) + + await request(server) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .expect('Content-Location', 'http://localhost/name.txt') + .expect('Content-Security-Policy', 'default-src \'self\'') + .expect(304) + }) + + await t.test('where "If-Match" is set', async function (t) { + t.plan(4) + + await t.test('should respond with 200 when "*"', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/name.txt') + .set('If-Match', '*') + .expect(200) + }) + + await t.test('should respond with 412 when ETag unmatched', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/name.txt') + .set('If-Match', ' "foo",, "bar" ,') + .expect(412) + }) + + await t.test('should respond with 200 when ETag matched /1', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const res = await request(app) + .get('/name.txt') + .expect(200) + + await request(app) + .get('/name.txt') + .set('If-Match', '"foo", "bar", ' + res.headers.etag) + .expect(200) + }) + + await t.test('should respond with 200 when ETag matched /2', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const res = await request(app) + .get('/name.txt') + .expect(200) + + await request(app) + .get('/name.txt') + .set('If-Match', '"foo", ' + res.headers.etag + ', "bar"') + .expect(200) + }) + }) + + await t.test('where "If-Modified-Since" is set', async function (t) { + t.plan(3) + + await t.test('should respond with 304 when unmodified', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const res = await request(app) + .get('/name.txt') + .expect(200) + + await request(app) + .get('/name.txt') + .set('If-Modified-Since', res.headers['last-modified']) + .expect(304) + }) + + await t.test('should respond with 200 when modified', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const res = await request(app) + .get('/name.txt') + .expect(200) + + const lmod = new Date(res.headers['last-modified']) + const date = new Date(lmod - 60000) + await request(app) + .get('/name.txt') + .set('If-Modified-Since', date.toUTCString()) + .expect(200, 'tobi') + }) + + await t.test('should respond with 200 when modified', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const res = await request(app) + .get('/name.txt') + .expect(200) + + await request(app) + .get('/name.txt') + .set('If-Modified-Since', res.headers['last-modified']) + .set('cache-control', 'no-cache') + .expect(200, 'tobi') + }) + }) + + await t.test('where "If-None-Match" is set', async function (t) { + t.plan(6) + + await t.test('should respond with 304 when ETag matched', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const res = await request(app) + .get('/name.txt') + .expect(200) + + await request(app) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .expect(304) + }) + + await t.test('should respond with 200 when ETag unmatched', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/name.txt') + .expect(200) + + await request(app) + .get('/name.txt') + .set('If-None-Match', '"123"') + .expect(200, 'tobi') + }) + + await t.test('should respond with 200 when ETag is not generated', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { etag: false, root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/name.txt') + .expect(200) + + await request(app) + .get('/name.txt') + .set('If-None-Match', '"123"') + .expect(200, 'tobi') + }) + + await t.test('should respond with 306 Not Modified when using wildcard * on existing file', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { etag: false, root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/name.txt') + .expect(200) + + await request(app) + .get('/name.txt') + .set('If-None-Match', '*') + .expect(304, '') + }) + + await t.test('should respond with 404 Not Found when using wildcard * on non-existing file', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { etag: false, root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/asdf.txt') + .set('If-None-Match', '*') + .expect(404, /Not Found/) + }) + + await t.test('should respond with 200 cache-control is set to no-cache', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const res = await request(app) + .get('/name.txt') + .expect(200) + + await request(app) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .set('cache-control', 'no-cache') + .expect(200, 'tobi') + }) + }) + + await t.test('where "If-Unmodified-Since" is set', async function (t) { + t.plan(3) + + await t.test('should respond with 200 when unmodified', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const res = await request(app) + .get('/name.txt') + .expect(200) + + await request(app) + .get('/name.txt') + .set('If-Unmodified-Since', res.headers['last-modified']) + .expect(200) + }) + + await t.test('should respond with 412 when modified', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const res = await request(app) + .get('/name.txt') + .expect(200) + + const lmod = new Date(res.headers['last-modified']) + const date = new Date(lmod - 60000).toUTCString() + await request(app) + .get('/name.txt') + .set('If-Unmodified-Since', date) + .expect(412) + }) + + await t.test('should respond with 200 when invalid date', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/name.txt') + .set('If-Unmodified-Since', 'foo') + .expect(200) + }) + }) + }) + + await t.test('with Range request', async function (t) { + t.plan(13) + + await t.test('should support byte ranges', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/nums.txt') + .set('Range', 'bytes=0-4') + .expect(206, '12345') + }) + + await t.test('should ignore non-byte ranges', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/nums.txt') + .set('Range', 'items=0-4') + .expect(200, '123456789') + }) + + await t.test('should be inclusive', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/nums.txt') + .set('Range', 'bytes=0-0') + .expect(206, '1') + }) + + await t.test('should set Content-Range', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-5') + .expect('Content-Range', 'bytes 2-5/9') + .expect(206) + }) + + await t.test('should support -n', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/nums.txt') + .set('Range', 'bytes=-3') + .expect(206, '789') + }) + + await t.test('should support n-', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/nums.txt') + .set('Range', 'bytes=3-') + .expect(206, '456789') + }) + + await t.test('should respond with 206 "Partial Content"', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/nums.txt') + .set('Range', 'bytes=0-4') + .expect(206) + }) + + await t.test('should set Content-Length to the # of octets transferred', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-3') + .expect('Content-Length', '2') + .expect(206, '34') + }) + + await t.test('when last-byte-pos of the range is greater the length', async function (t) { + t.plan(2) + + await t.test('is taken to be equal to one less than the length', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-50') + .expect('Content-Range', 'bytes 2-8/9') + .expect(206) + }) + + await t.test('should adapt the Content-Length accordingly', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-50') + .expect('Content-Length', '7') + .expect(206) + }) + }) + + await t.test('when the first- byte-pos of the range is greater length', async function (t) { + t.plan(2) + + await t.test('should respond with 416', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/nums.txt') + .set('Range', 'bytes=9-50') + .expect('Content-Range', 'bytes */9') + .expect(416) + }) + + await t.test('should emit error 416 with content-range header', async function (t) { + const server = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, { + ...headers, + 'X-Content-Range': headers['Content-Range'] + }) + stream.pipe(res) + }) + + await request(server) + .get('/nums.txt') + .set('Range', 'bytes=9-50') + .expect('X-Content-Range', 'bytes */9') + .expect(416) + }) + }) + + await t.test('when syntactically invalid', async function (t) { + t.plan(1) + + await t.test('should respond with 200 and the entire contents', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/nums.txt') + .set('Range', 'asdf') + .expect(200, '123456789') + }) + }) + + await t.test('when multiple ranges', async function (t) { + t.plan(2) + + await t.test('should respond with 200 and the entire contents', async function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/nums.txt') + .set('Range', 'bytes=1-1,3-') + .expect(shouldNotHaveHeader('Content-Range', t)) + .expect(200, '123456789') + }) + + await t.test('should respond with 206 is all ranges can be combined', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/nums.txt') + .set('Range', 'bytes=1-2,3-5') + .expect('Content-Range', 'bytes 1-5/9') + .expect(206, '23456') + }) + }) + + await t.test('when if-range present', async function (t) { + t.plan(5) + + await t.test('should respond with parts when etag unchanged', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const res = await request(app) + .get('/nums.txt') + .expect(200) + + const etag = res.headers.etag + + await request(app) + .get('/nums.txt') + .set('If-Range', etag) + .set('Range', 'bytes=0-0') + .expect(206, '1') + }) + + await t.test('should respond with 200 when etag changed', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const res = await request(app) + .get('/nums.txt') + .expect(200) + + const etag = res.headers.etag.replace(/"(.)/, '"0$1') + + await request(app) + .get('/nums.txt') + .set('If-Range', etag) + .set('Range', 'bytes=0-0') + .expect(200, '123456789') + }) + + await t.test('should respond with parts when modified unchanged', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const res = await request(app) + .get('/nums.txt') + .expect(200) + + const modified = res.headers['last-modified'] + + await request(app) + .get('/nums.txt') + .set('If-Range', modified) + .set('Range', 'bytes=0-0') + .expect(206, '1') + }) + + await t.test('should respond with 200 when modified changed', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const res = await request(app) + .get('/nums.txt') + .expect(200) + + const modified = Date.parse(res.headers['last-modified']) - 20000 + + await request(app) + .get('/nums.txt') + .set('If-Range', new Date(modified).toUTCString()) + .set('Range', 'bytes=0-0') + .expect(200, '123456789') + }) + + await t.test('should respond with 200 when invalid value', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/nums.txt') + .set('If-Range', 'foo') + .set('Range', 'bytes=0-0') + .expect(200, '123456789') + }) + }) + }) + + await t.test('when "options" is specified', async function (t) { + t.plan(4) + + await t.test('should support start/end', async function (t) { + await request(createServer({ root: fixtures, start: 3, end: 5 })) + .get('/nums.txt') + .expect(200, '456') + }) + + await t.test('should adjust too large end', async function (t) { + await request(createServer({ root: fixtures, start: 3, end: 90 })) + .get('/nums.txt') + .expect(200, '456789') + }) + + await t.test('should support start/end with Range request', async function (t) { + await request(createServer({ root: fixtures, start: 0, end: 2 })) + .get('/nums.txt') + .set('Range', 'bytes=-2') + .expect(206, '23') + }) + + await t.test('should support start/end with unsatisfiable Range request', async function (t) { + await request(createServer({ root: fixtures, start: 0, end: 2 })) + .get('/nums.txt') + .set('Range', 'bytes=5-9') + .expect('Content-Range', 'bytes */3') + .expect(416) + }) + }) +}) diff --git a/test/send.3.test.js b/test/send.3.test.js new file mode 100644 index 00000000..f6f0911f --- /dev/null +++ b/test/send.3.test.js @@ -0,0 +1,133 @@ +'use strict' + +const { test } = require('node:test') +const http = require('node:http') +const path = require('node:path') +const request = require('supertest') +const { readdir } = require('node:fs/promises') +const send = require('../lib/send').send + +const fixtures = path.join(__dirname, 'fixtures') + +test('send(file)', async function (t) { + t.plan(5) + + await t.test('file type', async function (t) { + t.plan(5) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url, { root: fixtures }) + t.assert.deepStrictEqual(type, 'file') + t.assert.ok(metadata.path) + t.assert.ok(metadata.stat) + t.assert.ok(!metadata.error) + t.assert.ok(!metadata.requestPath) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/name.txt') + .expect('Content-Length', '4') + .expect(200, 'tobi') + }) + + await t.test('directory type', async function (t) { + t.plan(5) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url, { root: fixtures }) + t.assert.deepStrictEqual(type, 'directory') + t.assert.ok(metadata.path) + t.assert.ok(!metadata.stat) + t.assert.ok(!metadata.error) + t.assert.ok(metadata.requestPath) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + await request(app) + .get('/pets') + .expect('Location', '/pets/') + .expect(301) + }) + + await t.test('error type', async function (t) { + t.plan(5) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url, { root: fixtures }) + t.assert.deepStrictEqual(type, 'error') + t.assert.ok(!metadata.path) + t.assert.ok(!metadata.stat) + t.assert.ok(metadata.error) + t.assert.ok(!metadata.requestPath) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const path = Array(100).join('foobar') + await request(app) + .get('/' + path) + .expect(404) + }) + + await t.test('custom directory index view', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url, { root: fixtures }) + if (type === 'directory') { + const list = await readdir(metadata.path) + res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }) + res.end(list.join('\n') + '\n') + } else { + res.writeHead(statusCode, headers) + stream.pipe(res) + } + }) + + await request(app) + .get('/pets') + .expect('Content-Type', 'text/plain; charset=utf-8') + .expect(200, '.hidden.txt\nindex.html\n') + }) + + await t.test('serving from a root directory with custom error-handling', async function (t) { + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url, { root: fixtures }) + switch (type) { + case 'directory': { + res.writeHead(301, { + Location: metadata.requestPath + '/' + }) + res.end('Redirecting to ' + metadata.requestPath + '/') + break + } + case 'error': { + res.writeHead(metadata.error.status ?? 500, {}) + res.end(metadata.error.message) + break + } + default: { + // serve all files for download + res.setHeader('Content-Disposition', 'attachment') + res.writeHead(statusCode, headers) + stream.pipe(res) + } + } + }) + + await request(app) + .get('/pets') + .expect('Location', '/pets/') + .expect(301) + + await request(app) + .get('/not-exists') + .expect(404) + + await request(app) + .get('/pets/index.html') + .expect('Content-Disposition', 'attachment') + .expect(200) + }) +}) diff --git a/test/send.js b/test/send.js deleted file mode 100644 index b8242821..00000000 --- a/test/send.js +++ /dev/null @@ -1,1330 +0,0 @@ - -process.env.NO_DEPRECATION = 'send' - -var after = require('after') -var assert = require('assert') -var fs = require('fs') -var http = require('http') -var path = require('path') -var request = require('supertest') -var send = require('..') - -// test server - -var dateRegExp = /^\w{3}, \d+ \w+ \d+ \d+:\d+:\d+ \w+$/ -var fixtures = path.join(__dirname, 'fixtures') -var app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - send(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) -}) - -describe('send(file).pipe(res)', function () { - it('should stream the file contents', function (done) { - request(app) - .get('/name.txt') - .expect('Content-Length', '4') - .expect(200, 'tobi', done) - }) - - it('should stream a zero-length file', function (done) { - request(app) - .get('/empty.txt') - .expect('Content-Length', '0') - .expect(200, '', done) - }) - - it('should decode the given path as a URI', function (done) { - request(app) - .get('/some%20thing.txt') - .expect(200, 'hey', done) - }) - - it('should serve files with dots in name', function (done) { - request(app) - .get('/do..ts.txt') - .expect(200, '...', done) - }) - - it('should treat a malformed URI as a bad request', function (done) { - request(app) - .get('/some%99thing.txt') - .expect(400, 'Bad Request', done) - }) - - it('should 400 on NULL bytes', function (done) { - request(app) - .get('/some%00thing.txt') - .expect(400, 'Bad Request', done) - }) - - it('should treat an ENAMETOOLONG as a 404', function (done) { - var path = Array(100).join('foobar') - request(app) - .get('/' + path) - .expect(404, done) - }) - - it('should handle headers already sent error', function (done) { - var app = http.createServer(function (req, res) { - res.write('0') - send(req, req.url, { root: fixtures }) - .on('error', function (err) { res.end(' - ' + err.message) }) - .pipe(res) - }) - request(app) - .get('/name.txt') - .expect(200, '0 - Can\'t set headers after they are sent.', done) - }) - - it('should support HEAD', function (done) { - request(app) - .head('/name.txt') - .expect(200) - .expect('Content-Length', '4') - .expect(shouldNotHaveBody()) - .end(done) - }) - - it('should add an ETag header field', function (done) { - request(app) - .get('/name.txt') - .expect('etag', /^W\/"[^"]+"$/) - .end(done) - }) - - it('should add a Date header field', function (done) { - request(app) - .get('/name.txt') - .expect('date', dateRegExp, done) - }) - - it('should add a Last-Modified header field', function (done) { - request(app) - .get('/name.txt') - .expect('last-modified', dateRegExp, done) - }) - - it('should add a Accept-Ranges header field', function (done) { - request(app) - .get('/name.txt') - .expect('Accept-Ranges', 'bytes', done) - }) - - it('should 404 if the file does not exist', function (done) { - request(app) - .get('/meow') - .expect(404, 'Not Found', done) - }) - - it('should emit ENOENT if the file does not exist', function (done) { - var app = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) - .on('error', function (err) { res.end(err.statusCode + ' ' + err.code) }) - .pipe(res) - }) - - request(app) - .get('/meow') - .expect(200, '404 ENOENT', done) - }) - - it('should not override content-type', function (done) { - var app = http.createServer(function (req, res) { - res.setHeader('Content-Type', 'application/x-custom') - send(req, req.url, { root: fixtures }).pipe(res) - }) - request(app) - .get('/name.txt') - .expect('Content-Type', 'application/x-custom', done) - }) - - it('should set Content-Type via mime map', function (done) { - request(app) - .get('/name.txt') - .expect('Content-Type', 'text/plain; charset=utf-8') - .expect(200, function (err) { - if (err) return done(err) - request(app) - .get('/tobi.html') - .expect('Content-Type', 'text/html; charset=utf-8') - .expect(200, done) - }) - }) - - it('should default Content-Type to octet-stream', function (done) { - request(app) - .get('/no_ext') - .expect('Content-Type', 'application/octet-stream') - .expect(200, done) - }) - - it('should 404 if file disappears after stat, before open', function (done) { - var app = http.createServer(function (req, res) { - send(req, req.url, { root: 'test/fixtures' }) - .on('file', function () { - // simulate file ENOENT after on open, after stat - var fn = this.send - this.send = function (path, stat) { - fn.call(this, (path + '__xxx_no_exist'), stat) - } - }) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(404, done) - }) - - it('should 500 on file stream error', function (done) { - var app = http.createServer(function (req, res) { - send(req, req.url, { root: 'test/fixtures' }) - .on('stream', function (stream) { - // simulate file error - stream.on('open', function () { - stream.emit('error', new Error('boom!')) - }) - }) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(500, done) - }) - - describe('"headers" event', function () { - it('should fire when sending file', function (done) { - var cb = after(2, done) - var server = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) - .on('headers', function () { cb() }) - .pipe(res) - }) - - request(server) - .get('/name.txt') - .expect(200, 'tobi', cb) - }) - - it('should not fire on 404', function (done) { - var cb = after(1, done) - var server = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) - .on('headers', function () { cb() }) - .pipe(res) - }) - - request(server) - .get('/bogus') - .expect(404, cb) - }) - - it('should fire on index', function (done) { - var cb = after(2, done) - var server = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) - .on('headers', function () { cb() }) - .pipe(res) - }) - - request(server) - .get('/pets/') - .expect(200, /tobi/, cb) - }) - - it('should not fire on redirect', function (done) { - var cb = after(1, done) - var server = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) - .on('headers', function () { cb() }) - .pipe(res) - }) - - request(server) - .get('/pets') - .expect(301, cb) - }) - - it('should provide path', function (done) { - var cb = after(2, done) - var server = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) - .on('headers', onHeaders) - .pipe(res) - }) - - function onHeaders (res, filePath) { - assert.ok(filePath) - assert.strictEqual(path.normalize(filePath), path.normalize(path.join(fixtures, 'name.txt'))) - cb() - } - - request(server) - .get('/name.txt') - .expect(200, 'tobi', cb) - }) - - it('should provide stat', function (done) { - var cb = after(2, done) - var server = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) - .on('headers', onHeaders) - .pipe(res) - }) - - function onHeaders (res, path, stat) { - assert.ok(stat) - assert.ok('ctime' in stat) - assert.ok('mtime' in stat) - cb() - } - - request(server) - .get('/name.txt') - .expect(200, 'tobi', cb) - }) - - it('should allow altering headers', function (done) { - var server = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) - .on('headers', onHeaders) - .pipe(res) - }) - - function onHeaders (res, path, stat) { - res.setHeader('Cache-Control', 'no-cache') - res.setHeader('Content-Type', 'text/x-custom') - res.setHeader('ETag', 'W/"everything"') - res.setHeader('X-Created', stat.ctime.toUTCString()) - } - - request(server) - .get('/name.txt') - .expect(200) - .expect('Cache-Control', 'no-cache') - .expect('Content-Type', 'text/x-custom') - .expect('ETag', 'W/"everything"') - .expect('X-Created', dateRegExp) - .expect('tobi') - .end(done) - }) - }) - - describe('when "directory" listeners are present', function () { - it('should be called when sending directory', function (done) { - var server = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) - .on('directory', onDirectory) - .pipe(res) - }) - - function onDirectory (res) { - res.statusCode = 400 - res.end('No directory for you') - } - - request(server) - .get('/pets') - .expect(400, 'No directory for you', done) - }) - - it('should be called with path', function (done) { - var server = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) - .on('directory', onDirectory) - .pipe(res) - }) - - function onDirectory (res, dirPath) { - res.end(path.normalize(dirPath)) - } - - request(server) - .get('/pets') - .expect(200, path.normalize(path.join(fixtures, 'pets')), done) - }) - }) - - describe('when no "directory" listeners are present', function () { - it('should redirect directories to trailing slash', function (done) { - request(createServer({ root: fixtures })) - .get('/pets') - .expect('Location', '/pets/') - .expect(301, done) - }) - - it('should respond with an HTML redirect', function (done) { - request(createServer({ root: fixtures })) - .get('/pets') - .expect('Location', '/pets/') - .expect('Content-Type', /html/) - .expect(301, />Redirecting to \/pets\/, done) - }) - - it('should respond with default Content-Security-Policy', function (done) { - request(createServer({ root: fixtures })) - .get('/pets') - .expect('Location', '/pets/') - .expect('Content-Security-Policy', "default-src 'none'") - .expect(301, done) - }) - - it('should not redirect to protocol-relative locations', function (done) { - request(createServer({ root: fixtures })) - .get('//pets') - .expect('Location', '/pets/') - .expect(301, done) - }) - - it('should respond with an HTML redirect', function (done) { - var app = http.createServer(function (req, res) { - send(req, req.url.replace('/snow', '/snow ☃'), { root: 'test/fixtures' }) - .pipe(res) - }) - - request(app) - .get('/snow') - .expect('Location', '/snow%20%E2%98%83/') - .expect('Content-Type', /html/) - .expect(301, />Redirecting to \/snow%20%E2%98%83\/, done) - }) - }) - - describe('when no "error" listeners are present', function () { - it('should respond to errors directly', function (done) { - request(createServer({ root: fixtures })) - .get('/foobar') - .expect(404, />Not Found, done) - }) - - it('should respond with default Content-Security-Policy', function (done) { - request(createServer({ root: fixtures })) - .get('/foobar') - .expect('Content-Security-Policy', "default-src 'none'") - .expect(404, done) - }) - - it('should remove all previously-set headers', function (done) { - var server = createServer({ root: fixtures }, function (req, res) { - res.setHeader('X-Foo', 'bar') - }) - - request(server) - .get('/foobar') - .expect(shouldNotHaveHeader('X-Foo')) - .expect(404, done) - }) - }) - - describe('with conditional-GET', function () { - it('should remove Content headers with 304', function (done) { - var server = createServer({ root: fixtures }, function (req, res) { - res.setHeader('Content-Language', 'en-US') - res.setHeader('Content-Location', 'http://localhost/name.txt') - res.setHeader('Contents', 'foo') - }) - - request(server) - .get('/name.txt') - .expect(200, function (err, res) { - if (err) return done(err) - request(server) - .get('/name.txt') - .set('If-None-Match', res.headers.etag) - .expect(shouldNotHaveHeader('Content-Language')) - .expect(shouldNotHaveHeader('Content-Length')) - .expect(shouldNotHaveHeader('Content-Type')) - .expect('Content-Location', 'http://localhost/name.txt') - .expect('Contents', 'foo') - .expect(304, done) - }) - }) - - it('should not remove all Content-* headers', function (done) { - var server = createServer({ root: fixtures }, function (req, res) { - res.setHeader('Content-Location', 'http://localhost/name.txt') - res.setHeader('Content-Security-Policy', 'default-src \'self\'') - }) - - request(server) - .get('/name.txt') - .expect(200, function (err, res) { - if (err) return done(err) - request(server) - .get('/name.txt') - .set('If-None-Match', res.headers.etag) - .expect(shouldNotHaveHeader('Content-Length')) - .expect(shouldNotHaveHeader('Content-Type')) - .expect('Content-Location', 'http://localhost/name.txt') - .expect('Content-Security-Policy', 'default-src \'self\'') - .expect(304, done) - }) - }) - - describe('where "If-Match" is set', function () { - it('should respond with 200 when "*"', function (done) { - request(app) - .get('/name.txt') - .set('If-Match', '*') - .expect(200, done) - }) - - it('should respond with 412 when ETag unmatched', function (done) { - request(app) - .get('/name.txt') - .set('If-Match', ' "foo",, "bar" ,') - .expect(412, done) - }) - - it('should respond with 200 when ETag matched', function (done) { - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - if (err) return done(err) - request(app) - .get('/name.txt') - .set('If-Match', '"foo", "bar", ' + res.headers.etag) - .expect(200, done) - }) - }) - }) - - describe('where "If-Modified-Since" is set', function () { - it('should respond with 304 when unmodified', function (done) { - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - if (err) return done(err) - request(app) - .get('/name.txt') - .set('If-Modified-Since', res.headers['last-modified']) - .expect(304, done) - }) - }) - - it('should respond with 200 when modified', function (done) { - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - if (err) return done(err) - var lmod = new Date(res.headers['last-modified']) - var date = new Date(lmod - 60000) - request(app) - .get('/name.txt') - .set('If-Modified-Since', date.toUTCString()) - .expect(200, 'tobi', done) - }) - }) - }) - - describe('where "If-None-Match" is set', function () { - it('should respond with 304 when ETag matched', function (done) { - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - if (err) return done(err) - request(app) - .get('/name.txt') - .set('If-None-Match', res.headers.etag) - .expect(304, done) - }) - }) - - it('should respond with 200 when ETag unmatched', function (done) { - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - if (err) return done(err) - request(app) - .get('/name.txt') - .set('If-None-Match', '"123"') - .expect(200, 'tobi', done) - }) - }) - }) - - describe('where "If-Unmodified-Since" is set', function () { - it('should respond with 200 when unmodified', function (done) { - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - if (err) return done(err) - request(app) - .get('/name.txt') - .set('If-Unmodified-Since', res.headers['last-modified']) - .expect(200, done) - }) - }) - - it('should respond with 412 when modified', function (done) { - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - if (err) return done(err) - var lmod = new Date(res.headers['last-modified']) - var date = new Date(lmod - 60000).toUTCString() - request(app) - .get('/name.txt') - .set('If-Unmodified-Since', date) - .expect(412, done) - }) - }) - - it('should respond with 200 when invalid date', function (done) { - request(app) - .get('/name.txt') - .set('If-Unmodified-Since', 'foo') - .expect(200, done) - }) - }) - }) - - describe('with Range request', function () { - it('should support byte ranges', function (done) { - request(app) - .get('/nums.txt') - .set('Range', 'bytes=0-4') - .expect(206, '12345', done) - }) - - it('should ignore non-byte ranges', function (done) { - request(app) - .get('/nums.txt') - .set('Range', 'items=0-4') - .expect(200, '123456789', done) - }) - - it('should be inclusive', function (done) { - request(app) - .get('/nums.txt') - .set('Range', 'bytes=0-0') - .expect(206, '1', done) - }) - - it('should set Content-Range', function (done) { - request(app) - .get('/nums.txt') - .set('Range', 'bytes=2-5') - .expect('Content-Range', 'bytes 2-5/9') - .expect(206, done) - }) - - it('should support -n', function (done) { - request(app) - .get('/nums.txt') - .set('Range', 'bytes=-3') - .expect(206, '789', done) - }) - - it('should support n-', function (done) { - request(app) - .get('/nums.txt') - .set('Range', 'bytes=3-') - .expect(206, '456789', done) - }) - - it('should respond with 206 "Partial Content"', function (done) { - request(app) - .get('/nums.txt') - .set('Range', 'bytes=0-4') - .expect(206, done) - }) - - it('should set Content-Length to the # of octets transferred', function (done) { - request(app) - .get('/nums.txt') - .set('Range', 'bytes=2-3') - .expect('Content-Length', '2') - .expect(206, '34', done) - }) - - describe('when last-byte-pos of the range is greater the length', function () { - it('is taken to be equal to one less than the length', function (done) { - request(app) - .get('/nums.txt') - .set('Range', 'bytes=2-50') - .expect('Content-Range', 'bytes 2-8/9') - .expect(206, done) - }) - - it('should adapt the Content-Length accordingly', function (done) { - request(app) - .get('/nums.txt') - .set('Range', 'bytes=2-50') - .expect('Content-Length', '7') - .expect(206, done) - }) - }) - - describe('when the first- byte-pos of the range is greater length', function () { - it('should respond with 416', function (done) { - request(app) - .get('/nums.txt') - .set('Range', 'bytes=9-50') - .expect('Content-Range', 'bytes */9') - .expect(416, done) - }) - - it('should emit error 416 with content-range header', function (done) { - var server = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) - .on('error', function (err) { - res.setHeader('X-Content-Range', err.headers['Content-Range']) - res.statusCode = err.statusCode - res.end(err.message) - }) - .pipe(res) - }) - - request(server) - .get('/nums.txt') - .set('Range', 'bytes=9-50') - .expect('X-Content-Range', 'bytes */9') - .expect(416, done) - }) - }) - - describe('when syntactically invalid', function () { - it('should respond with 200 and the entire contents', function (done) { - request(app) - .get('/nums.txt') - .set('Range', 'asdf') - .expect(200, '123456789', done) - }) - }) - - describe('when multiple ranges', function () { - it('should respond with 200 and the entire contents', function (done) { - request(app) - .get('/nums.txt') - .set('Range', 'bytes=1-1,3-') - .expect(shouldNotHaveHeader('Content-Range')) - .expect(200, '123456789', done) - }) - - it('should respond with 206 is all ranges can be combined', function (done) { - request(app) - .get('/nums.txt') - .set('Range', 'bytes=1-2,3-5') - .expect('Content-Range', 'bytes 1-5/9') - .expect(206, '23456', done) - }) - }) - - describe('when if-range present', function () { - it('should respond with parts when etag unchanged', function (done) { - request(app) - .get('/nums.txt') - .expect(200, function (err, res) { - if (err) return done(err) - var etag = res.headers.etag - - request(app) - .get('/nums.txt') - .set('If-Range', etag) - .set('Range', 'bytes=0-0') - .expect(206, '1', done) - }) - }) - - it('should respond with 200 when etag changed', function (done) { - request(app) - .get('/nums.txt') - .expect(200, function (err, res) { - if (err) return done(err) - var etag = res.headers.etag.replace(/"(.)/, '"0$1') - - request(app) - .get('/nums.txt') - .set('If-Range', etag) - .set('Range', 'bytes=0-0') - .expect(200, '123456789', done) - }) - }) - - it('should respond with parts when modified unchanged', function (done) { - request(app) - .get('/nums.txt') - .expect(200, function (err, res) { - if (err) return done(err) - var modified = res.headers['last-modified'] - - request(app) - .get('/nums.txt') - .set('If-Range', modified) - .set('Range', 'bytes=0-0') - .expect(206, '1', done) - }) - }) - - it('should respond with 200 when modified changed', function (done) { - request(app) - .get('/nums.txt') - .expect(200, function (err, res) { - if (err) return done(err) - var modified = Date.parse(res.headers['last-modified']) - 20000 - - request(app) - .get('/nums.txt') - .set('If-Range', new Date(modified).toUTCString()) - .set('Range', 'bytes=0-0') - .expect(200, '123456789', done) - }) - }) - - it('should respond with 200 when invalid value', function (done) { - request(app) - .get('/nums.txt') - .set('If-Range', 'foo') - .set('Range', 'bytes=0-0') - .expect(200, '123456789', done) - }) - }) - }) - - describe('when "options" is specified', function () { - it('should support start/end', function (done) { - request(createServer({ root: fixtures, start: 3, end: 5 })) - .get('/nums.txt') - .expect(200, '456', done) - }) - - it('should adjust too large end', function (done) { - request(createServer({ root: fixtures, start: 3, end: 90 })) - .get('/nums.txt') - .expect(200, '456789', done) - }) - - it('should support start/end with Range request', function (done) { - request(createServer({ root: fixtures, start: 0, end: 2 })) - .get('/nums.txt') - .set('Range', 'bytes=-2') - .expect(206, '23', done) - }) - - it('should support start/end with unsatisfiable Range request', function (done) { - request(createServer({ root: fixtures, start: 0, end: 2 })) - .get('/nums.txt') - .set('Range', 'bytes=5-9') - .expect('Content-Range', 'bytes */3') - .expect(416, done) - }) - }) -}) - -describe('send(file, options)', function () { - describe('acceptRanges', function () { - it('should support disabling accept-ranges', function (done) { - request(createServer({ acceptRanges: false, root: fixtures })) - .get('/nums.txt') - .expect(shouldNotHaveHeader('Accept-Ranges')) - .expect(200, done) - }) - - it('should ignore requested range', function (done) { - request(createServer({ acceptRanges: false, root: fixtures })) - .get('/nums.txt') - .set('Range', 'bytes=0-2') - .expect(shouldNotHaveHeader('Accept-Ranges')) - .expect(shouldNotHaveHeader('Content-Range')) - .expect(200, '123456789', done) - }) - }) - - describe('cacheControl', function () { - it('should support disabling cache-control', function (done) { - request(createServer({ cacheControl: false, root: fixtures })) - .get('/name.txt') - .expect(shouldNotHaveHeader('Cache-Control')) - .expect(200, done) - }) - - it('should ignore maxAge option', function (done) { - request(createServer({ cacheControl: false, maxAge: 1000, root: fixtures })) - .get('/name.txt') - .expect(shouldNotHaveHeader('Cache-Control')) - .expect(200, done) - }) - }) - - describe('etag', function () { - it('should support disabling etags', function (done) { - request(createServer({ etag: false, root: fixtures })) - .get('/name.txt') - .expect(shouldNotHaveHeader('ETag')) - .expect(200, done) - }) - }) - - describe('extensions', function () { - it('should reject numbers', function (done) { - request(createServer({ extensions: 42, root: fixtures })) - .get('/pets/') - .expect(500, /TypeError: extensions option/, done) - }) - - it('should reject true', function (done) { - request(createServer({ extensions: true, root: fixtures })) - .get('/pets/') - .expect(500, /TypeError: extensions option/, done) - }) - - it('should be not be enabled by default', function (done) { - request(createServer({ root: fixtures })) - .get('/tobi') - .expect(404, done) - }) - - it('should be configurable', function (done) { - request(createServer({ extensions: 'txt', root: fixtures })) - .get('/name') - .expect(200, 'tobi', done) - }) - - it('should support disabling extensions', function (done) { - request(createServer({ extensions: false, root: fixtures })) - .get('/name') - .expect(404, done) - }) - - it('should support fallbacks', function (done) { - request(createServer({ extensions: ['htm', 'html', 'txt'], root: fixtures })) - .get('/name') - .expect(200, 'tobi
', done) - }) - - it('should 404 if nothing found', function (done) { - request(createServer({ extensions: ['htm', 'html', 'txt'], root: fixtures })) - .get('/bob') - .expect(404, done) - }) - - it('should skip directories', function (done) { - request(createServer({ extensions: ['file', 'dir'], root: fixtures })) - .get('/name') - .expect(404, done) - }) - - it('should not search if file has extension', function (done) { - request(createServer({ extensions: 'html', root: fixtures })) - .get('/thing.html') - .expect(404, done) - }) - }) - - describe('lastModified', function () { - it('should support disabling last-modified', function (done) { - request(createServer({ lastModified: false, root: fixtures })) - .get('/name.txt') - .expect(shouldNotHaveHeader('Last-Modified')) - .expect(200, done) - }) - }) - - describe('dotfiles', function () { - it('should default to "ignore"', function (done) { - request(createServer({ root: fixtures })) - .get('/.hidden.txt') - .expect(404, done) - }) - - it('should ignore file within dotfile directory', function (done) { - request(createServer({ root: fixtures })) - .get('/.mine/name.txt') - .expect(404, done) - }) - - it('should reject bad value', function (done) { - request(createServer({ dotfiles: 'bogus' })) - .get('/name.txt') - .expect(500, /dotfiles/, done) - }) - - describe('when "allow"', function (done) { - it('should send dotfile', function (done) { - request(createServer({ dotfiles: 'allow', root: fixtures })) - .get('/.hidden.txt') - .expect(200, 'secret', done) - }) - - it('should send within dotfile directory', function (done) { - request(createServer({ dotfiles: 'allow', root: fixtures })) - .get('/.mine/name.txt') - .expect(200, /tobi/, done) - }) - - it('should 404 for non-existent dotfile', function (done) { - request(createServer({ dotfiles: 'allow', root: fixtures })) - .get('/.nothere') - .expect(404, done) - }) - }) - - describe('when "deny"', function (done) { - it('should 403 for dotfile', function (done) { - request(createServer({ dotfiles: 'deny', root: fixtures })) - .get('/.hidden.txt') - .expect(403, done) - }) - - it('should 403 for dotfile directory', function (done) { - request(createServer({ dotfiles: 'deny', root: fixtures })) - .get('/.mine') - .expect(403, done) - }) - - it('should 403 for dotfile directory with trailing slash', function (done) { - request(createServer({ dotfiles: 'deny', root: fixtures })) - .get('/.mine/') - .expect(403, done) - }) - - it('should 403 for file within dotfile directory', function (done) { - request(createServer({ dotfiles: 'deny', root: fixtures })) - .get('/.mine/name.txt') - .expect(403, done) - }) - - it('should 403 for non-existent dotfile', function (done) { - request(createServer({ dotfiles: 'deny', root: fixtures })) - .get('/.nothere') - .expect(403, done) - }) - - it('should 403 for non-existent dotfile directory', function (done) { - request(createServer({ dotfiles: 'deny', root: fixtures })) - .get('/.what/name.txt') - .expect(403, done) - }) - - it('should 403 for dotfile in directory', function (done) { - request(createServer({ dotfiles: 'deny', root: fixtures })) - .get('/pets/.hidden') - .expect(403, done) - }) - - it('should 403 for dotfile in dotfile directory', function (done) { - request(createServer({ dotfiles: 'deny', root: fixtures })) - .get('/.mine/.hidden') - .expect(403, done) - }) - - it('should send files in root dotfile directory', function (done) { - request(createServer({ dotfiles: 'deny', root: path.join(fixtures, '.mine') })) - .get('/name.txt') - .expect(200, /tobi/, done) - }) - - it('should 403 for dotfile without root', function (done) { - var server = http.createServer(function onRequest (req, res) { - send(req, fixtures + '/.mine' + req.url, { dotfiles: 'deny' }).pipe(res) - }) - - request(server) - .get('/name.txt') - .expect(403, done) - }) - }) - - describe('when "ignore"', function (done) { - it('should 404 for dotfile', function (done) { - request(createServer({ dotfiles: 'ignore', root: fixtures })) - .get('/.hidden.txt') - .expect(404, done) - }) - - it('should 404 for dotfile directory', function (done) { - request(createServer({ dotfiles: 'ignore', root: fixtures })) - .get('/.mine') - .expect(404, done) - }) - - it('should 404 for dotfile directory with trailing slash', function (done) { - request(createServer({ dotfiles: 'ignore', root: fixtures })) - .get('/.mine/') - .expect(404, done) - }) - - it('should 404 for file within dotfile directory', function (done) { - request(createServer({ dotfiles: 'ignore', root: fixtures })) - .get('/.mine/name.txt') - .expect(404, done) - }) - - it('should 404 for non-existent dotfile', function (done) { - request(createServer({ dotfiles: 'ignore', root: fixtures })) - .get('/.nothere') - .expect(404, done) - }) - - it('should 404 for non-existent dotfile directory', function (done) { - request(createServer({ dotfiles: 'ignore', root: fixtures })) - .get('/.what/name.txt') - .expect(404, done) - }) - - it('should send files in root dotfile directory', function (done) { - request(createServer({ dotfiles: 'ignore', root: path.join(fixtures, '.mine') })) - .get('/name.txt') - .expect(200, /tobi/, done) - }) - - it('should 404 for dotfile without root', function (done) { - var server = http.createServer(function onRequest (req, res) { - send(req, fixtures + '/.mine' + req.url, { dotfiles: 'ignore' }).pipe(res) - }) - - request(server) - .get('/name.txt') - .expect(404, done) - }) - }) - }) - - describe('immutable', function () { - it('should default to false', function (done) { - request(createServer({ root: fixtures })) - .get('/name.txt') - .expect('Cache-Control', 'public, max-age=0', done) - }) - - it('should set immutable directive in Cache-Control', function (done) { - request(createServer({ immutable: true, maxAge: '1h', root: fixtures })) - .get('/name.txt') - .expect('Cache-Control', 'public, max-age=3600, immutable', done) - }) - }) - - describe('maxAge', function () { - it('should default to 0', function (done) { - request(createServer({ root: fixtures })) - .get('/name.txt') - .expect('Cache-Control', 'public, max-age=0', done) - }) - - it('should floor to integer', function (done) { - request(createServer({ maxAge: 123956, root: fixtures })) - .get('/name.txt') - .expect('Cache-Control', 'public, max-age=123', done) - }) - - it('should accept string', function (done) { - request(createServer({ maxAge: '30d', root: fixtures })) - .get('/name.txt') - .expect('Cache-Control', 'public, max-age=2592000', done) - }) - - it('should max at 1 year', function (done) { - request(createServer({ maxAge: '2y', root: fixtures })) - .get('/name.txt') - .expect('Cache-Control', 'public, max-age=31536000', done) - }) - }) - - describe('index', function () { - it('should reject numbers', function (done) { - request(createServer({ root: fixtures, index: 42 })) - .get('/pets/') - .expect(500, /TypeError: index option/, done) - }) - - it('should reject true', function (done) { - request(createServer({ root: fixtures, index: true })) - .get('/pets/') - .expect(500, /TypeError: index option/, done) - }) - - it('should default to index.html', function (done) { - request(createServer({ root: fixtures })) - .get('/pets/') - .expect(fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8'), done) - }) - - it('should be configurable', function (done) { - request(createServer({ root: fixtures, index: 'tobi.html' })) - .get('/') - .expect(200, 'tobi
', done) - }) - - it('should support disabling', function (done) { - request(createServer({ root: fixtures, index: false })) - .get('/pets/') - .expect(403, done) - }) - - it('should support fallbacks', function (done) { - request(createServer({ root: fixtures, index: ['default.htm', 'index.html'] })) - .get('/pets/') - .expect(200, fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8'), done) - }) - - it('should 404 if no index file found (file)', function (done) { - request(createServer({ root: fixtures, index: 'default.htm' })) - .get('/pets/') - .expect(404, done) - }) - - it('should 404 if no index file found (dir)', function (done) { - request(createServer({ root: fixtures, index: 'pets' })) - .get('/') - .expect(404, done) - }) - - it('should not follow directories', function (done) { - request(createServer({ root: fixtures, index: ['pets', 'name.txt'] })) - .get('/') - .expect(200, 'tobi', done) - }) - - it('should work without root', function (done) { - var server = http.createServer(function (req, res) { - var p = path.join(fixtures, 'pets').replace(/\\/g, '/') + '/' - send(req, p, { index: ['index.html'] }) - .pipe(res) - }) - - request(server) - .get('/') - .expect(200, /tobi/, done) - }) - - it('should 404 if file path contains trailing slash (windows)', function (done) { - request(createServer({ root: fixtures, index: false })) - .get('/tobi.html/') - .expect(404, done) - }) - }) - - describe('root', function () { - describe('when given', function () { - it('should join root', function (done) { - request(createServer({ root: fixtures })) - .get('/pets/../name.txt') - .expect(200, 'tobi', done) - }) - - it('should work with trailing slash', function (done) { - var app = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures + '/' }) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, 'tobi', done) - }) - - it('should work with empty path', function (done) { - var app = http.createServer(function (req, res) { - send(req, '', { root: fixtures }) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(301, /Redirecting to/, done) - }) - - // - // NOTE: This is not a real part of the API, but - // over time this has become something users - // are doing, so this will prevent unseen - // regressions around this use-case. - // - it('should try as file with empty path', function (done) { - var app = http.createServer(function (req, res) { - send(req, '', { root: path.join(fixtures, 'name.txt') }) - .pipe(res) - }) - - request(app) - .get('/') - .expect(200, 'tobi', done) - }) - - it('should restrict paths to within root', function (done) { - request(createServer({ root: fixtures })) - .get('/pets/../../send.js') - .expect(403, done) - }) - - it('should allow .. in root', function (done) { - var app = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures + '/../fixtures' }) - .pipe(res) - }) - - request(app) - .get('/pets/../../send.js') - .expect(403, done) - }) - - it('should not allow root transversal', function (done) { - request(createServer({ root: path.join(fixtures, 'name.d') })) - .get('/../name.dir/name.txt') - .expect(403, done) - }) - - it('should not allow root path disclosure', function (done) { - request(createServer({ root: fixtures })) - .get('/pets/../../fixtures/name.txt') - .expect(403, done) - }) - }) - - describe('when missing', function () { - it('should consider .. malicious', function (done) { - var app = http.createServer(function (req, res) { - send(req, fixtures + req.url) - .pipe(res) - }) - - request(app) - .get('/../send.js') - .expect(403, done) - }) - - it('should still serve files with dots in name', function (done) { - var app = http.createServer(function (req, res) { - send(req, fixtures + req.url) - .pipe(res) - }) - - request(app) - .get('/do..ts.txt') - .expect(200, '...', done) - }) - }) - }) -}) - -function createServer (opts, fn) { - return http.createServer(function onRequest (req, res) { - try { - fn && fn(req, res) - send(req, req.url, opts).pipe(res) - } catch (err) { - res.statusCode = 500 - res.end(String(err)) - } - }) -} - -function shouldNotHaveBody () { - return function (res) { - assert.ok(res.text === '' || res.text === undefined) - } -} - -function shouldNotHaveHeader (header) { - return function (res) { - assert.ok(!(header.toLowerCase() in res.headers), 'should not have header ' + header) - } -} diff --git a/test/utils.js b/test/utils.js new file mode 100644 index 00000000..218eba7f --- /dev/null +++ b/test/utils.js @@ -0,0 +1,36 @@ +'use strict' + +const http = require('node:http') +const send = require('..') + +module.exports.shouldNotHaveHeader = function shouldNotHaveHeader (header, t) { + return function (res) { + t.assert.ok(!(header.toLowerCase() in res.headers), 'should not have header ' + header) + } +} + +module.exports.shouldHaveHeader = function shouldHaveHeader (header, t) { + return function (res) { + t.assert.ok((header.toLowerCase() in res.headers), 'should have header ' + header) + } +} + +module.exports.createServer = function createServer (opts, fn) { + return http.createServer(async function onRequest (req, res) { + try { + fn?.(req, res) + const { statusCode, headers, stream } = await send(req, req.url, opts) + res.writeHead(statusCode, headers) + stream.pipe(res) + } catch (err) { + res.statusCode = 500 + res.end(String(err)) + } + }) +} + +module.exports.shouldNotHaveBody = function shouldNotHaveBody (t) { + return function (res) { + t.assert.ok(res.text === '' || res.text === undefined) + } +} diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 00000000..36184e18 --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1,167 @@ +// Definitions by: Mike Jerred