From 72004f090ac886ec50d9bfd3d5b56efa86ebe867 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 11 Jan 2023 11:25:10 +0100 Subject: [PATCH 001/109] module rename Signed-off-by: Matteo Collina --- README.md | 32 ++++++-------------------------- package.json | 4 ++-- 2 files changed, 8 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index fadf838..143eae0 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,11 @@ # send -[![NPM Version][npm-version-image]][npm-url] -[![NPM Downloads][npm-downloads-image]][npm-url] -[![Linux Build][github-actions-ci-image]][github-actions-ci-url] -[![Windows Build][appveyor-image]][appveyor-url] -[![Test Coverage][coveralls-image]][coveralls-url] - Send is a library for streaming files from the file system as a 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 @@ -21,13 +13,13 @@ This is a [Node.js](https://nodejs.org/en/) module available through the [`npm install` command](https://docs.npmjs.com/getting-started/installing-npm-packages-locally): ```bash -$ npm install send +$ npm install @fastify/send ``` ## API ```js -var send = require('send') +var send = require('@fastify/send') ``` ### send(req, path, [options]) @@ -197,7 +189,7 @@ given directory as the top-level. For example, a request ```js var http = require('http') var parseUrl = require('parseurl') -var send = require('send') +var send = require('@fastify/send') var server = http.createServer(function onRequest (req, res) { send(req, parseUrl(req).pathname, { root: '/www/public' }) @@ -212,7 +204,7 @@ server.listen(3000) ```js var http = require('http') var parseUrl = require('parseurl') -var send = require('send') +var send = require('@fastify/send') // Default unknown types to text/plain send.mime.default_type = 'text/plain' @@ -239,7 +231,7 @@ custom function to render a listing of a directory. var http = require('http') var fs = require('fs') var parseUrl = require('parseurl') -var send = require('send') +var send = require('@fastify/send') // Transfer arbitrary files from within /www/example.com/public/* // with a custom handler for directory listing @@ -276,7 +268,7 @@ function directory (res, path) { ```js var http = require('http') var parseUrl = require('parseurl') -var send = require('send') +var send = require('@fastify/send') var server = http.createServer(function onRequest (req, res) { // your custom error-handling logic: @@ -313,15 +305,3 @@ server.listen(3000) ## License [MIT](LICENSE) - -[appveyor-image]: https://badgen.net/appveyor/ci/dougwilson/send/master?label=windows -[appveyor-url]: https://ci.appveyor.com/project/dougwilson/send -[coveralls-image]: https://badgen.net/coveralls/c/github/pillarjs/send/master -[coveralls-url]: https://coveralls.io/r/pillarjs/send?branch=master -[github-actions-ci-image]: https://badgen.net/github/checks/pillarjs/send/master?label=linux -[github-actions-ci-url]: https://github.com/pillarjs/send/actions/workflows/ci.yml -[node-image]: https://badgen.net/npm/node/send -[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 diff --git a/package.json b/package.json index 7f269d5..8fc05e4 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "send", + "name": "@fastify/send", "description": "Better streaming static file server with Range and conditional-GET support", "version": "0.18.0", "author": "TJ Holowaychuk ", @@ -9,7 +9,7 @@ "Jesús Leganés Combarro " ], "license": "MIT", - "repository": "pillarjs/send", + "repository": "fastify/send", "keywords": [ "static", "file", From ac7922b82f7098f0cf2f30787ad938ebf2fcaa3c Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 11 Jan 2023 11:28:38 +0100 Subject: [PATCH 002/109] fixup Signed-off-by: Matteo Collina --- .github/workflows/ci.yml | 79 ---------------------------------------- 1 file changed, 79 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c5b941..4d9cd8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,91 +10,12 @@ jobs: strategy: matrix: name: - - Node.js 0.8 - - Node.js 0.10 - - Node.js 0.12 - - io.js 1.x - - io.js 2.x - - io.js 3.x - - Node.js 4.x - - Node.js 5.x - - Node.js 6.x - - Node.js 7.x - - Node.js 8.x - - Node.js 9.x - - Node.js 10.x - - Node.js 11.x - - Node.js 12.x - - Node.js 13.x - Node.js 14.x - Node.js 15.x - Node.js 16.x - Node.js 17.x include: - - name: Node.js 0.8 - node-version: "0.8" - npm-i: mocha@2.5.3 supertest@1.1.0 - npm-rm: nyc - - - name: Node.js 0.10 - node-version: "0.10" - npm-i: mocha@3.5.3 nyc@10.3.2 supertest@2.0.0 - - - name: Node.js 0.12 - node-version: "0.12" - npm-i: mocha@3.5.3 nyc@10.3.2 supertest@2.0.0 - - - name: io.js 1.x - node-version: "1.8" - npm-i: mocha@3.5.3 nyc@10.3.2 supertest@2.0.0 - - - name: io.js 2.x - node-version: "2.5" - npm-i: mocha@3.5.3 nyc@10.3.2 supertest@2.0.0 - - - name: io.js 3.x - node-version: "3.3" - npm-i: mocha@3.5.3 nyc@10.3.2 supertest@2.0.0 - - - name: Node.js 4.x - node-version: "4.9" - npm-i: mocha@5.2.0 nyc@11.9.0 supertest@3.4.2 - - - name: Node.js 5.x - node-version: "5.12" - npm-i: mocha@5.2.0 nyc@11.9.0 supertest@3.4.2 - - - name: Node.js 6.x - node-version: "6.17" - npm-i: mocha@6.2.3 nyc@14.1.1 supertest@6.1.6 - - - name: Node.js 7.x - node-version: "7.10" - npm-i: mocha@6.2.3 nyc@14.1.1 supertest@6.1.6 - - - name: Node.js 8.x - node-version: "8.16" - npm-i: mocha@7.2.0 - - - name: Node.js 9.x - node-version: "9.11" - npm-i: mocha@7.2.0 - - - name: Node.js 10.x - node-version: "10.24" - npm-i: mocha@8.4.0 - - - name: Node.js 11.x - node-version: "11.15" - npm-i: mocha@8.4.0 - - - name: Node.js 12.x - node-version: "12.22" - - - name: Node.js 13.x - node-version: "13.14" - - name: Node.js 14.x node-version: "14.19" From 70aa30924e55753ca3304ee922e8115394c0ca91 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 11 Jan 2023 11:26:15 +0100 Subject: [PATCH 003/109] Update debug Signed-off-by: Matteo Collina --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8fc05e4..da7722c 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "server" ], "dependencies": { - "debug": "2.6.9", + "debug": "^4.3.4", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", From e5ebad0871aee89d5aa4a013dc7654694e51b9db Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 11 Jan 2023 11:38:49 +0100 Subject: [PATCH 004/109] Bumped v1.0.0 Signed-off-by: Matteo Collina --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index da7722c..2706bcc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@fastify/send", "description": "Better streaming static file server with Range and conditional-GET support", - "version": "0.18.0", + "version": "1.0.0", "author": "TJ Holowaychuk ", "contributors": [ "Douglas Christopher Wilson ", From a22509374ce1a6a44b88fe5f68d422a1296b42e7 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Thu, 12 Jan 2023 07:16:28 +0000 Subject: [PATCH 005/109] chore: repo updates (#3) * chore: disable package-lock generation * ci: use org reusable workflow * chore(.gitignore): use skeleton template * docs: remove security policy, allowing org one to show * chore(package): replace eslint with standard * chore(package): remove engines * docs(readme): update title, add badges * chore: lint files --- .eslintignore | 2 - .eslintrc.yml | 11 --- .github/dependabot.yml | 13 +++ .github/workflows/ci.yml | 118 +++---------------------- .gitignore | 149 ++++++++++++++++++++++++++++++- .npmrc | 1 + README.md | 6 +- SECURITY.md | 24 ----- appveyor.yml | 89 ------------------- index.js | 183 ++++++++++++++++++++------------------- package.json | 22 ++--- test/.eslintrc.yml | 2 - test/send.js | 132 ++++++++++++++-------------- 13 files changed, 344 insertions(+), 408 deletions(-) delete mode 100644 .eslintignore delete mode 100644 .eslintrc.yml create mode 100644 .github/dependabot.yml create mode 100644 .npmrc delete mode 100644 SECURITY.md delete mode 100644 appveyor.yml delete mode 100644 test/.eslintrc.yml diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 62562b7..0000000 --- a/.eslintignore +++ /dev/null @@ -1,2 +0,0 @@ -coverage -node_modules diff --git a/.eslintrc.yml b/.eslintrc.yml deleted file mode 100644 index e4f03fb..0000000 --- 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/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..618a234 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d9cd8d..907c06a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,110 +1,18 @@ -name: ci +name: CI on: -- pull_request -- push + push: + paths-ignore: + - 'docs/**' + - '*.md' + pull_request: + paths-ignore: + - 'docs/**' + - '*.md' jobs: test: - runs-on: ubuntu-latest - strategy: - matrix: - name: - - Node.js 14.x - - Node.js 15.x - - Node.js 16.x - - Node.js 17.x - - include: - - name: Node.js 14.x - node-version: "14.19" - - - name: Node.js 15.x - node-version: "15.14" - - - name: Node.js 16.x - node-version: "16.14" - - - name: Node.js 17.x - node-version: "17.7" - - steps: - - uses: actions/checkout@v2 - - - name: Install Node.js ${{ matrix.node-version }} - shell: bash -eo pipefail -l {0} - run: | - nvm install --default ${{ matrix.node-version }} - if [[ "${{ matrix.node-version }}" == 0.* && "$(cut -d. -f2 <<< "${{ matrix.node-version }}")" -lt 10 ]]; then - nvm install --alias=npm 0.10 - nvm use ${{ matrix.node-version }} - sed -i '1s;^.*$;'"$(printf '#!%q' "$(nvm which npm)")"';' "$(readlink -f "$(which npm)")" - npm config set strict-ssl false - fi - dirname "$(nvm which ${{ matrix.node-version }})" >> "$GITHUB_PATH" - - - name: Configure npm - run: npm config set shrinkwrap false - - - name: Remove npm module(s) ${{ matrix.npm-rm }} - run: npm rm --silent --save-dev ${{ matrix.npm-rm }} - if: matrix.npm-rm != '' - - - name: Install npm module(s) ${{ matrix.npm-i }} - run: npm install --save-dev ${{ matrix.npm-i }} - if: matrix.npm-i != '' - - - name: Setup Node.js version-specific dependencies - shell: bash - run: | - # eslint for linting - # - remove on Node.js < 8 - if [[ "$(cut -d. -f1 <<< "${{ matrix.node-version }}")" -lt 10 ]]; then - node -pe 'Object.keys(require("./package").devDependencies).join("\n")' | \ - grep -E '^eslint(-|$)' | \ - sort -r | \ - xargs -n1 npm rm --silent --save-dev - fi - - - name: Install Node.js dependencies - run: npm install - - - name: List environment - id: list_env - shell: bash - run: | - echo "node@$(node -v)" - echo "npm@$(npm -v)" - npm -s ls ||: - (npm -s ls --depth=0 ||:) | awk -F'[ @]' 'NR>1 && $2 { print "::set-output name=" $2 "::" $3 }' - - - name: Run tests - shell: bash - run: | - if npm -ps ls nyc | grep -q nyc; then - npm run test-ci - else - npm test - fi - - - name: Lint code - if: steps.list_env.outputs.eslint != '' - run: npm run lint - - - name: Collect code coverage - uses: coverallsapp/github-action@master - if: steps.list_env.outputs.nyc != '' - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - flag-name: run-${{ matrix.test_number }} - parallel: true - - coverage: - needs: test - runs-on: ubuntu-latest - steps: - - name: Uploade code coverage - uses: coverallsapp/github-action@master - with: - github-token: ${{ secrets.github_token }} - parallel-finished: true + uses: fastify/workflows/.github/workflows/plugins-ci.yml@v3 + with: + license-check: true + lint: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index f15b98e..1fb3e10 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,148 @@ -.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 package-lock.json +pnpm-lock.yaml +yarn.lock + +# editor files +.vscode +.idea \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..9cf9495 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false \ No newline at end of file diff --git a/README.md b/README.md index 143eae0..6c6b45b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ -# send +# @fastify/send + +![CI](https://github.com/fastify/send/workflows/CI/badge.svg) +[![NPM version](https://img.shields.io/npm/v/@fastify/send.svg?style=flat)](https://www.npmjs.com/package/@fastify/send) +[![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](https://standardjs.com/) Send is a library for streaming files from the file system as a http response supporting partial responses (Ranges), conditional-GET negotiation (If-Match, diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 46b48f7..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,24 +0,0 @@ -# Security Policies and Procedures - -## Reporting a Bug - -The `send` team and community take all security bugs seriously. Thank you -for improving the security of Express. We appreciate your efforts and -responsible disclosure and will make every effort to acknowledge your -contributions. - -Report security bugs by emailing the current owner(s) of `send`. This information -can be found in the npm registry using the command `npm owner ls send`. -If unsure or unable to get the information from the above, open an issue -in the [project issue tracker](https://github.com/pillarjs/send/issues) -asking for the current contact information. - -To ensure the timely response to your report, please ensure that the entirety -of the report is contained within the email body and not solely behind a web -link or an attachment. - -At least one owner will acknowledge your email within 48 hours, and will send a -more detailed response within 48 hours indicating the next steps in handling -your report. After the initial reply to your report, the owners will -endeavor to keep you informed of the progress towards a fix and full -announcement, and may ask for additional information or guidance. diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 1332a99..0000000 --- a/appveyor.yml +++ /dev/null @@ -1,89 +0,0 @@ -environment: - matrix: - - nodejs_version: "0.10" - - nodejs_version: "0.12" - - nodejs_version: "1.8" - - nodejs_version: "2.5" - - nodejs_version: "3.3" - - nodejs_version: "4.9" - - nodejs_version: "5.12" - - nodejs_version: "6.17" - - nodejs_version: "7.10" - - nodejs_version: "8.16" - - nodejs_version: "9.11" - - nodejs_version: "10.24" - - nodejs_version: "11.15" - - nodejs_version: "12.22" - - nodejs_version: "13.14" - - nodejs_version: "14.19" - - nodejs_version: "15.14" - - nodejs_version: "16.14" - - nodejs_version: "17.7" -cache: - - node_modules -install: - # Install Node.js - - ps: >- - try { Install-Product node $env:nodejs_version -ErrorAction Stop } - catch { Update-NodeJsInstallation (Get-NodeJsLatestBuild $env:nodejs_version) } - # Configure npm - - ps: | - # Skip updating shrinkwrap / lock - npm config set shrinkwrap false - # Remove all non-test dependencies - - ps: | - # Remove coverage dependency - npm rm --silent --save-dev nyc - # Remove lint dependencies - cmd.exe /c "node -pe `"Object.keys(require('./package').devDependencies).join('\n')`"" | ` - sls "^eslint(-|$)" | ` - %{ npm rm --silent --save-dev $_ } - # Setup Node.js version-specific dependencies - - ps: | - # mocha for testing - # - use 3.x for Node.js < 4 - # - use 5.x for Node.js < 6 - # - use 6.x for Node.js < 8 - # - use 7.x for Node.js < 10 - # - use 8.x for Node.js < 12 - if ([int]$env:nodejs_version.split(".")[0] -lt 4) { - npm install --silent --save-dev mocha@3.5.3 - } elseif ([int]$env:nodejs_version.split(".")[0] -lt 6) { - npm install --silent --save-dev mocha@5.2.0 - } elseif ([int]$env:nodejs_version.split(".")[0] -lt 8) { - npm install --silent --save-dev mocha@6.2.3 - } elseif ([int]$env:nodejs_version.split(".")[0] -lt 10) { - npm install --silent --save-dev mocha@7.2.0 - } elseif ([int]$env:nodejs_version.split(".")[0] -lt 12) { - npm install --silent --save-dev mocha@8.4.0 - } - - ps: | - # supertest for http calls - # - use 2.0.0 for Node.js < 4 - # - use 3.4.2 for Node.js < 6 - # - use 6.1.6 for Node.js < 8 - if ([int]$env:nodejs_version.split(".")[0] -lt 4) { - npm install --silent --save-dev supertest@2.0.0 - } elseif ([int]$env:nodejs_version.split(".")[0] -lt 6) { - npm install --silent --save-dev supertest@3.4.2 - } elseif ([int]$env:nodejs_version.split(".")[0] -lt 8) { - npm install --silent --save-dev supertest@6.1.6 - } - # Update Node.js modules - - ps: | - # Prune & rebuild node_modules - if (Test-Path -Path node_modules) { - npm prune - npm rebuild - } - # Install Node.js modules - - npm install -build: off -test_script: - # Output version data - - ps: | - node --version - npm --version - # Run test script - - npm test -version: "{build}" diff --git a/index.js b/index.js index 89afd7e..13449e8 100644 --- a/index.js +++ b/index.js @@ -12,55 +12,55 @@ * @private */ -var createError = require('http-errors') -var debug = require('debug')('send') -var deprecate = require('depd')('send') -var destroy = require('destroy') -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') -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') +const createError = require('http-errors') +const debug = require('debug')('send') +const deprecate = require('depd')('send') +const destroy = require('destroy') +const encodeUrl = require('encodeurl') +const escapeHtml = require('escape-html') +const etag = require('etag') +const fresh = require('fresh') +const fs = require('fs') +const mime = require('mime') +const ms = require('ms') +const onFinished = require('on-finished') +const parseRange = require('range-parser') +const path = require('path') +const statuses = require('statuses') +const Stream = require('stream') +const 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 +const extname = path.extname +const join = path.join +const normalize = path.normalize +const resolve = path.resolve +const sep = path.sep /** * Regular expression for identifying a bytes Range header. * @private */ -var BYTES_RANGE_REGEXP = /^ *bytes=/ +const BYTES_RANGE_REGEXP = /^ *bytes=/ /** * Maximum value allowed for the max age. * @private */ -var MAX_MAXAGE = 60 * 60 * 24 * 365 * 1000 // 1 year +const 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 UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/ /** * Module exports. @@ -96,7 +96,7 @@ function send (req, path, options) { function SendStream (req, path, options) { Stream.call(this) - var opts = options || {} + const opts = options || {} this.options = opts this.path = path @@ -211,7 +211,7 @@ SendStream.prototype.hidden = deprecate.function(function hidden (val) { */ SendStream.prototype.index = deprecate.function(function index (paths) { - var index = !paths ? [] : normalizeList(paths, 'paths argument') + const index = !paths ? [] : normalizeList(paths, 'paths argument') debug('index %o', paths) this._index = index return this @@ -270,9 +270,9 @@ SendStream.prototype.error = function error (status, err) { return this.emit('error', createHttpError(status, err)) } - var res = this.res - var msg = statuses.message[status] || String(status) - var doc = createHtmlDocument('Error', escapeHtml(msg)) + const res = this.res + const msg = statuses.message[status] || String(status) + const doc = createHtmlDocument('Error', escapeHtml(msg)) // clear existing headers clearHeaders(res) @@ -324,22 +324,22 @@ SendStream.prototype.isConditionalGET = function isConditionalGET () { */ SendStream.prototype.isPreconditionFailure = function isPreconditionFailure () { - var req = this.req - var res = this.res + const req = this.req + const res = this.res // if-match - var match = req.headers['if-match'] + const match = req.headers['if-match'] if (match) { - var etag = res.getHeader('ETag') + const 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']) + const unmodifiedSince = parseHttpDate(req.headers['if-unmodified-since']) if (!isNaN(unmodifiedSince)) { - var lastModified = parseHttpDate(res.getHeader('Last-Modified')) + const lastModified = parseHttpDate(res.getHeader('Last-Modified')) return isNaN(lastModified) || lastModified > unmodifiedSince } @@ -353,7 +353,7 @@ SendStream.prototype.isPreconditionFailure = function isPreconditionFailure () { */ SendStream.prototype.removeContentHeaderFields = function removeContentHeaderFields () { - var res = this.res + const res = this.res res.removeHeader('Content-Encoding') res.removeHeader('Content-Language') @@ -369,7 +369,7 @@ SendStream.prototype.removeContentHeaderFields = function removeContentHeaderFie */ SendStream.prototype.notModified = function notModified () { - var res = this.res + const res = this.res debug('not modified') this.removeContentHeaderFields() res.statusCode = 304 @@ -383,7 +383,7 @@ SendStream.prototype.notModified = function notModified () { */ SendStream.prototype.headersAlreadySent = function headersAlreadySent () { - var err = new Error('Can\'t set headers after they are sent.') + const err = new Error('Can\'t set headers after they are sent.') debug('headers already sent') this.error(500, err) } @@ -397,7 +397,7 @@ SendStream.prototype.headersAlreadySent = function headersAlreadySent () { */ SendStream.prototype.isCachable = function isCachable () { - var statusCode = this.res.statusCode + const statusCode = this.res.statusCode return (statusCode >= 200 && statusCode < 300) || statusCode === 304 } @@ -444,7 +444,7 @@ SendStream.prototype.isFresh = function isFresh () { */ SendStream.prototype.isRangeFresh = function isRangeFresh () { - var ifRange = this.req.headers['if-range'] + const ifRange = this.req.headers['if-range'] if (!ifRange) { return true @@ -452,12 +452,12 @@ SendStream.prototype.isRangeFresh = function isRangeFresh () { // if-range as etag if (ifRange.indexOf('"') !== -1) { - var etag = this.res.getHeader('ETag') + const etag = this.res.getHeader('ETag') return Boolean(etag && ifRange.indexOf(etag) !== -1) } // if-range as modified date - var lastModified = this.res.getHeader('Last-Modified') + const lastModified = this.res.getHeader('Last-Modified') return parseHttpDate(lastModified) <= parseHttpDate(ifRange) } @@ -469,7 +469,7 @@ SendStream.prototype.isRangeFresh = function isRangeFresh () { */ SendStream.prototype.redirect = function redirect (path) { - var res = this.res + const res = this.res if (hasListeners(this, 'directory')) { this.emit('directory', res, path) @@ -481,8 +481,8 @@ SendStream.prototype.redirect = function redirect (path) { return } - var loc = encodeUrl(collapseLeadingSlashes(this.path + '/')) - var doc = createHtmlDocument('Redirecting', 'Redirecting to ' + + const loc = encodeUrl(collapseLeadingSlashes(this.path + '/')) + const doc = createHtmlDocument('Redirecting', 'Redirecting to ' + escapeHtml(loc) + '') // redirect @@ -505,13 +505,13 @@ SendStream.prototype.redirect = function redirect (path) { SendStream.prototype.pipe = function pipe (res) { // root path - var root = this._root + const root = this._root // references this.res = res // decode the path - var path = decode(this.path) + let path = decode(this.path) if (path === -1) { this.error(400) return res @@ -523,7 +523,7 @@ SendStream.prototype.pipe = function pipe (res) { return res } - var parts + let parts if (root !== null) { // normalize if (path) { @@ -559,7 +559,7 @@ SendStream.prototype.pipe = function pipe (res) { // dotfile handling if (containsDotFile(parts)) { - var access = this._dotfiles + let access = this._dotfiles // legacy support if (access === undefined) { @@ -600,13 +600,13 @@ SendStream.prototype.pipe = function pipe (res) { */ 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 + let len = stat.size + const options = this.options + const opts = {} + const res = this.res + const req = this.req + let ranges = req.headers.range + let offset = options.start || 0 if (headersSent(res)) { // impossible to send now @@ -638,7 +638,7 @@ SendStream.prototype.send = function send (path, stat) { // adjust len to start/end options len = Math.max(0, len - offset) if (options.end !== undefined) { - var bytes = options.end - offset + 1 + const bytes = options.end - offset + 1 if (len > bytes) len = bytes } @@ -683,7 +683,7 @@ SendStream.prototype.send = function send (path, stat) { } // clone options - for (var prop in options) { + for (const prop in options) { opts[prop] = options[prop] } @@ -710,8 +710,8 @@ SendStream.prototype.send = function send (path, stat) { * @api private */ SendStream.prototype.sendFile = function sendFile (path) { - var i = 0 - var self = this + let i = 0 + const self = this debug('stat "%s"', path) fs.stat(path, function onstat (err, stat) { @@ -732,7 +732,7 @@ SendStream.prototype.sendFile = function sendFile (path) { : self.error(404) } - var p = path + '.' + self._extensions[i++] + const p = path + '.' + self._extensions[i++] debug('stat "%s"', p) fs.stat(p, function (err, stat) { @@ -751,8 +751,8 @@ SendStream.prototype.sendFile = function sendFile (path) { * @api private */ SendStream.prototype.sendIndex = function sendIndex (path) { - var i = -1 - var self = this + let i = -1 + const self = this function next (err) { if (++i >= self._index.length) { @@ -760,7 +760,7 @@ SendStream.prototype.sendIndex = function sendIndex (path) { return self.error(404) } - var p = join(path, self._index[i]) + const p = join(path, self._index[i]) debug('stat "%s"', p) fs.stat(p, function (err, stat) { @@ -783,11 +783,11 @@ SendStream.prototype.sendIndex = function sendIndex (path) { */ SendStream.prototype.stream = function stream (path, options) { - var self = this - var res = this.res + const self = this + const res = this.res // pipe - var stream = fs.createReadStream(path, options) + const stream = fs.createReadStream(path, options) this.emit('stream', stream) stream.pipe(res) @@ -823,18 +823,18 @@ SendStream.prototype.stream = function stream (path, options) { */ SendStream.prototype.type = function type (path) { - var res = this.res + const res = this.res if (res.getHeader('Content-Type')) return - var type = mime.lookup(path) + const type = mime.lookup(path) if (!type) { debug('no content-type') return } - var charset = mime.charsets.lookup(type) + const charset = mime.charsets.lookup(type) debug('content-type %s', type) res.setHeader('Content-Type', type + (charset ? '; charset=' + charset : '')) @@ -850,7 +850,7 @@ SendStream.prototype.type = function type (path) { */ SendStream.prototype.setHeader = function setHeader (path, stat) { - var res = this.res + const res = this.res this.emit('headers', res, path, stat) @@ -860,7 +860,7 @@ SendStream.prototype.setHeader = function setHeader (path, stat) { } if (this._cacheControl && !res.getHeader('Cache-Control')) { - var cacheControl = 'public, max-age=' + Math.floor(this._maxage / 1000) + let cacheControl = 'public, max-age=' + Math.floor(this._maxage / 1000) if (this._immutable) { cacheControl += ', immutable' @@ -871,13 +871,13 @@ SendStream.prototype.setHeader = function setHeader (path, stat) { } if (this._lastModified && !res.getHeader('Last-Modified')) { - var modified = stat.mtime.toUTCString() + const modified = stat.mtime.toUTCString() debug('modified %s', modified) res.setHeader('Last-Modified', modified) } if (this._etag && !res.getHeader('ETag')) { - var val = etag(stat) + const val = etag(stat) debug('etag %s', val) res.setHeader('ETag', val) } @@ -891,9 +891,9 @@ SendStream.prototype.setHeader = function setHeader (path, stat) { */ function clearHeaders (res) { - var headers = getHeaderNames(res) + const headers = getHeaderNames(res) - for (var i = 0; i < headers.length; i++) { + for (let i = 0; i < headers.length; i++) { res.removeHeader(headers[i]) } } @@ -905,7 +905,8 @@ function clearHeaders (res) { * @private */ function collapseLeadingSlashes (str) { - for (var i = 0; i < str.length; i++) { + let i = 0 + for (i; i < str.length; i++) { if (str[i] !== '/') { break } @@ -923,8 +924,8 @@ function collapseLeadingSlashes (str) { */ function containsDotFile (parts) { - for (var i = 0; i < parts.length; i++) { - var part = parts[i] + for (let i = 0; i < parts.length; i++) { + const part = parts[i] if (part.length > 1 && part[0] === '.') { return true } @@ -1029,7 +1030,7 @@ function getHeaderNames (res) { */ function hasListeners (emitter, type) { - var count = typeof emitter.listenerCount !== 'function' + const count = typeof emitter.listenerCount !== 'function' ? emitter.listeners(type).length : emitter.listenerCount(type) @@ -1059,9 +1060,9 @@ function headersSent (res) { */ function normalizeList (val, name) { - var list = [].concat(val || []) + const list = [].concat(val || []) - for (var i = 0; i < list.length; i++) { + for (let i = 0; i < list.length; i++) { if (typeof list[i] !== 'string') { throw new TypeError(name + ' must be array of strings or false') } @@ -1078,7 +1079,7 @@ function normalizeList (val, name) { */ function parseHttpDate (date) { - var timestamp = date && Date.parse(date) + const timestamp = date && Date.parse(date) return typeof timestamp === 'number' ? timestamp @@ -1093,12 +1094,12 @@ function parseHttpDate (date) { */ function parseTokenList (str) { - var end = 0 - var list = [] - var start = 0 + let end = 0 + const list = [] + let start = 0 // gather tokens - for (var i = 0, len = str.length; i < len; i++) { + for (let i = 0, len = str.length; i < len; i++) { switch (str.charCodeAt(i)) { case 0x20: /* */ if (start === end) { @@ -1134,10 +1135,10 @@ function parseTokenList (str) { */ function setHeaders (res, headers) { - var keys = Object.keys(headers) + const keys = Object.keys(headers) - for (var i = 0; i < keys.length; i++) { - var key = keys[i] + for (let i = 0; i < keys.length; i++) { + const key = keys[i] res.setHeader(key, headers[key]) } } diff --git a/package.json b/package.json index 2706bcc..fd1d5ea 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "file", "server" ], + "standard": { + "env": [ "mocha" ] + }, "dependencies": { "debug": "^4.3.4", "depd": "2.0.0", @@ -32,15 +35,10 @@ }, "devDependencies": { "after": "0.8.2", - "eslint": "7.32.0", - "eslint-config-standard": "14.1.1", - "eslint-plugin-import": "2.25.4", - "eslint-plugin-markdown": "2.2.1", - "eslint-plugin-node": "11.1.0", - "eslint-plugin-promise": "5.2.0", - "eslint-plugin-standard": "4.1.0", "mocha": "9.2.2", "nyc": "15.1.0", + "snazzy": "^9.0.0", + "standard": "^17.0.0", "supertest": "6.2.2" }, "files": [ @@ -50,13 +48,9 @@ "SECURITY.md", "index.js" ], - "engines": { - "node": ">= 0.8.0" - }, "scripts": { - "lint": "eslint .", - "test": "mocha --check-leaks --reporter spec --bail", - "test-ci": "nyc --reporter=lcov --reporter=text npm test", - "test-cov": "nyc --reporter=html --reporter=text npm test" + "lint": "standard | snazzy", + "lint:fix": "standard --fix | snazzy", + "test": "mocha --check-leaks --reporter spec --bail" } } diff --git a/test/.eslintrc.yml b/test/.eslintrc.yml deleted file mode 100644 index 9808c3b..0000000 --- a/test/.eslintrc.yml +++ /dev/null @@ -1,2 +0,0 @@ -env: - mocha: true diff --git a/test/send.js b/test/send.js index d419f8f..c79c2bc 100644 --- a/test/send.js +++ b/test/send.js @@ -1,19 +1,19 @@ 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('..') +const after = require('after') +const assert = require('assert') +const fs = require('fs') +const http = require('http') +const path = require('path') +const request = require('supertest') +const 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) { +const dateRegExp = /^\w{3}, \d+ \w+ \d+ \d+:\d+:\d+ \w+$/ +const fixtures = path.join(__dirname, 'fixtures') +const app = http.createServer(function (req, res) { function error (err) { res.statusCode = err.status res.end(http.STATUS_CODES[err.status]) @@ -64,14 +64,14 @@ describe('send(file).pipe(res)', function () { }) it('should treat an ENAMETOOLONG as a 404', function (done) { - var path = Array(100).join('foobar') + const 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) { + const app = http.createServer(function (req, res) { res.write('0') send(req, req.url, { root: fixtures }) .on('error', function (err) { res.end(' - ' + err.message) }) @@ -123,7 +123,7 @@ describe('send(file).pipe(res)', function () { }) it('should emit ENOENT if the file does not exist', function (done) { - var app = http.createServer(function (req, res) { + const app = http.createServer(function (req, res) { send(req, req.url, { root: fixtures }) .on('error', function (err) { res.end(err.statusCode + ' ' + err.code) }) .pipe(res) @@ -135,7 +135,7 @@ describe('send(file).pipe(res)', function () { }) it('should not override content-type', function (done) { - var app = http.createServer(function (req, res) { + const app = http.createServer(function (req, res) { res.setHeader('Content-Type', 'application/x-custom') send(req, req.url, { root: fixtures }).pipe(res) }) @@ -158,11 +158,11 @@ describe('send(file).pipe(res)', function () { }) it('should 404 if file disappears after stat, before open', function (done) { - var app = http.createServer(function (req, res) { + const 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 + const fn = this.send this.send = function (path, stat) { fn.call(this, (path + '__xxx_no_exist'), stat) } @@ -176,7 +176,7 @@ describe('send(file).pipe(res)', function () { }) it('should 500 on file stream error', function (done) { - var app = http.createServer(function (req, res) { + const app = http.createServer(function (req, res) { send(req, req.url, { root: 'test/fixtures' }) .on('stream', function (stream) { // simulate file error @@ -194,8 +194,8 @@ describe('send(file).pipe(res)', function () { describe('"headers" event', function () { it('should fire when sending file', function (done) { - var cb = after(2, done) - var server = http.createServer(function (req, res) { + const cb = after(2, done) + const server = http.createServer(function (req, res) { send(req, req.url, { root: fixtures }) .on('headers', function () { cb() }) .pipe(res) @@ -207,8 +207,8 @@ describe('send(file).pipe(res)', function () { }) it('should not fire on 404', function (done) { - var cb = after(1, done) - var server = http.createServer(function (req, res) { + const cb = after(1, done) + const server = http.createServer(function (req, res) { send(req, req.url, { root: fixtures }) .on('headers', function () { cb() }) .pipe(res) @@ -220,8 +220,8 @@ describe('send(file).pipe(res)', function () { }) it('should fire on index', function (done) { - var cb = after(2, done) - var server = http.createServer(function (req, res) { + const cb = after(2, done) + const server = http.createServer(function (req, res) { send(req, req.url, { root: fixtures }) .on('headers', function () { cb() }) .pipe(res) @@ -233,8 +233,8 @@ describe('send(file).pipe(res)', function () { }) it('should not fire on redirect', function (done) { - var cb = after(1, done) - var server = http.createServer(function (req, res) { + const cb = after(1, done) + const server = http.createServer(function (req, res) { send(req, req.url, { root: fixtures }) .on('headers', function () { cb() }) .pipe(res) @@ -246,8 +246,8 @@ describe('send(file).pipe(res)', function () { }) it('should provide path', function (done) { - var cb = after(2, done) - var server = http.createServer(function (req, res) { + const cb = after(2, done) + const server = http.createServer(function (req, res) { send(req, req.url, { root: fixtures }) .on('headers', onHeaders) .pipe(res) @@ -265,8 +265,8 @@ describe('send(file).pipe(res)', function () { }) it('should provide stat', function (done) { - var cb = after(2, done) - var server = http.createServer(function (req, res) { + const cb = after(2, done) + const server = http.createServer(function (req, res) { send(req, req.url, { root: fixtures }) .on('headers', onHeaders) .pipe(res) @@ -285,7 +285,7 @@ describe('send(file).pipe(res)', function () { }) it('should allow altering headers', function (done) { - var server = http.createServer(function (req, res) { + const server = http.createServer(function (req, res) { send(req, req.url, { root: fixtures }) .on('headers', onHeaders) .pipe(res) @@ -312,7 +312,7 @@ describe('send(file).pipe(res)', function () { describe('when "directory" listeners are present', function () { it('should be called when sending directory', function (done) { - var server = http.createServer(function (req, res) { + const server = http.createServer(function (req, res) { send(req, req.url, { root: fixtures }) .on('directory', onDirectory) .pipe(res) @@ -329,7 +329,7 @@ describe('send(file).pipe(res)', function () { }) it('should be called with path', function (done) { - var server = http.createServer(function (req, res) { + const server = http.createServer(function (req, res) { send(req, req.url, { root: fixtures }) .on('directory', onDirectory) .pipe(res) @@ -377,7 +377,7 @@ describe('send(file).pipe(res)', function () { }) it('should respond with an HTML redirect', function (done) { - var app = http.createServer(function (req, res) { + const app = http.createServer(function (req, res) { send(req, req.url.replace('/snow', '/snow ☃'), { root: 'test/fixtures' }) .pipe(res) }) @@ -405,7 +405,7 @@ describe('send(file).pipe(res)', function () { }) it('should remove all previously-set headers', function (done) { - var server = createServer({ root: fixtures }, function (req, res) { + const server = createServer({ root: fixtures }, function (req, res) { res.setHeader('X-Foo', 'bar') }) @@ -418,7 +418,7 @@ describe('send(file).pipe(res)', function () { describe('with conditional-GET', function () { it('should remove Content headers with 304', function (done) { - var server = createServer({ root: fixtures }, function (req, res) { + 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') @@ -441,7 +441,7 @@ describe('send(file).pipe(res)', function () { }) it('should not remove all Content-* headers', function (done) { - var server = createServer({ root: fixtures }, function (req, res) { + const server = createServer({ root: fixtures }, function (req, res) { res.setHeader('Content-Location', 'http://localhost/name.txt') res.setHeader('Content-Security-Policy', 'default-src \'self\'') }) @@ -507,8 +507,8 @@ describe('send(file).pipe(res)', function () { .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) + const lmod = new Date(res.headers['last-modified']) + const date = new Date(lmod - 60000) request(app) .get('/name.txt') .set('If-Modified-Since', date.toUTCString()) @@ -561,8 +561,8 @@ describe('send(file).pipe(res)', function () { .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() + const lmod = new Date(res.headers['last-modified']) + const date = new Date(lmod - 60000).toUTCString() request(app) .get('/name.txt') .set('If-Unmodified-Since', date) @@ -666,7 +666,7 @@ describe('send(file).pipe(res)', function () { }) it('should emit error 416 with content-range header', function (done) { - var server = http.createServer(function (req, res) { + const 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']) @@ -717,7 +717,7 @@ describe('send(file).pipe(res)', function () { .get('/nums.txt') .expect(200, function (err, res) { if (err) return done(err) - var etag = res.headers.etag + const etag = res.headers.etag request(app) .get('/nums.txt') @@ -732,7 +732,7 @@ describe('send(file).pipe(res)', function () { .get('/nums.txt') .expect(200, function (err, res) { if (err) return done(err) - var etag = res.headers.etag.replace(/"(.)/, '"0$1') + const etag = res.headers.etag.replace(/"(.)/, '"0$1') request(app) .get('/nums.txt') @@ -747,7 +747,7 @@ describe('send(file).pipe(res)', function () { .get('/nums.txt') .expect(200, function (err, res) { if (err) return done(err) - var modified = res.headers['last-modified'] + const modified = res.headers['last-modified'] request(app) .get('/nums.txt') @@ -762,7 +762,7 @@ describe('send(file).pipe(res)', function () { .get('/nums.txt') .expect(200, function (err, res) { if (err) return done(err) - var modified = Date.parse(res.headers['last-modified']) - 20000 + const modified = Date.parse(res.headers['last-modified']) - 20000 request(app) .get('/nums.txt') @@ -813,7 +813,7 @@ describe('send(file).pipe(res)', function () { describe('.etag()', function () { it('should support disabling etags', function (done) { - var app = http.createServer(function (req, res) { + const app = http.createServer(function (req, res) { send(req, req.url, { root: fixtures }) .etag(false) .pipe(res) @@ -828,7 +828,7 @@ describe('send(file).pipe(res)', function () { describe('.from()', function () { it('should set with deprecated from', function (done) { - var app = http.createServer(function (req, res) { + const app = http.createServer(function (req, res) { send(req, req.url) .from(fixtures) .pipe(res) @@ -842,7 +842,7 @@ describe('send(file).pipe(res)', function () { describe('.hidden()', function () { it('should default support sending hidden files', function (done) { - var app = http.createServer(function (req, res) { + const app = http.createServer(function (req, res) { send(req, req.url, { root: fixtures }) .hidden(true) .pipe(res) @@ -856,7 +856,7 @@ describe('send(file).pipe(res)', function () { describe('.index()', function () { it('should be configurable', function (done) { - var app = http.createServer(function (req, res) { + const app = http.createServer(function (req, res) { send(req, req.url, { root: fixtures }) .index('tobi.html') .pipe(res) @@ -868,7 +868,7 @@ describe('send(file).pipe(res)', function () { }) it('should support disabling', function (done) { - var app = http.createServer(function (req, res) { + const app = http.createServer(function (req, res) { send(req, req.url, { root: fixtures }) .index(false) .pipe(res) @@ -880,7 +880,7 @@ describe('send(file).pipe(res)', function () { }) it('should support fallbacks', function (done) { - var app = http.createServer(function (req, res) { + const app = http.createServer(function (req, res) { send(req, req.url, { root: fixtures }) .index(['default.htm', 'index.html']) .pipe(res) @@ -894,7 +894,7 @@ describe('send(file).pipe(res)', function () { describe('.maxage()', function () { it('should default to 0', function (done) { - var app = http.createServer(function (req, res) { + const app = http.createServer(function (req, res) { send(req, 'test/fixtures/name.txt') .maxage(undefined) .pipe(res) @@ -906,7 +906,7 @@ describe('send(file).pipe(res)', function () { }) it('should floor to integer', function (done) { - var app = http.createServer(function (req, res) { + const app = http.createServer(function (req, res) { send(req, 'test/fixtures/name.txt') .maxage(1234) .pipe(res) @@ -918,7 +918,7 @@ describe('send(file).pipe(res)', function () { }) it('should accept string', function (done) { - var app = http.createServer(function (req, res) { + const app = http.createServer(function (req, res) { send(req, 'test/fixtures/name.txt') .maxage('30d') .pipe(res) @@ -930,7 +930,7 @@ describe('send(file).pipe(res)', function () { }) it('should max at 1 year', function (done) { - var app = http.createServer(function (req, res) { + const app = http.createServer(function (req, res) { send(req, 'test/fixtures/name.txt') .maxage(Infinity) .pipe(res) @@ -944,7 +944,7 @@ describe('send(file).pipe(res)', function () { describe('.root()', function () { it('should set root', function (done) { - var app = http.createServer(function (req, res) { + const app = http.createServer(function (req, res) { send(req, req.url) .root(fixtures) .pipe(res) @@ -1169,7 +1169,7 @@ describe('send(file, options)', function () { }) it('should 403 for dotfile without root', function (done) { - var server = http.createServer(function onRequest (req, res) { + const server = http.createServer(function onRequest (req, res) { send(req, fixtures + '/.mine' + req.url, { dotfiles: 'deny' }).pipe(res) }) @@ -1223,7 +1223,7 @@ describe('send(file, options)', function () { }) it('should 404 for dotfile without root', function (done) { - var server = http.createServer(function onRequest (req, res) { + const server = http.createServer(function onRequest (req, res) { send(req, fixtures + '/.mine' + req.url, { dotfiles: 'ignore' }).pipe(res) }) @@ -1344,8 +1344,8 @@ describe('send(file, options)', function () { }) it('should work without root', function (done) { - var server = http.createServer(function (req, res) { - var p = path.join(fixtures, 'pets').replace(/\\/g, '/') + '/' + const server = http.createServer(function (req, res) { + const p = path.join(fixtures, 'pets').replace(/\\/g, '/') + '/' send(req, p, { index: ['index.html'] }) .pipe(res) }) @@ -1365,7 +1365,7 @@ describe('send(file, options)', function () { }) it('should work with trailing slash', function (done) { - var app = http.createServer(function (req, res) { + const app = http.createServer(function (req, res) { send(req, req.url, { root: fixtures + '/' }) .pipe(res) }) @@ -1376,7 +1376,7 @@ describe('send(file, options)', function () { }) it('should work with empty path', function (done) { - var app = http.createServer(function (req, res) { + const app = http.createServer(function (req, res) { send(req, '', { root: fixtures }) .pipe(res) }) @@ -1393,7 +1393,7 @@ describe('send(file, options)', function () { // regressions around this use-case. // it('should try as file with empty path', function (done) { - var app = http.createServer(function (req, res) { + const app = http.createServer(function (req, res) { send(req, '', { root: path.join(fixtures, 'name.txt') }) .pipe(res) }) @@ -1410,7 +1410,7 @@ describe('send(file, options)', function () { }) it('should allow .. in root', function (done) { - var app = http.createServer(function (req, res) { + const app = http.createServer(function (req, res) { send(req, req.url, { root: fixtures + '/../fixtures' }) .pipe(res) }) @@ -1435,7 +1435,7 @@ describe('send(file, options)', function () { describe('when missing', function () { it('should consider .. malicious', function (done) { - var app = http.createServer(function (req, res) { + const app = http.createServer(function (req, res) { send(req, fixtures + req.url) .pipe(res) }) @@ -1446,7 +1446,7 @@ describe('send(file, options)', function () { }) it('should still serve files with dots in name', function (done) { - var app = http.createServer(function (req, res) { + const app = http.createServer(function (req, res) { send(req, fixtures + req.url) .pipe(res) }) From b05ea94fcc1f7a73ea361f02fe5443f2072d0a09 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Jan 2023 07:57:08 +0000 Subject: [PATCH 006/109] build(deps-dev): bump supertest from 6.2.2 to 6.3.3 (#8) Bumps [supertest](https://github.com/visionmedia/supertest) from 6.2.2 to 6.3.3. - [Release notes](https://github.com/visionmedia/supertest/releases) - [Commits](https://github.com/visionmedia/supertest/compare/v6.2.2...v6.3.3) --- updated-dependencies: - dependency-name: supertest dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fd1d5ea..5d628a6 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "nyc": "15.1.0", "snazzy": "^9.0.0", "standard": "^17.0.0", - "supertest": "6.2.2" + "supertest": "6.3.3" }, "files": [ "HISTORY.md", From 8a01a72ae8918a9d9c6f19c5201459129dbff939 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Jan 2023 07:58:09 +0000 Subject: [PATCH 007/109] build(deps-dev): bump mocha from 9.2.2 to 10.2.0 (#9) Bumps [mocha](https://github.com/mochajs/mocha) from 9.2.2 to 10.2.0. - [Release notes](https://github.com/mochajs/mocha/releases) - [Changelog](https://github.com/mochajs/mocha/blob/master/CHANGELOG.md) - [Commits](https://github.com/mochajs/mocha/compare/v9.2.2...v10.2.0) --- updated-dependencies: - dependency-name: mocha dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5d628a6..e605374 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ }, "devDependencies": { "after": "0.8.2", - "mocha": "9.2.2", + "mocha": "10.2.0", "nyc": "15.1.0", "snazzy": "^9.0.0", "standard": "^17.0.0", From e86dba564c4863f04c33cd6f68da6bafee2eb9f7 Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Thu, 12 Jan 2023 10:19:50 +0100 Subject: [PATCH 008/109] replace mocha with tap (#10) * replace mocha with tap * fix coverage issue --- .taprc | 4 + package.json | 11 +- test/mime.test.js | 52 ++ test/send-pipe.test.js | 1623 ++++++++++++++++++++++++++++++++++++++++ test/send.js | 1518 ------------------------------------- test/send.test.js | 679 +++++++++++++++++ test/utils.js | 28 + 7 files changed, 2390 insertions(+), 1525 deletions(-) create mode 100644 .taprc create mode 100644 test/mime.test.js create mode 100644 test/send-pipe.test.js delete mode 100644 test/send.js create mode 100644 test/send.test.js create mode 100644 test/utils.js diff --git a/.taprc b/.taprc new file mode 100644 index 0000000..252c3aa --- /dev/null +++ b/.taprc @@ -0,0 +1,4 @@ +files: + - test/**/*.test.js + +branches: 96 diff --git a/package.json b/package.json index e605374..60c375f 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,6 @@ "file", "server" ], - "standard": { - "env": [ "mocha" ] - }, "dependencies": { "debug": "^4.3.4", "depd": "2.0.0", @@ -35,11 +32,10 @@ }, "devDependencies": { "after": "0.8.2", - "mocha": "10.2.0", - "nyc": "15.1.0", "snazzy": "^9.0.0", "standard": "^17.0.0", - "supertest": "6.3.3" + "supertest": "6.3.3", + "tap": "^16.3.3" }, "files": [ "HISTORY.md", @@ -51,6 +47,7 @@ "scripts": { "lint": "standard | snazzy", "lint:fix": "standard --fix | snazzy", - "test": "mocha --check-leaks --reporter spec --bail" + "test": "npm run test:unit", + "test:unit": "tap" } } diff --git a/test/mime.test.js b/test/mime.test.js new file mode 100644 index 0000000..b904c24 --- /dev/null +++ b/test/mime.test.js @@ -0,0 +1,52 @@ +'use strict' + +process.env.NO_DEPRECATION = 'send' + +const { test } = require('tap') +const path = require('path') +const request = require('supertest') +const send = require('..') +const { shouldNotHaveHeader, createServer } = require('./utils') + +const fixtures = path.join(__dirname, 'fixtures') + +test('send.mime', function (t) { + t.plan(2) + + t.test('should be exposed', function (t) { + t.plan(1) + t.ok(send.mime) + }) + + t.test('.default_type', function (t) { + t.plan(2) + + t.before(function () { + this.default_type = send.mime.default_type + }) + + t.afterEach(function () { + send.mime.default_type = this.default_type + }) + + t.test('should change the default type', function (t) { + t.plan(1) + send.mime.default_type = 'text/plain' + + request(createServer({ root: fixtures })) + .get('/no_ext') + .expect('Content-Type', 'text/plain; charset=UTF-8') + .expect(200, () => t.pass()) + }) + + t.test('should not add Content-Type for undefined default', function (t) { + t.plan(2) + send.mime.default_type = undefined + + request(createServer({ root: fixtures })) + .get('/no_ext') + .expect(shouldNotHaveHeader('Content-Type', t)) + .expect(200, () => t.pass()) + }) + }) +}) diff --git a/test/send-pipe.test.js b/test/send-pipe.test.js new file mode 100644 index 0000000..69d3ce6 --- /dev/null +++ b/test/send-pipe.test.js @@ -0,0 +1,1623 @@ +'use strict' + +process.env.NO_DEPRECATION = 'send' + +const { test } = require('tap') +const after = require('after') +const fs = require('fs') +const http = require('http') +const path = require('path') +const request = require('supertest') +const send = require('..') +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).pipe(res)', function (t) { + t.plan(32) + + t.test('should stream the file contents', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/name.txt') + .expect('Content-Length', '4') + .expect(200, 'tobi', () => t.pass()) + }) + + t.test('should stream a zero-length file', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/empty.txt') + .expect('Content-Length', '0') + .expect(200, '', () => t.pass()) + }) + + t.test('should decode the given path as a URI', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/some%20thing.txt') + .expect(200, 'hey', () => t.pass()) + }) + + t.test('should serve files with dots in name', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/do..ts.txt') + .expect(200, '...', () => t.pass()) + }) + + t.test('should treat a malformed URI as a bad request', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/some%99thing.txt') + .expect(400, 'Bad Request', () => t.pass()) + }) + + t.test('should 400 on NULL bytes', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/some%00thing.txt') + .expect(400, 'Bad Request', () => t.pass()) + }) + + t.test('should treat an ENAMETOOLONG as a 404', function (t) { + t.plan(1) + + const 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) + }) + + const path = Array(100).join('foobar') + request(app) + .get('/' + path) + .expect(404, () => t.pass()) + }) + + t.test('should handle headers already sent error', function (t) { + t.plan(1) + + const 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.', () => t.pass()) + }) + + t.test('should support HEAD', function (t) { + t.plan(2) + + const 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) + }) + + request(app) + .head('/name.txt') + .expect(200) + .expect('Content-Length', '4') + .expect(shouldNotHaveBody(t)) + .end(() => t.pass()) + }) + + t.test('should add an ETag header field', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/name.txt') + .expect('etag', /^W\/"[^"]+"$/) + .end(() => t.pass()) + }) + + t.test('should add a Date header field', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/name.txt') + .expect('date', dateRegExp, () => t.pass()) + }) + + t.test('should add a Last-Modified header field', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/name.txt') + .expect('last-modified', dateRegExp, () => t.pass()) + }) + + t.test('should add a Accept-Ranges header field', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/name.txt') + .expect('Accept-Ranges', 'bytes', () => t.pass()) + }) + + t.test('should 404 if the file does not exist', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/meow') + .expect(404, 'Not Found', () => t.pass()) + }) + + t.test('should emit ENOENT if the file does not exist', function (t) { + t.plan(1) + + const 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', () => t.pass()) + }) + + t.test('should not override content-type', function (t) { + t.plan(1) + + const 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', () => t.pass()) + }) + + t.test('should set Content-Type via mime map', function (t) { + t.plan(2) + + const 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) + }) + + request(app) + .get('/name.txt') + .expect('Content-Type', 'text/plain; charset=UTF-8') + .expect(200, function (err) { + t.error(err) + request(app) + .get('/tobi.html') + .expect('Content-Type', 'text/html; charset=UTF-8') + .expect(200, () => t.pass()) + }) + }) + + t.test('should 404 if file disappears after stat, before open', function (t) { + t.plan(1) + + const app = http.createServer(function (req, res) { + send(req, req.url, { root: 'test/fixtures' }) + .on('file', function () { + // simulate file ENOENT after on open, after stat + const 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, () => t.pass()) + }) + + t.test('should 500 on file stream error', function (t) { + t.plan(1) + + const 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, () => t.pass()) + }) + + t.test('"headers" event', function (t) { + t.plan(7) + t.test('should fire when sending file', function (t) { + t.plan(1) + const cb = after(2, () => t.pass()) + const 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) + }) + + t.test('should not fire on 404', function (t) { + t.plan(1) + const cb = after(1, () => t.pass()) + const 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) + }) + + t.test('should fire on index', function (t) { + t.plan(1) + const cb = after(2, () => t.pass()) + const 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) + }) + + t.test('should not fire on redirect', function (t) { + t.plan(1) + const cb = after(1, () => t.pass()) + const 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) + }) + + t.test('should provide path', function (t) { + t.plan(3) + const cb = after(2, () => t.pass()) + const server = http.createServer(function (req, res) { + send(req, req.url, { root: fixtures }) + .on('headers', onHeaders) + .pipe(res) + }) + + function onHeaders (res, filePath) { + t.ok(filePath) + t.strictSame(path.normalize(filePath), path.normalize(path.join(fixtures, 'name.txt'))) + cb() + } + + request(server) + .get('/name.txt') + .expect(200, 'tobi', cb) + }) + + t.test('should provide stat', function (t) { + t.plan(4) + const cb = after(2, () => t.pass()) + const server = http.createServer(function (req, res) { + send(req, req.url, { root: fixtures }) + .on('headers', onHeaders) + .pipe(res) + }) + + function onHeaders (res, path, stat) { + t.ok(stat) + t.ok('ctime' in stat) + t.ok('mtime' in stat) + cb() + } + + request(server) + .get('/name.txt') + .expect(200, 'tobi', cb) + }) + + t.test('should allow altering headers', function (t) { + t.plan(1) + const 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(() => t.pass()) + }) + }) + + t.test('when "directory" listeners are present', function (t) { + t.plan(2) + + t.test('should be called when sending directory', function (t) { + t.plan(1) + const 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', () => t.pass()) + }) + + t.test('should be called with path', function (t) { + t.plan(1) + const 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')), () => t.pass()) + }) + }) + + t.test('when no "directory" listeners are present', function (t) { + t.plan(5) + + t.test('should redirect directories to trailing slash', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/pets') + .expect('Location', '/pets/') + .expect(301, () => t.pass()) + }) + + t.test('should respond with an HTML redirect', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/pets') + .expect('Location', '/pets/') + .expect('Content-Type', /html/) + .expect(301, />Redirecting to \/pets\/<\/a> t.pass()) + }) + + t.test('should respond with default Content-Security-Policy', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/pets') + .expect('Location', '/pets/') + .expect('Content-Security-Policy', "default-src 'none'") + .expect(301, () => t.pass()) + }) + + t.test('should not redirect to protocol-relative locations', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('//pets') + .expect('Location', '/pets/') + .expect(301, () => t.pass()) + }) + + t.test('should respond with an HTML redirect', function (t) { + t.plan(1) + + const 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\/<\/a> t.pass()) + }) + }) + + t.test('when no "error" listeners are present', function (t) { + t.plan(3) + + t.test('should respond to errors directly', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/foobar') + .expect(404, />Not Found t.pass()) + }) + + t.test('should respond with default Content-Security-Policy', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/foobar') + .expect('Content-Security-Policy', "default-src 'none'") + .expect(404, () => t.pass()) + }) + + t.test('should remove all previously-set headers', function (t) { + t.plan(2) + + const server = createServer({ root: fixtures }, function (req, res) { + res.setHeader('X-Foo', 'bar') + }) + + request(server) + .get('/foobar') + .expect(shouldNotHaveHeader('X-Foo', t)) + .expect(404, () => t.pass()) + }) + }) + + t.test('with conditional-GET', function (t) { + t.plan(6) + + t.test('should remove Content headers with 304', function (t) { + t.plan(5) + + 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') + }) + + request(server) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(server) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .expect(shouldNotHaveHeader('Content-Language', t)) + .expect(shouldNotHaveHeader('Content-Length', t)) + .expect(shouldNotHaveHeader('Content-Type', t)) + .expect('Content-Location', 'http://localhost/name.txt') + .expect('Contents', 'foo') + .expect(304, () => t.pass()) + }) + }) + + t.test('should not remove all Content-* headers', function (t) { + t.plan(4) + + const 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) { + t.error(err) + request(server) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .expect(shouldNotHaveHeader('Content-Length', t)) + .expect(shouldNotHaveHeader('Content-Type', t)) + .expect('Content-Location', 'http://localhost/name.txt') + .expect('Content-Security-Policy', 'default-src \'self\'') + .expect(304, () => t.pass()) + }) + }) + + t.test('where "If-Match" is set', function (t) { + t.plan(3) + + t.test('should respond with 200 when "*"', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/name.txt') + .set('If-Match', '*') + .expect(200, () => t.pass()) + }) + + t.test('should respond with 412 when ETag unmatched', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/name.txt') + .set('If-Match', ' "foo",, "bar" ,') + .expect(412, () => t.pass()) + }) + + t.test('should respond with 200 when ETag matched', function (t) { + t.plan(2) + + const 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) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-Match', '"foo", "bar", ' + res.headers.etag) + .expect(200, () => t.pass()) + }) + }) + }) + + t.test('where "If-Modified-Since" is set', function (t) { + t.plan(2) + + t.test('should respond with 304 when unmodified', function (t) { + t.plan(2) + + const 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) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-Modified-Since', res.headers['last-modified']) + .expect(304, () => t.pass()) + }) + }) + + t.test('should respond with 200 when modified', function (t) { + t.plan(2) + + const 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) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + const lmod = new Date(res.headers['last-modified']) + const date = new Date(lmod - 60000) + request(app) + .get('/name.txt') + .set('If-Modified-Since', date.toUTCString()) + .expect(200, 'tobi', () => t.pass()) + }) + }) + }) + + t.test('where "If-None-Match" is set', function (t) { + t.plan(2) + + t.test('should respond with 304 when ETag matched', function (t) { + t.plan(2) + + const 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) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .expect(304, () => t.pass()) + }) + }) + + t.test('should respond with 200 when ETag unmatched', function (t) { + t.plan(2) + + const 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) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-None-Match', '"123"') + .expect(200, 'tobi', () => t.pass()) + }) + }) + }) + + t.test('where "If-Unmodified-Since" is set', function (t) { + t.plan(3) + + t.test('should respond with 200 when unmodified', function (t) { + t.plan(2) + + const 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) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-Unmodified-Since', res.headers['last-modified']) + .expect(200, () => t.pass()) + }) + }) + + t.test('should respond with 412 when modified', function (t) { + t.plan(2) + + const 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) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + const lmod = new Date(res.headers['last-modified']) + const date = new Date(lmod - 60000).toUTCString() + request(app) + .get('/name.txt') + .set('If-Unmodified-Since', date) + .expect(412, () => t.pass()) + }) + }) + + t.test('should respond with 200 when invalid date', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/name.txt') + .set('If-Unmodified-Since', 'foo') + .expect(200, () => t.pass()) + }) + }) + }) + + t.test('with Range request', function (t) { + t.plan(13) + + t.test('should support byte ranges', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=0-4') + .expect(206, '12345', () => t.pass()) + }) + + t.test('should ignore non-byte ranges', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'items=0-4') + .expect(200, '123456789', () => t.pass()) + }) + + t.test('should be inclusive', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=0-0') + .expect(206, '1', () => t.pass()) + }) + + t.test('should set Content-Range', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-5') + .expect('Content-Range', 'bytes 2-5/9') + .expect(206, () => t.pass()) + }) + + t.test('should support -n', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=-3') + .expect(206, '789', () => t.pass()) + }) + + t.test('should support n-', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=3-') + .expect(206, '456789', () => t.pass()) + }) + + t.test('should respond with 206 "Partial Content"', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=0-4') + .expect(206, () => t.pass()) + }) + + t.test('should set Content-Length to the # of octets transferred', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-3') + .expect('Content-Length', '2') + .expect(206, '34', () => t.pass()) + }) + + t.test('when last-byte-pos of the range is greater the length', function (t) { + t.plan(2) + + t.test('is taken to be equal to one less than the length', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-50') + .expect('Content-Range', 'bytes 2-8/9') + .expect(206, () => t.pass()) + }) + + t.test('should adapt the Content-Length accordingly', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-50') + .expect('Content-Length', '7') + .expect(206, () => t.pass()) + }) + }) + + t.test('when the first- byte-pos of the range is greater length', function (t) { + t.plan(2) + + t.test('should respond with 416', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=9-50') + .expect('Content-Range', 'bytes */9') + .expect(416, () => t.pass()) + }) + + t.test('should emit error 416 with content-range header', function (t) { + t.plan(1) + + const 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, () => t.pass()) + }) + }) + + t.test('when syntactically invalid', function (t) { + t.plan(1) + + t.test('should respond with 200 and the entire contents', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'asdf') + .expect(200, '123456789', () => t.pass()) + }) + }) + + t.test('when multiple ranges', function (t) { + t.plan(2) + + t.test('should respond with 200 and the entire contents', function (t) { + t.plan(2) + + const 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=1-1,3-') + .expect(shouldNotHaveHeader('Content-Range', t)) + .expect(200, '123456789', () => t.pass()) + }) + + t.test('should respond with 206 is all ranges can be combined', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=1-2,3-5') + .expect('Content-Range', 'bytes 1-5/9') + .expect(206, '23456', () => t.pass()) + }) + }) + + t.test('when if-range present', function (t) { + t.plan(5) + + t.test('should respond with parts when etag unchanged', function (t) { + t.plan(2) + + const 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) + }) + + request(app) + .get('/nums.txt') + .expect(200, function (err, res) { + t.error(err) + const etag = res.headers.etag + + request(app) + .get('/nums.txt') + .set('If-Range', etag) + .set('Range', 'bytes=0-0') + .expect(206, '1', () => t.pass()) + }) + }) + + t.test('should respond with 200 when etag changed', function (t) { + t.plan(2) + + const 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) + }) + + request(app) + .get('/nums.txt') + .expect(200, function (err, res) { + t.error(err) + const etag = res.headers.etag.replace(/"(.)/, '"0$1') + + request(app) + .get('/nums.txt') + .set('If-Range', etag) + .set('Range', 'bytes=0-0') + .expect(200, '123456789', () => t.pass()) + }) + }) + + t.test('should respond with parts when modified unchanged', function (t) { + t.plan(2) + + const 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) + }) + + request(app) + .get('/nums.txt') + .expect(200, function (err, res) { + t.error(err) + const modified = res.headers['last-modified'] + + request(app) + .get('/nums.txt') + .set('If-Range', modified) + .set('Range', 'bytes=0-0') + .expect(206, '1', () => t.pass()) + }) + }) + + t.test('should respond with 200 when modified changed', function (t) { + t.plan(2) + + const 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) + }) + + request(app) + .get('/nums.txt') + .expect(200, function (err, res) { + t.error(err) + const 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', () => t.pass()) + }) + }) + + t.test('should respond with 200 when invalid value', function (t) { + t.plan(1) + + const 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) + }) + + request(app) + .get('/nums.txt') + .set('If-Range', 'foo') + .set('Range', 'bytes=0-0') + .expect(200, '123456789', () => t.pass()) + }) + }) + }) + + t.test('when "options" is specified', function (t) { + t.plan(4) + + t.test('should support start/end', function (t) { + t.plan(1) + + request(createServer({ root: fixtures, start: 3, end: 5 })) + .get('/nums.txt') + .expect(200, '456', () => t.pass()) + }) + + t.test('should adjust too large end', function (t) { + t.plan(1) + + request(createServer({ root: fixtures, start: 3, end: 90 })) + .get('/nums.txt') + .expect(200, '456789', () => t.pass()) + }) + + t.test('should support start/end with Range request', function (t) { + t.plan(1) + + request(createServer({ root: fixtures, start: 0, end: 2 })) + .get('/nums.txt') + .set('Range', 'bytes=-2') + .expect(206, '23', () => t.pass()) + }) + + t.test('should support start/end with unsatisfiable Range request', function (t) { + t.plan(1) + + request(createServer({ root: fixtures, start: 0, end: 2 })) + .get('/nums.txt') + .set('Range', 'bytes=5-9') + .expect('Content-Range', 'bytes */3') + .expect(416, () => t.pass()) + }) + }) + + t.test('.etag()', function (t) { + t.plan(1) + + t.test('should support disabling etags', function (t) { + t.plan(2) + + const app = http.createServer(function (req, res) { + send(req, req.url, { root: fixtures }) + .etag(false) + .pipe(res) + }) + + request(app) + .get('/name.txt') + .expect(shouldNotHaveHeader('ETag', t)) + .expect(200, () => t.pass()) + }) + }) + + t.test('.from()', function (t) { + t.plan(1) + + t.test('should set with deprecated from', function (t) { + t.plan(1) + + const app = http.createServer(function (req, res) { + send(req, req.url) + .from(fixtures) + .pipe(res) + }) + + request(app) + .get('/pets/../name.txt') + .expect(200, 'tobi', () => t.pass()) + }) + }) + + t.test('.hidden()', function (t) { + t.plan(1) + + t.test('should default support sending hidden files', function (t) { + t.plan(1) + + const app = http.createServer(function (req, res) { + send(req, req.url, { root: fixtures }) + .hidden(true) + .pipe(res) + }) + + request(app) + .get('/.hidden.txt') + .expect(200, 'secret', () => t.pass()) + }) + }) + + t.test('.index()', function (t) { + t.plan(3) + + t.test('should be configurable', function (t) { + t.plan(1) + + const app = http.createServer(function (req, res) { + send(req, req.url, { root: fixtures }) + .index('tobi.html') + .pipe(res) + }) + + request(app) + .get('/') + .expect(200, '

tobi

', () => t.pass()) + }) + + t.test('should support disabling', function (t) { + t.plan(1) + + const app = http.createServer(function (req, res) { + send(req, req.url, { root: fixtures }) + .index(false) + .pipe(res) + }) + + request(app) + .get('/pets/') + .expect(403, () => t.pass()) + }) + + t.test('should support fallbacks', function (t) { + t.plan(1) + + const app = http.createServer(function (req, res) { + send(req, req.url, { root: fixtures }) + .index(['default.htm', 'index.html']) + .pipe(res) + }) + + request(app) + .get('/pets/') + .expect(200, fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8'), () => t.pass()) + }) + }) + + t.test('.maxage()', function (t) { + t.plan(4) + + t.test('should default to 0', function (t) { + t.plan(1) + + const app = http.createServer(function (req, res) { + send(req, 'test/fixtures/name.txt') + .maxage(undefined) + .pipe(res) + }) + + request(app) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=0', () => t.pass()) + }) + + t.test('should floor to integer', function (t) { + t.plan(1) + + const app = http.createServer(function (req, res) { + send(req, 'test/fixtures/name.txt') + .maxage(1234) + .pipe(res) + }) + + request(app) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=1', () => t.pass()) + }) + + t.test('should accept string', function (t) { + t.plan(1) + + const app = http.createServer(function (req, res) { + send(req, 'test/fixtures/name.txt') + .maxage('30d') + .pipe(res) + }) + + request(app) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=2592000', () => t.pass()) + }) + + t.test('should max at 1 year', function (t) { + t.plan(1) + + const app = http.createServer(function (req, res) { + send(req, 'test/fixtures/name.txt') + .maxage(Infinity) + .pipe(res) + }) + + request(app) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=31536000', () => t.pass()) + }) + }) + + t.test('.root()', function (t) { + t.plan(1) + + t.test('should set root', function (t) { + t.plan(1) + + const app = http.createServer(function (req, res) { + send(req, req.url) + .root(fixtures) + .pipe(res) + }) + + request(app) + .get('/pets/../name.txt') + .expect(200, 'tobi', () => t.pass()) + }) + }) +}) diff --git a/test/send.js b/test/send.js deleted file mode 100644 index c79c2bc..0000000 --- a/test/send.js +++ /dev/null @@ -1,1518 +0,0 @@ - -process.env.NO_DEPRECATION = 'send' - -const after = require('after') -const assert = require('assert') -const fs = require('fs') -const http = require('http') -const path = require('path') -const request = require('supertest') -const send = require('..') - -// test server - -const dateRegExp = /^\w{3}, \d+ \w+ \d+ \d+:\d+:\d+ \w+$/ -const fixtures = path.join(__dirname, 'fixtures') -const 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) { - const path = Array(100).join('foobar') - request(app) - .get('/' + path) - .expect(404, done) - }) - - it('should handle headers already sent error', function (done) { - const 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) { - const 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) { - const 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 404 if file disappears after stat, before open', function (done) { - const app = http.createServer(function (req, res) { - send(req, req.url, { root: 'test/fixtures' }) - .on('file', function () { - // simulate file ENOENT after on open, after stat - const 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) { - const 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) { - const cb = after(2, done) - const 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) { - const cb = after(1, done) - const 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) { - const cb = after(2, done) - const 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) { - const cb = after(1, done) - const 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) { - const cb = after(2, done) - const 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) { - const cb = after(2, done) - const 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) { - const 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) { - const 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) { - const 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\/<\/a>Redirecting to \/snow%20%E2%98%83\/<\/a>Not Foundtobi

', done) - }) - - it('should support disabling', function (done) { - const app = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) - .index(false) - .pipe(res) - }) - - request(app) - .get('/pets/') - .expect(403, done) - }) - - it('should support fallbacks', function (done) { - const app = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) - .index(['default.htm', 'index.html']) - .pipe(res) - }) - - request(app) - .get('/pets/') - .expect(200, fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8'), done) - }) - }) - - describe('.maxage()', function () { - it('should default to 0', function (done) { - const app = http.createServer(function (req, res) { - send(req, 'test/fixtures/name.txt') - .maxage(undefined) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect('Cache-Control', 'public, max-age=0', done) - }) - - it('should floor to integer', function (done) { - const app = http.createServer(function (req, res) { - send(req, 'test/fixtures/name.txt') - .maxage(1234) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect('Cache-Control', 'public, max-age=1', done) - }) - - it('should accept string', function (done) { - const app = http.createServer(function (req, res) { - send(req, 'test/fixtures/name.txt') - .maxage('30d') - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect('Cache-Control', 'public, max-age=2592000', done) - }) - - it('should max at 1 year', function (done) { - const app = http.createServer(function (req, res) { - send(req, 'test/fixtures/name.txt') - .maxage(Infinity) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect('Cache-Control', 'public, max-age=31536000', done) - }) - }) - - describe('.root()', function () { - it('should set root', function (done) { - const app = http.createServer(function (req, res) { - send(req, req.url) - .root(fixtures) - .pipe(res) - }) - - request(app) - .get('/pets/../name.txt') - .expect(200, 'tobi', 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('from', function () { - it('should set with deprecated from', function (done) { - request(createServer({ from: fixtures })) - .get('/pets/../name.txt') - .expect(200, 'tobi', done) - }) - }) - - describe('dotfiles', function () { - it('should default to "ignore"', function (done) { - request(createServer({ root: fixtures })) - .get('/.hidden.txt') - .expect(404, done) - }) - - it('should allow file within dotfile directory for back-compat', function (done) { - request(createServer({ root: fixtures })) - .get('/.mine/name.txt') - .expect(200, /tobi/, 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) { - const 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) { - const 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('hidden', function () { - it('should default to false', function (done) { - request(app) - .get('/.hidden.txt') - .expect(404, 'Not Found', done) - }) - - it('should default support sending hidden files', function (done) { - request(createServer({ hidden: true, root: fixtures })) - .get('/.hidden.txt') - .expect(200, 'secret', 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) { - const server = http.createServer(function (req, res) { - const p = path.join(fixtures, 'pets').replace(/\\/g, '/') + '/' - send(req, p, { index: ['index.html'] }) - .pipe(res) - }) - - request(server) - .get('/') - .expect(200, /tobi/, 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) { - const 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) { - const 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) { - const 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) { - const 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) { - const 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) { - const app = http.createServer(function (req, res) { - send(req, fixtures + req.url) - .pipe(res) - }) - - request(app) - .get('/do..ts.txt') - .expect(200, '...', done) - }) - }) - }) -}) - -describe('send.mime', function () { - it('should be exposed', function () { - assert.ok(send.mime) - }) - - describe('.default_type', function () { - before(function () { - this.default_type = send.mime.default_type - }) - - afterEach(function () { - send.mime.default_type = this.default_type - }) - - it('should change the default type', function (done) { - send.mime.default_type = 'text/plain' - - request(createServer({ root: fixtures })) - .get('/no_ext') - .expect('Content-Type', 'text/plain; charset=UTF-8') - .expect(200, done) - }) - - it('should not add Content-Type for undefined default', function (done) { - send.mime.default_type = undefined - - request(createServer({ root: fixtures })) - .get('/no_ext') - .expect(shouldNotHaveHeader('Content-Type')) - .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/send.test.js b/test/send.test.js new file mode 100644 index 0000000..6ca1205 --- /dev/null +++ b/test/send.test.js @@ -0,0 +1,679 @@ +'use strict' + +process.env.NO_DEPRECATION = 'send' + +const { test } = require('tap') +const fs = require('fs') +const http = require('http') +const path = require('path') +const request = require('supertest') +const send = require('..') +const { shouldNotHaveHeader, createServer } = require('./utils') + +// test server + +const fixtures = path.join(__dirname, 'fixtures') + +test('send(file, options)', function (t) { + t.plan(12) + + t.test('acceptRanges', function (t) { + t.plan(2) + + t.test('should support disabling accept-ranges', function (t) { + t.plan(2) + + request(createServer({ acceptRanges: false, root: fixtures })) + .get('/nums.txt') + .expect(shouldNotHaveHeader('Accept-Ranges', t)) + .expect(200, () => t.pass()) + }) + + t.test('should ignore requested range', function (t) { + t.plan(3) + + request(createServer({ acceptRanges: false, root: fixtures })) + .get('/nums.txt') + .set('Range', 'bytes=0-2') + .expect(shouldNotHaveHeader('Accept-Ranges', t)) + .expect(shouldNotHaveHeader('Content-Range', t)) + .expect(200, '123456789', () => t.pass()) + }) + }) + + t.test('cacheControl', function (t) { + t.plan(2) + + t.test('should support disabling cache-control', function (t) { + t.plan(2) + request(createServer({ cacheControl: false, root: fixtures })) + .get('/name.txt') + .expect(shouldNotHaveHeader('Cache-Control', t)) + .expect(200, () => t.pass()) + }) + + t.test('should ignore maxAge option', function (t) { + t.plan(2) + + request(createServer({ cacheControl: false, maxAge: 1000, root: fixtures })) + .get('/name.txt') + .expect(shouldNotHaveHeader('Cache-Control', t)) + .expect(200, () => t.pass()) + }) + }) + + t.test('etag', function (t) { + t.plan(1) + + t.test('should support disabling etags', function (t) { + t.plan(2) + + request(createServer({ etag: false, root: fixtures })) + .get('/name.txt') + .expect(shouldNotHaveHeader('ETag', t)) + .expect(200, () => t.pass()) + }) + }) + + t.test('extensions', function (t) { + t.plan(9) + + t.test('should reject numbers', function (t) { + t.plan(1) + + request(createServer({ extensions: 42, root: fixtures })) + .get('/pets/') + .expect(500, /TypeError: extensions option/, () => t.pass()) + }) + + t.test('should reject true', function (t) { + t.plan(1) + + request(createServer({ extensions: true, root: fixtures })) + .get('/pets/') + .expect(500, /TypeError: extensions option/, () => t.pass()) + }) + + t.test('should be not be enabled by default', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/tobi') + .expect(404, () => t.pass()) + }) + + t.test('should be configurable', function (t) { + t.plan(1) + + request(createServer({ extensions: 'txt', root: fixtures })) + .get('/name') + .expect(200, 'tobi', () => t.pass()) + }) + + t.test('should support disabling extensions', function (t) { + t.plan(1) + + request(createServer({ extensions: false, root: fixtures })) + .get('/name') + .expect(404, () => t.pass()) + }) + + t.test('should support fallbacks', function (t) { + t.plan(1) + + request(createServer({ extensions: ['htm', 'html', 'txt'], root: fixtures })) + .get('/name') + .expect(200, '

tobi

', () => t.pass()) + }) + + t.test('should 404 if nothing found', function (t) { + t.plan(1) + + request(createServer({ extensions: ['htm', 'html', 'txt'], root: fixtures })) + .get('/bob') + .expect(404, () => t.pass()) + }) + + t.test('should skip directories', function (t) { + t.plan(1) + + request(createServer({ extensions: ['file', 'dir'], root: fixtures })) + .get('/name') + .expect(404, () => t.pass()) + }) + + t.test('should not search if file has extension', function (t) { + t.plan(1) + + request(createServer({ extensions: 'html', root: fixtures })) + .get('/thing.html') + .expect(404, () => t.pass()) + }) + }) + + t.test('lastModified', function (t) { + t.plan(1) + + t.test('should support disabling last-modified', function (t) { + t.plan(2) + + request(createServer({ lastModified: false, root: fixtures })) + .get('/name.txt') + .expect(shouldNotHaveHeader('Last-Modified', t)) + .expect(200, () => t.pass()) + }) + }) + + t.test('from', function (t) { + t.plan(1) + + t.test('should set with deprecated from', function (t) { + t.plan(1) + + request(createServer({ from: fixtures })) + .get('/pets/../name.txt') + .expect(200, 'tobi', () => t.pass()) + }) + }) + + t.test('dotfiles', function (t) { + t.plan(6) + + t.test('should default to "ignore"', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/.hidden.txt') + .expect(404, () => t.pass()) + }) + + t.test('should allow file within dotfile directory for back-compat', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/.mine/name.txt') + .expect(200, /tobi/, () => t.pass()) + }) + + t.test('should reject bad value', function (t) { + t.plan(1) + + request(createServer({ dotfiles: 'bogus' })) + .get('/name.txt') + .expect(500, /dotfiles/, () => t.pass()) + }) + + t.test('when "allow"', function (t) { + t.plan(3) + + t.test('should send dotfile', function (t) { + t.plan(1) + request(createServer({ dotfiles: 'allow', root: fixtures })) + .get('/.hidden.txt') + .expect(200, 'secret', () => t.pass()) + }) + + t.test('should send within dotfile directory', function (t) { + t.plan(1) + request(createServer({ dotfiles: 'allow', root: fixtures })) + .get('/.mine/name.txt') + .expect(200, /tobi/, () => t.pass()) + }) + + t.test('should 404 for non-existent dotfile', function (t) { + t.plan(1) + request(createServer({ dotfiles: 'allow', root: fixtures })) + .get('/.nothere') + .expect(404, () => t.pass()) + }) + }) + + t.test('when "deny"', function (t) { + t.plan(10) + + t.test('should 403 for dotfile', function (t) { + t.plan(1) + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.hidden.txt') + .expect(403, () => t.pass()) + }) + + t.test('should 403 for dotfile directory', function (t) { + t.plan(1) + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.mine') + .expect(403, () => t.pass()) + }) + + t.test('should 403 for dotfile directory with trailing slash', function (t) { + t.plan(1) + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.mine/') + .expect(403, () => t.pass()) + }) + + t.test('should 403 for file within dotfile directory', function (t) { + t.plan(1) + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.mine/name.txt') + .expect(403, () => t.pass()) + }) + + t.test('should 403 for non-existent dotfile', function (t) { + t.plan(1) + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.nothere') + .expect(403, () => t.pass()) + }) + + t.test('should 403 for non-existent dotfile directory', function (t) { + t.plan(1) + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.what/name.txt') + .expect(403, () => t.pass()) + }) + + t.test('should 403 for dotfile in directory', function (t) { + t.plan(1) + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/pets/.hidden') + .expect(403, () => t.pass()) + }) + + t.test('should 403 for dotfile in dotfile directory', function (t) { + t.plan(1) + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.mine/.hidden') + .expect(403, () => t.pass()) + }) + + t.test('should send files in root dotfile directory', function (t) { + t.plan(1) + request(createServer({ dotfiles: 'deny', root: path.join(fixtures, '.mine') })) + .get('/name.txt') + .expect(200, /tobi/, () => t.pass()) + }) + + t.test('should 403 for dotfile without root', function (t) { + t.plan(1) + const server = http.createServer(function onRequest (req, res) { + send(req, fixtures + '/.mine' + req.url, { dotfiles: 'deny' }).pipe(res) + }) + + request(server) + .get('/name.txt') + .expect(403, () => t.pass()) + }) + }) + + t.test('when "ignore"', function (t) { + t.plan(8) + + t.test('should 404 for dotfile', function (t) { + t.plan(1) + + request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.hidden.txt') + .expect(404, () => t.pass()) + }) + + t.test('should 404 for dotfile directory', function (t) { + t.plan(1) + + request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.mine') + .expect(404, () => t.pass()) + }) + + t.test('should 404 for dotfile directory with trailing slash', function (t) { + t.plan(1) + + request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.mine/') + .expect(404, () => t.pass()) + }) + + t.test('should 404 for file within dotfile directory', function (t) { + t.plan(1) + + request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.mine/name.txt') + .expect(404, () => t.pass()) + }) + + t.test('should 404 for non-existent dotfile', function (t) { + t.plan(1) + + request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.nothere') + .expect(404, () => t.pass()) + }) + + t.test('should 404 for non-existent dotfile directory', function (t) { + t.plan(1) + + request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.what/name.txt') + .expect(404, () => t.pass()) + }) + + t.test('should send files in root dotfile directory', function (t) { + t.plan(1) + + request(createServer({ dotfiles: 'ignore', root: path.join(fixtures, '.mine') })) + .get('/name.txt') + .expect(200, /tobi/, () => t.pass()) + }) + + t.test('should 404 for dotfile without root', function (t) { + t.plan(1) + + const server = http.createServer(function onRequest (req, res) { + send(req, fixtures + '/.mine' + req.url, { dotfiles: 'ignore' }).pipe(res) + }) + + request(server) + .get('/name.txt') + .expect(404, () => t.pass()) + }) + }) + }) + + t.test('hidden', function (t) { + t.plan(2) + + t.test('should default to false', function (t) { + t.plan(1) + + const 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) + }) + request(app) + .get('/.hidden.txt') + .expect(404, 'Not Found', () => t.pass()) + }) + + t.test('should default support sending hidden files', function (t) { + t.plan(1) + request(createServer({ hidden: true, root: fixtures })) + .get('/.hidden.txt') + .expect(200, 'secret', () => t.pass()) + }) + }) + + t.test('immutable', function (t) { + t.plan(2) + + t.test('should default to false', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=0', () => t.pass()) + }) + + t.test('should set immutable directive in Cache-Control', function (t) { + t.plan(1) + + request(createServer({ immutable: true, maxAge: '1h', root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=3600, immutable', () => t.pass()) + }) + }) + + t.test('maxAge', function (t) { + t.plan(4) + + t.test('should default to 0', function (t) { + t.plan(1) + request(createServer({ root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=0', () => t.pass()) + }) + + t.test('should floor to integer', function (t) { + t.plan(1) + request(createServer({ maxAge: 123956, root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=123', () => t.pass()) + }) + + t.test('should accept string', function (t) { + t.plan(1) + request(createServer({ maxAge: '30d', root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=2592000', () => t.pass()) + }) + + t.test('should max at 1 year', function (t) { + t.plan(1) + request(createServer({ maxAge: '2y', root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=31536000', () => t.pass()) + }) + }) + + t.test('index', function (t) { + t.plan(10) + + t.test('should reject numbers', function (t) { + t.plan(1) + + request(createServer({ root: fixtures, index: 42 })) + .get('/pets/') + .expect(500, /TypeError: index option/, () => t.pass()) + }) + + t.test('should reject true', function (t) { + t.plan(1) + + request(createServer({ root: fixtures, index: true })) + .get('/pets/') + .expect(500, /TypeError: index option/, () => t.pass()) + }) + + t.test('should default to index.html', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/pets/') + .expect(fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8'), () => t.pass()) + }) + + t.test('should be configurable', function (t) { + t.plan(1) + + request(createServer({ root: fixtures, index: 'tobi.html' })) + .get('/') + .expect(200, '

tobi

', () => t.pass()) + }) + + t.test('should support disabling', function (t) { + t.plan(1) + + request(createServer({ root: fixtures, index: false })) + .get('/pets/') + .expect(403, () => t.pass()) + }) + + t.test('should support fallbacks', function (t) { + t.plan(1) + + request(createServer({ root: fixtures, index: ['default.htm', 'index.html'] })) + .get('/pets/') + .expect(200, fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8'), () => t.pass()) + }) + + t.test('should 404 if no index file found (file)', function (t) { + t.plan(1) + + request(createServer({ root: fixtures, index: 'default.htm' })) + .get('/pets/') + .expect(404, () => t.pass()) + }) + + t.test('should 404 if no index file found (dir)', function (t) { + t.plan(1) + + request(createServer({ root: fixtures, index: 'pets' })) + .get('/') + .expect(404, () => t.pass()) + }) + + t.test('should not follow directories', function (t) { + t.plan(1) + + request(createServer({ root: fixtures, index: ['pets', 'name.txt'] })) + .get('/') + .expect(200, 'tobi', () => t.pass()) + }) + + t.test('should work without root', function (t) { + t.plan(1) + + const server = http.createServer(function (req, res) { + const p = path.join(fixtures, 'pets').replace(/\\/g, '/') + '/' + send(req, p, { index: ['index.html'] }) + .pipe(res) + }) + + request(server) + .get('/') + .expect(200, /tobi/, () => t.pass()) + }) + }) + + t.test('root', function (t) { + t.plan(2) + + t.test('when given', function (t) { + t.plan(8) + + t.test('should join root', function (t) { + t.plan(1) + request(createServer({ root: fixtures })) + .get('/pets/../name.txt') + .expect(200, 'tobi', () => t.pass()) + }) + + t.test('should work with trailing slash', function (t) { + t.plan(1) + + const app = http.createServer(function (req, res) { + send(req, req.url, { root: fixtures + '/' }) + .pipe(res) + }) + + request(app) + .get('/name.txt') + .expect(200, 'tobi', () => t.pass()) + }) + + t.test('should work with empty path', function (t) { + t.plan(1) + + const app = http.createServer(function (req, res) { + send(req, '', { root: fixtures }) + .pipe(res) + }) + + request(app) + .get('/name.txt') + .expect(301, /Redirecting to/, () => t.pass()) + }) + + // + // 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. + // + t.test('should try as file with empty path', function (t) { + t.plan(1) + + const app = http.createServer(function (req, res) { + send(req, '', { root: path.join(fixtures, 'name.txt') }) + .pipe(res) + }) + + request(app) + .get('/') + .expect(200, 'tobi', () => t.pass()) + }) + + t.test('should restrict paths to within root', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/pets/../../send.js') + .expect(403, () => t.pass()) + }) + + t.test('should allow .. in root', function (t) { + t.plan(1) + + const app = http.createServer(function (req, res) { + send(req, req.url, { root: fixtures + '/../fixtures' }) + .pipe(res) + }) + + request(app) + .get('/pets/../../send.js') + .expect(403, () => t.pass()) + }) + + t.test('should not allow root transversal', function (t) { + t.plan(1) + + request(createServer({ root: path.join(fixtures, 'name.d') })) + .get('/../name.dir/name.txt') + .expect(403, () => t.pass()) + }) + + t.test('should not allow root path disclosure', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/pets/../../fixtures/name.txt') + .expect(403, () => t.pass()) + }) + }) + + t.test('when missing', function (t) { + t.plan(2) + + t.test('should consider .. malicious', function (t) { + t.plan(1) + + const app = http.createServer(function (req, res) { + send(req, fixtures + req.url) + .pipe(res) + }) + + request(app) + .get('/../send.js') + .expect(403, () => t.pass()) + }) + + t.test('should still serve files with dots in name', function (t) { + t.plan(1) + + const app = http.createServer(function (req, res) { + send(req, fixtures + req.url) + .pipe(res) + }) + + request(app) + .get('/do..ts.txt') + .expect(200, '...', () => t.pass()) + }) + }) + }) +}) diff --git a/test/utils.js b/test/utils.js new file mode 100644 index 0000000..923905f --- /dev/null +++ b/test/utils.js @@ -0,0 +1,28 @@ +'use strict' + +const http = require('http') +const send = require('..') + +module.exports.shouldNotHaveHeader = function shouldNotHaveHeader (header, t) { + return function (res) { + t.ok(!(header.toLowerCase() in res.headers), 'should not have header ' + header) + } +} + +module.exports.createServer = 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)) + } + }) +} + +module.exports.shouldNotHaveBody = function shouldNotHaveBody (t) { + return function (res) { + t.ok(res.text === '' || res.text === undefined) + } +} From 46fd432b6abc83fcf9f34b57d35e2968b6fa1b2f Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Thu, 12 Jan 2023 10:39:37 +0100 Subject: [PATCH 009/109] add types, nodenext, upgrade mime (#6) * add types, nodenext, upgrade mime * improve test coverage --- index.js | 33 ++-- package.json | 18 +-- test/fixtures/images/node-js.png | Bin 0 -> 569 bytes test/mime.test.js | 11 +- types/index.d.ts | 248 +++++++++++++++++++++++++++++++ types/index.test-d.ts | 35 +++++ 6 files changed, 322 insertions(+), 23 deletions(-) create mode 100644 test/fixtures/images/node-js.png create mode 100644 types/index.d.ts create mode 100644 types/index.test-d.ts diff --git a/index.js b/index.js index 13449e8..54ed8e5 100644 --- a/index.js +++ b/index.js @@ -62,14 +62,6 @@ const MAX_MAXAGE = 60 * 60 * 24 * 365 * 1000 // 1 year const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/ -/** - * Module exports. - * @public - */ - -module.exports = send -module.exports.mime = mime - /** * Return a `SendStream` for `req` and `path`. * @@ -814,6 +806,9 @@ SendStream.prototype.stream = function stream (path, options) { }) } +const utf8MimeTypeRE = /^text\/|^application\/(javascript|json)/ +const isUtf8MimeType = utf8MimeTypeRE.test.bind(utf8MimeTypeRE) + /** * Set content-type based on `path` * if it hasn't been explicitly set. @@ -827,17 +822,19 @@ SendStream.prototype.type = function type (path) { if (res.getHeader('Content-Type')) return - const type = mime.lookup(path) + const type = mime.getType(path) || mime.default_type if (!type) { debug('no content-type') return } - const charset = mime.charsets.lookup(type) - debug('content-type %s', type) - res.setHeader('Content-Type', type + (charset ? '; charset=' + charset : '')) + if (isUtf8MimeType(type)) { + res.setHeader('Content-Type', type + '; charset=UTF-8') + } else { + res.setHeader('Content-Type', type) + } } /** @@ -1142,3 +1139,15 @@ function setHeaders (res, headers) { res.setHeader(key, headers[key]) } } + +/** + * Module exports. + * @public + */ + +module.exports = send +module.exports.default = send +module.exports.send = send + +module.exports.isUtf8MimeType = isUtf8MimeType +module.exports.mime = mime diff --git a/package.json b/package.json index 60c375f..e3092e8 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "James Wyatt Cready ", "Jesús Leganés Combarro " ], + "main": "index.js", + "types": "types/index.d.ts", "license": "MIT", "repository": "fastify/send", "keywords": [ @@ -24,30 +26,26 @@ "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "mime": "1.6.0", + "mime": "^3.0.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" }, "devDependencies": { + "@types/node": "^18.11.18", "after": "0.8.2", "snazzy": "^9.0.0", "standard": "^17.0.0", "supertest": "6.3.3", - "tap": "^16.3.3" + "tap": "^16.3.3", + "tsd": "^0.25.0" }, - "files": [ - "HISTORY.md", - "LICENSE", - "README.md", - "SECURITY.md", - "index.js" - ], "scripts": { "lint": "standard | snazzy", "lint:fix": "standard --fix | snazzy", - "test": "npm run test:unit", + "test": "npm run test:unit && npm run test:typescript", + "test:typescript": "tsd", "test:unit": "tap" } } diff --git a/test/fixtures/images/node-js.png b/test/fixtures/images/node-js.png new file mode 100644 index 0000000000000000000000000000000000000000..e652bd0a15dd377c9c37e151890206bf9bcf9ce2 GIT binary patch literal 569 zcmV-90>=G`P)q$gGR5;6JliMpqQ543{nK3TqHbp7kguP#s@-p&DNs>!bL`k8% z@kFl8DSrTk7am=5E2Sj2$lI9|xfP8EW%5_}ow3f$#;LEh&RXkR>)Y$>V>xkc`HvG< zftago@q@9rn!(Odla-3O+9nq8>+#c$4KbK$qBrfO}HqIK7 zDM9k35VEB~8<3hR@Ij**$fID}RB%eNOC;vd;1SzKY@gg6{wj7H+b#;mz?QiK8t5)LKMhi{ zqxco57{V@r17cs;2FSx;C)2>yCrpis)I3PHNS}uA49bl`57bUV?*G+*mrIgO=?1Ry z6tsW|a!F!0j;G31*HS-7Y|mWc22;;T6v`qKH0hB2lz|8})uN&R{}z~^Q58X6!*T=l z8Ph(QR$~2mh|d!LqIQ`E`Twpmek~2ywrU2AhC*+luiiCx%&Y9*QqTGQGpS0Z++)4Z zd(6}X&3g?cLB}WizcYm?HQxRAm4Qj#=P3?Jt?d}UGjZ!1m}^L8Z8DFF00000NkvXX Hu0mjfY}g0G literal 0 HcmV?d00001 diff --git a/test/mime.test.js b/test/mime.test.js index b904c24..4a5e550 100644 --- a/test/mime.test.js +++ b/test/mime.test.js @@ -19,7 +19,7 @@ test('send.mime', function (t) { }) t.test('.default_type', function (t) { - t.plan(2) + t.plan(3) t.before(function () { this.default_type = send.mime.default_type @@ -48,5 +48,14 @@ test('send.mime', function (t) { .expect(shouldNotHaveHeader('Content-Type', t)) .expect(200, () => t.pass()) }) + + t.test('should return Content-Type without charset', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/images/node-js.png') + .expect('Content-Type', 'image/png') + .expect(200, () => t.pass()) + }) }) }) diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 0000000..9ceb18b --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1,248 @@ +// Definitions by: Mike Jerred +// Piotr Błażejewicz +// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped + +/// + +import * as stream from "stream"; +import * as fs from "fs"; + +/** + * 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). + */ +declare function send(req: stream.Readable, path: string, options?: send.SendOptions): send.SendStream; + +type Send = typeof send; + +declare class Mime { + constructor(typeMap: TypeMap, ...mimes: TypeMap[]); + + getType(path: string): string | null; + getExtension(mime: string): string | null; + define(typeMap: TypeMap, force?: boolean): void; +} + +interface TypeMap { + [key: string]: string[]; +} + +declare namespace send { + export const mime: Mime; + export const isUtf8MimeType: (value: string) => boolean; + + export interface SendOptions { + /** + * Enable or disable accepting ranged requests, defaults to true. + * Disabling this will not send Accept-Ranges and ignore the contents of the Range request header. + */ + acceptRanges?: boolean | undefined; + + /** + * Enable or disable setting Cache-Control response header, defaults to true. + * Disabling this will ignore the maxAge option. + */ + cacheControl?: boolean | undefined; + + /** + * 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 disk. + * If root is specified, only the dotfiles above the root are checked (i.e. the root itself can be within a dotfile when when set to "deny"). + * 'allow' No special treatment for dotfiles. + * 'deny' Send a 403 for any request for a dotfile. + * 'ignore' Pretend like the dotfile does not exist and 404. + * The default value is similar to 'ignore', with the exception that this default will not ignore the files within a directory that begins with a dot, for backward-compatibility. + */ + dotfiles?: "allow" | "deny" | "ignore" | undefined; + + /** + * Byte offset at which the stream ends, defaults to the length of the file minus 1. + * The end is inclusive in the stream, meaning end: 3 will include the 4th byte in the stream. + */ + end?: number | undefined; + + /** + * Enable or disable etag generation, defaults to true. + */ + etag?: boolean | undefined; + + /** + * If a given file doesn't exist, try appending one of the given extensions, in the given order. + * By default, this is disabled (set to false). + * An example value that will serve extension-less HTML files: ['html', 'htm']. + * This is skipped if the requested file already has an extension. + */ + extensions?: string[] | string | boolean | undefined; + + /** + * Enable or disable the immutable directive in the Cache-Control response header, defaults to false. + * If set to true, the maxAge option should also be specified to enable caching. + * The immutable directive will prevent supported clients from making conditional requests during the life of the maxAge option to check if the file has changed. + * @default false + */ + immutable?: boolean | undefined; + + /** + * By default send supports "index.html" files, to disable this set false or to supply a new index pass a string or an array in preferred order. + */ + index?: string[] | string | boolean | undefined; + + /** + * Enable or disable Last-Modified header, defaults to true. + * Uses the file system's last modified value. + */ + lastModified?: boolean | undefined; + + /** + * Provide a max-age in milliseconds for http caching, defaults to 0. + * This can also be a string accepted by the ms module. + */ + maxAge?: string | number | undefined; + + /** + * Serve files relative to path. + */ + root?: string | undefined; + + /** + * 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. + */ + start?: number | undefined; + } + + export interface SendStream extends stream.Stream { + /** + * @deprecated pass etag as option + * Enable or disable etag generation. + */ + etag(val: boolean): SendStream; + + /** + * @deprecated use dotfiles option + * Enable or disable "hidden" (dot) files. + */ + hidden(val: boolean): SendStream; + + /** + * @deprecated pass index as option + * Set index `paths`, set to a falsy value to disable index support. + */ + index(paths: string[] | string): SendStream; + + /** + * @deprecated pass root as option + * Set root `path`. + */ + root(paths: string): SendStream; + + /** + * @deprecated pass root as option + * Set root `path`. + */ + from(paths: string): SendStream; + + /** + * @deprecated pass maxAge as option + * Set max-age to `maxAge`. + */ + maxage(maxAge: string | number): SendStream; + + /** + * Emit error with `status`. + */ + error(status: number, error?: Error): void; + + /** + * Check if the pathname ends with "/". + */ + hasTrailingSlash(): boolean; + + /** + * Check if this is a conditional GET request. + */ + isConditionalGET(): boolean; + + /** + * Strip content-* header fields. + */ + removeContentHeaderFields(): void; + + /** + * Respond with 304 not modified. + */ + notModified(): void; + + /** + * Raise error that headers already sent. + */ + headersAlreadySent(): void; + + /** + * Check if the request is cacheable, aka responded with 2xx or 304 (see RFC 2616 section 14.2{5,6}). + */ + isCachable(): boolean; + + /** + * Handle stat() error. + */ + onStatError(error: Error): void; + + /** + * Check if the cache is fresh. + */ + isFresh(): boolean; + + /** + * Check if the range is fresh. + */ + isRangeFresh(): boolean; + + /** + * Redirect to path. + */ + redirect(path: string): void; + + /** + * Pipe to `res`. + */ + pipe(res: T): T; + + /** + * Transfer `path`. + */ + send(path: string, stat?: fs.Stats): void; + + /** + * Transfer file for `path`. + */ + sendFile(path: string): void; + + /** + * Transfer index for `path`. + */ + sendIndex(path: string): void; + + /** + * Transfer index for `path`. + */ + stream(path: string, options?: {}): void; + + /** + * Set content-type based on `path` if it hasn't been explicitly set. + */ + type(path: string): void; + + /** + * Set response header fields, most fields may be pre-defined. + */ + setHeader(path: string, stat: fs.Stats): void; + } + + export const send: Send + + export { send as default } +} + +export = send; diff --git a/types/index.test-d.ts b/types/index.test-d.ts new file mode 100644 index 0000000..f015304 --- /dev/null +++ b/types/index.test-d.ts @@ -0,0 +1,35 @@ +import { expectType } from 'tsd' +import send from '..' + +send.mime.define({ + 'application/x-my-type': ['x-mt', 'x-mtt'] +}); + +expectType<(value: string) => boolean>(send.isUtf8MimeType) +expectType(send.isUtf8MimeType('application/json')) + +const req: any = {} +const res: any = {} + +send(req, '/test.html', { + immutable: true, + maxAge: 0, + root: __dirname + '/wwwroot' +}).pipe(res); + +send(req, '/test.html') + .maxage(0) + .root(__dirname + '/wwwroot') + .on('error', (err: any) => { + res.statusCode = err.status || 500; + res.end(err.message); + }) + .on('directory', () => { + res.statusCode = 301; + res.setHeader('Location', req.url + '/'); + res.end(`Redirecting to ${req.url}/`); + }) + .on('headers', (res: any, path: string, stat: any) => { + res.setHeader('Content-Disposition', 'attachment'); + }) + .pipe(res); \ No newline at end of file From 5b263c26abe3ae2d81884ee2a696279986301604 Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Thu, 12 Jan 2023 15:18:02 +0100 Subject: [PATCH 010/109] use t.error instead of t.pass (#11) --- test/mime.test.js | 6 +- test/send-pipe.test.js | 166 ++++++++++++++++++++--------------------- test/send.test.js | 136 ++++++++++++++++----------------- 3 files changed, 154 insertions(+), 154 deletions(-) diff --git a/test/mime.test.js b/test/mime.test.js index 4a5e550..b68429c 100644 --- a/test/mime.test.js +++ b/test/mime.test.js @@ -36,7 +36,7 @@ test('send.mime', function (t) { request(createServer({ root: fixtures })) .get('/no_ext') .expect('Content-Type', 'text/plain; charset=UTF-8') - .expect(200, () => t.pass()) + .expect(200, err => t.error(err)) }) t.test('should not add Content-Type for undefined default', function (t) { @@ -46,7 +46,7 @@ test('send.mime', function (t) { request(createServer({ root: fixtures })) .get('/no_ext') .expect(shouldNotHaveHeader('Content-Type', t)) - .expect(200, () => t.pass()) + .expect(200, err => t.error(err)) }) t.test('should return Content-Type without charset', function (t) { @@ -55,7 +55,7 @@ test('send.mime', function (t) { request(createServer({ root: fixtures })) .get('/images/node-js.png') .expect('Content-Type', 'image/png') - .expect(200, () => t.pass()) + .expect(200, err => t.error(err)) }) }) }) diff --git a/test/send-pipe.test.js b/test/send-pipe.test.js index 69d3ce6..24c5304 100644 --- a/test/send-pipe.test.js +++ b/test/send-pipe.test.js @@ -34,7 +34,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/name.txt') .expect('Content-Length', '4') - .expect(200, 'tobi', () => t.pass()) + .expect(200, 'tobi', err => t.error(err)) }) t.test('should stream a zero-length file', function (t) { @@ -54,7 +54,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/empty.txt') .expect('Content-Length', '0') - .expect(200, '', () => t.pass()) + .expect(200, '', err => t.error(err)) }) t.test('should decode the given path as a URI', function (t) { @@ -73,7 +73,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/some%20thing.txt') - .expect(200, 'hey', () => t.pass()) + .expect(200, 'hey', err => t.error(err)) }) t.test('should serve files with dots in name', function (t) { @@ -92,7 +92,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/do..ts.txt') - .expect(200, '...', () => t.pass()) + .expect(200, '...', err => t.error(err)) }) t.test('should treat a malformed URI as a bad request', function (t) { @@ -111,7 +111,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/some%99thing.txt') - .expect(400, 'Bad Request', () => t.pass()) + .expect(400, 'Bad Request', err => t.error(err)) }) t.test('should 400 on NULL bytes', function (t) { @@ -130,7 +130,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/some%00thing.txt') - .expect(400, 'Bad Request', () => t.pass()) + .expect(400, 'Bad Request', err => t.error(err)) }) t.test('should treat an ENAMETOOLONG as a 404', function (t) { @@ -150,7 +150,7 @@ test('send(file).pipe(res)', function (t) { const path = Array(100).join('foobar') request(app) .get('/' + path) - .expect(404, () => t.pass()) + .expect(404, err => t.error(err)) }) t.test('should handle headers already sent error', function (t) { @@ -164,7 +164,7 @@ test('send(file).pipe(res)', function (t) { }) request(app) .get('/name.txt') - .expect(200, '0 - Can\'t set headers after they are sent.', () => t.pass()) + .expect(200, '0 - Can\'t set headers after they are sent.', err => t.error(err)) }) t.test('should support HEAD', function (t) { @@ -186,7 +186,7 @@ test('send(file).pipe(res)', function (t) { .expect(200) .expect('Content-Length', '4') .expect(shouldNotHaveBody(t)) - .end(() => t.pass()) + .end(err => t.error(err)) }) t.test('should add an ETag header field', function (t) { @@ -206,7 +206,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/name.txt') .expect('etag', /^W\/"[^"]+"$/) - .end(() => t.pass()) + .end(err => t.error(err)) }) t.test('should add a Date header field', function (t) { @@ -225,7 +225,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/name.txt') - .expect('date', dateRegExp, () => t.pass()) + .expect('date', dateRegExp, err => t.error(err)) }) t.test('should add a Last-Modified header field', function (t) { @@ -244,7 +244,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/name.txt') - .expect('last-modified', dateRegExp, () => t.pass()) + .expect('last-modified', dateRegExp, err => t.error(err)) }) t.test('should add a Accept-Ranges header field', function (t) { @@ -263,7 +263,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/name.txt') - .expect('Accept-Ranges', 'bytes', () => t.pass()) + .expect('Accept-Ranges', 'bytes', err => t.error(err)) }) t.test('should 404 if the file does not exist', function (t) { @@ -282,7 +282,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/meow') - .expect(404, 'Not Found', () => t.pass()) + .expect(404, 'Not Found', err => t.error(err)) }) t.test('should emit ENOENT if the file does not exist', function (t) { @@ -296,7 +296,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/meow') - .expect(200, '404 ENOENT', () => t.pass()) + .expect(200, '404 ENOENT', err => t.error(err)) }) t.test('should not override content-type', function (t) { @@ -308,7 +308,7 @@ test('send(file).pipe(res)', function (t) { }) request(app) .get('/name.txt') - .expect('Content-Type', 'application/x-custom', () => t.pass()) + .expect('Content-Type', 'application/x-custom', err => t.error(err)) }) t.test('should set Content-Type via mime map', function (t) { @@ -333,7 +333,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/tobi.html') .expect('Content-Type', 'text/html; charset=UTF-8') - .expect(200, () => t.pass()) + .expect(200, err => t.error(err)) }) }) @@ -354,7 +354,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/name.txt') - .expect(404, () => t.pass()) + .expect(404, err => t.error(err)) }) t.test('should 500 on file stream error', function (t) { @@ -373,14 +373,14 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/name.txt') - .expect(500, () => t.pass()) + .expect(500, err => t.error(err)) }) t.test('"headers" event', function (t) { t.plan(7) t.test('should fire when sending file', function (t) { t.plan(1) - const cb = after(2, () => t.pass()) + const cb = after(2, err => t.error(err)) const server = http.createServer(function (req, res) { send(req, req.url, { root: fixtures }) .on('headers', function () { cb() }) @@ -394,7 +394,7 @@ test('send(file).pipe(res)', function (t) { t.test('should not fire on 404', function (t) { t.plan(1) - const cb = after(1, () => t.pass()) + const cb = after(1, err => t.error(err)) const server = http.createServer(function (req, res) { send(req, req.url, { root: fixtures }) .on('headers', function () { cb() }) @@ -408,7 +408,7 @@ test('send(file).pipe(res)', function (t) { t.test('should fire on index', function (t) { t.plan(1) - const cb = after(2, () => t.pass()) + const cb = after(2, err => t.error(err)) const server = http.createServer(function (req, res) { send(req, req.url, { root: fixtures }) .on('headers', function () { cb() }) @@ -422,7 +422,7 @@ test('send(file).pipe(res)', function (t) { t.test('should not fire on redirect', function (t) { t.plan(1) - const cb = after(1, () => t.pass()) + const cb = after(1, err => t.error(err)) const server = http.createServer(function (req, res) { send(req, req.url, { root: fixtures }) .on('headers', function () { cb() }) @@ -436,7 +436,7 @@ test('send(file).pipe(res)', function (t) { t.test('should provide path', function (t) { t.plan(3) - const cb = after(2, () => t.pass()) + const cb = after(2, err => t.error(err)) const server = http.createServer(function (req, res) { send(req, req.url, { root: fixtures }) .on('headers', onHeaders) @@ -456,7 +456,7 @@ test('send(file).pipe(res)', function (t) { t.test('should provide stat', function (t) { t.plan(4) - const cb = after(2, () => t.pass()) + const cb = after(2, err => t.error(err)) const server = http.createServer(function (req, res) { send(req, req.url, { root: fixtures }) .on('headers', onHeaders) @@ -498,7 +498,7 @@ test('send(file).pipe(res)', function (t) { .expect('ETag', 'W/"everything"') .expect('X-Created', dateRegExp) .expect('tobi') - .end(() => t.pass()) + .end(err => t.error(err)) }) }) @@ -520,7 +520,7 @@ test('send(file).pipe(res)', function (t) { request(server) .get('/pets') - .expect(400, 'No directory for you', () => t.pass()) + .expect(400, 'No directory for you', err => t.error(err)) }) t.test('should be called with path', function (t) { @@ -537,7 +537,7 @@ test('send(file).pipe(res)', function (t) { request(server) .get('/pets') - .expect(200, path.normalize(path.join(fixtures, 'pets')), () => t.pass()) + .expect(200, path.normalize(path.join(fixtures, 'pets')), err => t.error(err)) }) }) @@ -550,7 +550,7 @@ test('send(file).pipe(res)', function (t) { request(createServer({ root: fixtures })) .get('/pets') .expect('Location', '/pets/') - .expect(301, () => t.pass()) + .expect(301, err => t.error(err)) }) t.test('should respond with an HTML redirect', function (t) { @@ -560,7 +560,7 @@ test('send(file).pipe(res)', function (t) { .get('/pets') .expect('Location', '/pets/') .expect('Content-Type', /html/) - .expect(301, />Redirecting to
\/pets\/<\/a> t.pass()) + .expect(301, />Redirecting to \/pets\/<\/a> t.error(err)) }) t.test('should respond with default Content-Security-Policy', function (t) { @@ -570,7 +570,7 @@ test('send(file).pipe(res)', function (t) { .get('/pets') .expect('Location', '/pets/') .expect('Content-Security-Policy', "default-src 'none'") - .expect(301, () => t.pass()) + .expect(301, err => t.error(err)) }) t.test('should not redirect to protocol-relative locations', function (t) { @@ -579,7 +579,7 @@ test('send(file).pipe(res)', function (t) { request(createServer({ root: fixtures })) .get('//pets') .expect('Location', '/pets/') - .expect(301, () => t.pass()) + .expect(301, err => t.error(err)) }) t.test('should respond with an HTML redirect', function (t) { @@ -594,7 +594,7 @@ test('send(file).pipe(res)', function (t) { .get('/snow') .expect('Location', '/snow%20%E2%98%83/') .expect('Content-Type', /html/) - .expect(301, />Redirecting to \/snow%20%E2%98%83\/<\/a> t.pass()) + .expect(301, />Redirecting to \/snow%20%E2%98%83\/<\/a> t.error(err)) }) }) @@ -606,7 +606,7 @@ test('send(file).pipe(res)', function (t) { request(createServer({ root: fixtures })) .get('/foobar') - .expect(404, />Not Found t.pass()) + .expect(404, />Not Found t.error(err)) }) t.test('should respond with default Content-Security-Policy', function (t) { @@ -615,7 +615,7 @@ test('send(file).pipe(res)', function (t) { request(createServer({ root: fixtures })) .get('/foobar') .expect('Content-Security-Policy', "default-src 'none'") - .expect(404, () => t.pass()) + .expect(404, err => t.error(err)) }) t.test('should remove all previously-set headers', function (t) { @@ -628,7 +628,7 @@ test('send(file).pipe(res)', function (t) { request(server) .get('/foobar') .expect(shouldNotHaveHeader('X-Foo', t)) - .expect(404, () => t.pass()) + .expect(404, err => t.error(err)) }) }) @@ -656,7 +656,7 @@ test('send(file).pipe(res)', function (t) { .expect(shouldNotHaveHeader('Content-Type', t)) .expect('Content-Location', 'http://localhost/name.txt') .expect('Contents', 'foo') - .expect(304, () => t.pass()) + .expect(304, err => t.error(err)) }) }) @@ -679,7 +679,7 @@ test('send(file).pipe(res)', function (t) { .expect(shouldNotHaveHeader('Content-Type', t)) .expect('Content-Location', 'http://localhost/name.txt') .expect('Content-Security-Policy', 'default-src \'self\'') - .expect(304, () => t.pass()) + .expect(304, err => t.error(err)) }) }) @@ -703,7 +703,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/name.txt') .set('If-Match', '*') - .expect(200, () => t.pass()) + .expect(200, err => t.error(err)) }) t.test('should respond with 412 when ETag unmatched', function (t) { @@ -723,7 +723,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/name.txt') .set('If-Match', ' "foo",, "bar" ,') - .expect(412, () => t.pass()) + .expect(412, err => t.error(err)) }) t.test('should respond with 200 when ETag matched', function (t) { @@ -747,7 +747,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/name.txt') .set('If-Match', '"foo", "bar", ' + res.headers.etag) - .expect(200, () => t.pass()) + .expect(200, err => t.error(err)) }) }) }) @@ -776,7 +776,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/name.txt') .set('If-Modified-Since', res.headers['last-modified']) - .expect(304, () => t.pass()) + .expect(304, err => t.error(err)) }) }) @@ -803,7 +803,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/name.txt') .set('If-Modified-Since', date.toUTCString()) - .expect(200, 'tobi', () => t.pass()) + .expect(200, 'tobi', err => t.error(err)) }) }) }) @@ -832,7 +832,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/name.txt') .set('If-None-Match', res.headers.etag) - .expect(304, () => t.pass()) + .expect(304, err => t.error(err)) }) }) @@ -857,7 +857,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/name.txt') .set('If-None-Match', '"123"') - .expect(200, 'tobi', () => t.pass()) + .expect(200, 'tobi', err => t.error(err)) }) }) }) @@ -886,7 +886,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/name.txt') .set('If-Unmodified-Since', res.headers['last-modified']) - .expect(200, () => t.pass()) + .expect(200, err => t.error(err)) }) }) @@ -913,7 +913,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/name.txt') .set('If-Unmodified-Since', date) - .expect(412, () => t.pass()) + .expect(412, err => t.error(err)) }) }) @@ -934,7 +934,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/name.txt') .set('If-Unmodified-Since', 'foo') - .expect(200, () => t.pass()) + .expect(200, err => t.error(err)) }) }) }) @@ -959,7 +959,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/nums.txt') .set('Range', 'bytes=0-4') - .expect(206, '12345', () => t.pass()) + .expect(206, '12345', err => t.error(err)) }) t.test('should ignore non-byte ranges', function (t) { @@ -979,7 +979,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/nums.txt') .set('Range', 'items=0-4') - .expect(200, '123456789', () => t.pass()) + .expect(200, '123456789', err => t.error(err)) }) t.test('should be inclusive', function (t) { @@ -999,7 +999,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/nums.txt') .set('Range', 'bytes=0-0') - .expect(206, '1', () => t.pass()) + .expect(206, '1', err => t.error(err)) }) t.test('should set Content-Range', function (t) { @@ -1020,7 +1020,7 @@ test('send(file).pipe(res)', function (t) { .get('/nums.txt') .set('Range', 'bytes=2-5') .expect('Content-Range', 'bytes 2-5/9') - .expect(206, () => t.pass()) + .expect(206, err => t.error(err)) }) t.test('should support -n', function (t) { @@ -1040,7 +1040,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/nums.txt') .set('Range', 'bytes=-3') - .expect(206, '789', () => t.pass()) + .expect(206, '789', err => t.error(err)) }) t.test('should support n-', function (t) { @@ -1060,7 +1060,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/nums.txt') .set('Range', 'bytes=3-') - .expect(206, '456789', () => t.pass()) + .expect(206, '456789', err => t.error(err)) }) t.test('should respond with 206 "Partial Content"', function (t) { @@ -1080,7 +1080,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/nums.txt') .set('Range', 'bytes=0-4') - .expect(206, () => t.pass()) + .expect(206, err => t.error(err)) }) t.test('should set Content-Length to the # of octets transferred', function (t) { @@ -1101,7 +1101,7 @@ test('send(file).pipe(res)', function (t) { .get('/nums.txt') .set('Range', 'bytes=2-3') .expect('Content-Length', '2') - .expect(206, '34', () => t.pass()) + .expect(206, '34', err => t.error(err)) }) t.test('when last-byte-pos of the range is greater the length', function (t) { @@ -1125,7 +1125,7 @@ test('send(file).pipe(res)', function (t) { .get('/nums.txt') .set('Range', 'bytes=2-50') .expect('Content-Range', 'bytes 2-8/9') - .expect(206, () => t.pass()) + .expect(206, err => t.error(err)) }) t.test('should adapt the Content-Length accordingly', function (t) { @@ -1146,7 +1146,7 @@ test('send(file).pipe(res)', function (t) { .get('/nums.txt') .set('Range', 'bytes=2-50') .expect('Content-Length', '7') - .expect(206, () => t.pass()) + .expect(206, err => t.error(err)) }) }) @@ -1171,7 +1171,7 @@ test('send(file).pipe(res)', function (t) { .get('/nums.txt') .set('Range', 'bytes=9-50') .expect('Content-Range', 'bytes */9') - .expect(416, () => t.pass()) + .expect(416, err => t.error(err)) }) t.test('should emit error 416 with content-range header', function (t) { @@ -1191,7 +1191,7 @@ test('send(file).pipe(res)', function (t) { .get('/nums.txt') .set('Range', 'bytes=9-50') .expect('X-Content-Range', 'bytes */9') - .expect(416, () => t.pass()) + .expect(416, err => t.error(err)) }) }) @@ -1215,7 +1215,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/nums.txt') .set('Range', 'asdf') - .expect(200, '123456789', () => t.pass()) + .expect(200, '123456789', err => t.error(err)) }) }) @@ -1240,7 +1240,7 @@ test('send(file).pipe(res)', function (t) { .get('/nums.txt') .set('Range', 'bytes=1-1,3-') .expect(shouldNotHaveHeader('Content-Range', t)) - .expect(200, '123456789', () => t.pass()) + .expect(200, '123456789', err => t.error(err)) }) t.test('should respond with 206 is all ranges can be combined', function (t) { @@ -1261,7 +1261,7 @@ test('send(file).pipe(res)', function (t) { .get('/nums.txt') .set('Range', 'bytes=1-2,3-5') .expect('Content-Range', 'bytes 1-5/9') - .expect(206, '23456', () => t.pass()) + .expect(206, '23456', err => t.error(err)) }) }) @@ -1292,7 +1292,7 @@ test('send(file).pipe(res)', function (t) { .get('/nums.txt') .set('If-Range', etag) .set('Range', 'bytes=0-0') - .expect(206, '1', () => t.pass()) + .expect(206, '1', err => t.error(err)) }) }) @@ -1320,7 +1320,7 @@ test('send(file).pipe(res)', function (t) { .get('/nums.txt') .set('If-Range', etag) .set('Range', 'bytes=0-0') - .expect(200, '123456789', () => t.pass()) + .expect(200, '123456789', err => t.error(err)) }) }) @@ -1348,7 +1348,7 @@ test('send(file).pipe(res)', function (t) { .get('/nums.txt') .set('If-Range', modified) .set('Range', 'bytes=0-0') - .expect(206, '1', () => t.pass()) + .expect(206, '1', err => t.error(err)) }) }) @@ -1376,7 +1376,7 @@ test('send(file).pipe(res)', function (t) { .get('/nums.txt') .set('If-Range', new Date(modified).toUTCString()) .set('Range', 'bytes=0-0') - .expect(200, '123456789', () => t.pass()) + .expect(200, '123456789', err => t.error(err)) }) }) @@ -1398,7 +1398,7 @@ test('send(file).pipe(res)', function (t) { .get('/nums.txt') .set('If-Range', 'foo') .set('Range', 'bytes=0-0') - .expect(200, '123456789', () => t.pass()) + .expect(200, '123456789', err => t.error(err)) }) }) }) @@ -1411,7 +1411,7 @@ test('send(file).pipe(res)', function (t) { request(createServer({ root: fixtures, start: 3, end: 5 })) .get('/nums.txt') - .expect(200, '456', () => t.pass()) + .expect(200, '456', err => t.error(err)) }) t.test('should adjust too large end', function (t) { @@ -1419,7 +1419,7 @@ test('send(file).pipe(res)', function (t) { request(createServer({ root: fixtures, start: 3, end: 90 })) .get('/nums.txt') - .expect(200, '456789', () => t.pass()) + .expect(200, '456789', err => t.error(err)) }) t.test('should support start/end with Range request', function (t) { @@ -1428,7 +1428,7 @@ test('send(file).pipe(res)', function (t) { request(createServer({ root: fixtures, start: 0, end: 2 })) .get('/nums.txt') .set('Range', 'bytes=-2') - .expect(206, '23', () => t.pass()) + .expect(206, '23', err => t.error(err)) }) t.test('should support start/end with unsatisfiable Range request', function (t) { @@ -1438,7 +1438,7 @@ test('send(file).pipe(res)', function (t) { .get('/nums.txt') .set('Range', 'bytes=5-9') .expect('Content-Range', 'bytes */3') - .expect(416, () => t.pass()) + .expect(416, err => t.error(err)) }) }) @@ -1457,7 +1457,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/name.txt') .expect(shouldNotHaveHeader('ETag', t)) - .expect(200, () => t.pass()) + .expect(200, err => t.error(err)) }) }) @@ -1475,7 +1475,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/pets/../name.txt') - .expect(200, 'tobi', () => t.pass()) + .expect(200, 'tobi', err => t.error(err)) }) }) @@ -1493,7 +1493,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/.hidden.txt') - .expect(200, 'secret', () => t.pass()) + .expect(200, 'secret', err => t.error(err)) }) }) @@ -1511,7 +1511,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/') - .expect(200, '

tobi

', () => t.pass()) + .expect(200, '

tobi

', err => t.error(err)) }) t.test('should support disabling', function (t) { @@ -1525,7 +1525,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/pets/') - .expect(403, () => t.pass()) + .expect(403, err => t.error(err)) }) t.test('should support fallbacks', function (t) { @@ -1539,7 +1539,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/pets/') - .expect(200, fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8'), () => t.pass()) + .expect(200, fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8'), err => t.error(err)) }) }) @@ -1557,7 +1557,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/name.txt') - .expect('Cache-Control', 'public, max-age=0', () => t.pass()) + .expect('Cache-Control', 'public, max-age=0', err => t.error(err)) }) t.test('should floor to integer', function (t) { @@ -1571,7 +1571,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/name.txt') - .expect('Cache-Control', 'public, max-age=1', () => t.pass()) + .expect('Cache-Control', 'public, max-age=1', err => t.error(err)) }) t.test('should accept string', function (t) { @@ -1585,7 +1585,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/name.txt') - .expect('Cache-Control', 'public, max-age=2592000', () => t.pass()) + .expect('Cache-Control', 'public, max-age=2592000', err => t.error(err)) }) t.test('should max at 1 year', function (t) { @@ -1599,7 +1599,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/name.txt') - .expect('Cache-Control', 'public, max-age=31536000', () => t.pass()) + .expect('Cache-Control', 'public, max-age=31536000', err => t.error(err)) }) }) @@ -1617,7 +1617,7 @@ test('send(file).pipe(res)', function (t) { request(app) .get('/pets/../name.txt') - .expect(200, 'tobi', () => t.pass()) + .expect(200, 'tobi', err => t.error(err)) }) }) }) diff --git a/test/send.test.js b/test/send.test.js index 6ca1205..1c65aa5 100644 --- a/test/send.test.js +++ b/test/send.test.js @@ -26,7 +26,7 @@ test('send(file, options)', function (t) { request(createServer({ acceptRanges: false, root: fixtures })) .get('/nums.txt') .expect(shouldNotHaveHeader('Accept-Ranges', t)) - .expect(200, () => t.pass()) + .expect(200, err => t.error(err)) }) t.test('should ignore requested range', function (t) { @@ -37,7 +37,7 @@ test('send(file, options)', function (t) { .set('Range', 'bytes=0-2') .expect(shouldNotHaveHeader('Accept-Ranges', t)) .expect(shouldNotHaveHeader('Content-Range', t)) - .expect(200, '123456789', () => t.pass()) + .expect(200, '123456789', err => t.error(err)) }) }) @@ -49,7 +49,7 @@ test('send(file, options)', function (t) { request(createServer({ cacheControl: false, root: fixtures })) .get('/name.txt') .expect(shouldNotHaveHeader('Cache-Control', t)) - .expect(200, () => t.pass()) + .expect(200, err => t.error(err)) }) t.test('should ignore maxAge option', function (t) { @@ -58,7 +58,7 @@ test('send(file, options)', function (t) { request(createServer({ cacheControl: false, maxAge: 1000, root: fixtures })) .get('/name.txt') .expect(shouldNotHaveHeader('Cache-Control', t)) - .expect(200, () => t.pass()) + .expect(200, err => t.error(err)) }) }) @@ -71,7 +71,7 @@ test('send(file, options)', function (t) { request(createServer({ etag: false, root: fixtures })) .get('/name.txt') .expect(shouldNotHaveHeader('ETag', t)) - .expect(200, () => t.pass()) + .expect(200, err => t.error(err)) }) }) @@ -83,7 +83,7 @@ test('send(file, options)', function (t) { request(createServer({ extensions: 42, root: fixtures })) .get('/pets/') - .expect(500, /TypeError: extensions option/, () => t.pass()) + .expect(500, /TypeError: extensions option/, err => t.error(err)) }) t.test('should reject true', function (t) { @@ -91,7 +91,7 @@ test('send(file, options)', function (t) { request(createServer({ extensions: true, root: fixtures })) .get('/pets/') - .expect(500, /TypeError: extensions option/, () => t.pass()) + .expect(500, /TypeError: extensions option/, err => t.error(err)) }) t.test('should be not be enabled by default', function (t) { @@ -99,7 +99,7 @@ test('send(file, options)', function (t) { request(createServer({ root: fixtures })) .get('/tobi') - .expect(404, () => t.pass()) + .expect(404, err => t.error(err)) }) t.test('should be configurable', function (t) { @@ -107,7 +107,7 @@ test('send(file, options)', function (t) { request(createServer({ extensions: 'txt', root: fixtures })) .get('/name') - .expect(200, 'tobi', () => t.pass()) + .expect(200, 'tobi', err => t.error(err)) }) t.test('should support disabling extensions', function (t) { @@ -115,7 +115,7 @@ test('send(file, options)', function (t) { request(createServer({ extensions: false, root: fixtures })) .get('/name') - .expect(404, () => t.pass()) + .expect(404, err => t.error(err)) }) t.test('should support fallbacks', function (t) { @@ -123,7 +123,7 @@ test('send(file, options)', function (t) { request(createServer({ extensions: ['htm', 'html', 'txt'], root: fixtures })) .get('/name') - .expect(200, '

tobi

', () => t.pass()) + .expect(200, '

tobi

', err => t.error(err)) }) t.test('should 404 if nothing found', function (t) { @@ -131,7 +131,7 @@ test('send(file, options)', function (t) { request(createServer({ extensions: ['htm', 'html', 'txt'], root: fixtures })) .get('/bob') - .expect(404, () => t.pass()) + .expect(404, err => t.error(err)) }) t.test('should skip directories', function (t) { @@ -139,7 +139,7 @@ test('send(file, options)', function (t) { request(createServer({ extensions: ['file', 'dir'], root: fixtures })) .get('/name') - .expect(404, () => t.pass()) + .expect(404, err => t.error(err)) }) t.test('should not search if file has extension', function (t) { @@ -147,7 +147,7 @@ test('send(file, options)', function (t) { request(createServer({ extensions: 'html', root: fixtures })) .get('/thing.html') - .expect(404, () => t.pass()) + .expect(404, err => t.error(err)) }) }) @@ -160,7 +160,7 @@ test('send(file, options)', function (t) { request(createServer({ lastModified: false, root: fixtures })) .get('/name.txt') .expect(shouldNotHaveHeader('Last-Modified', t)) - .expect(200, () => t.pass()) + .expect(200, err => t.error(err)) }) }) @@ -172,7 +172,7 @@ test('send(file, options)', function (t) { request(createServer({ from: fixtures })) .get('/pets/../name.txt') - .expect(200, 'tobi', () => t.pass()) + .expect(200, 'tobi', err => t.error(err)) }) }) @@ -184,7 +184,7 @@ test('send(file, options)', function (t) { request(createServer({ root: fixtures })) .get('/.hidden.txt') - .expect(404, () => t.pass()) + .expect(404, err => t.error(err)) }) t.test('should allow file within dotfile directory for back-compat', function (t) { @@ -192,7 +192,7 @@ test('send(file, options)', function (t) { request(createServer({ root: fixtures })) .get('/.mine/name.txt') - .expect(200, /tobi/, () => t.pass()) + .expect(200, /tobi/, err => t.error(err)) }) t.test('should reject bad value', function (t) { @@ -200,7 +200,7 @@ test('send(file, options)', function (t) { request(createServer({ dotfiles: 'bogus' })) .get('/name.txt') - .expect(500, /dotfiles/, () => t.pass()) + .expect(500, /dotfiles/, err => t.error(err)) }) t.test('when "allow"', function (t) { @@ -210,21 +210,21 @@ test('send(file, options)', function (t) { t.plan(1) request(createServer({ dotfiles: 'allow', root: fixtures })) .get('/.hidden.txt') - .expect(200, 'secret', () => t.pass()) + .expect(200, 'secret', err => t.error(err)) }) t.test('should send within dotfile directory', function (t) { t.plan(1) request(createServer({ dotfiles: 'allow', root: fixtures })) .get('/.mine/name.txt') - .expect(200, /tobi/, () => t.pass()) + .expect(200, /tobi/, err => t.error(err)) }) t.test('should 404 for non-existent dotfile', function (t) { t.plan(1) request(createServer({ dotfiles: 'allow', root: fixtures })) .get('/.nothere') - .expect(404, () => t.pass()) + .expect(404, err => t.error(err)) }) }) @@ -235,63 +235,63 @@ test('send(file, options)', function (t) { t.plan(1) request(createServer({ dotfiles: 'deny', root: fixtures })) .get('/.hidden.txt') - .expect(403, () => t.pass()) + .expect(403, err => t.error(err)) }) t.test('should 403 for dotfile directory', function (t) { t.plan(1) request(createServer({ dotfiles: 'deny', root: fixtures })) .get('/.mine') - .expect(403, () => t.pass()) + .expect(403, err => t.error(err)) }) t.test('should 403 for dotfile directory with trailing slash', function (t) { t.plan(1) request(createServer({ dotfiles: 'deny', root: fixtures })) .get('/.mine/') - .expect(403, () => t.pass()) + .expect(403, err => t.error(err)) }) t.test('should 403 for file within dotfile directory', function (t) { t.plan(1) request(createServer({ dotfiles: 'deny', root: fixtures })) .get('/.mine/name.txt') - .expect(403, () => t.pass()) + .expect(403, err => t.error(err)) }) t.test('should 403 for non-existent dotfile', function (t) { t.plan(1) request(createServer({ dotfiles: 'deny', root: fixtures })) .get('/.nothere') - .expect(403, () => t.pass()) + .expect(403, err => t.error(err)) }) t.test('should 403 for non-existent dotfile directory', function (t) { t.plan(1) request(createServer({ dotfiles: 'deny', root: fixtures })) .get('/.what/name.txt') - .expect(403, () => t.pass()) + .expect(403, err => t.error(err)) }) t.test('should 403 for dotfile in directory', function (t) { t.plan(1) request(createServer({ dotfiles: 'deny', root: fixtures })) .get('/pets/.hidden') - .expect(403, () => t.pass()) + .expect(403, err => t.error(err)) }) t.test('should 403 for dotfile in dotfile directory', function (t) { t.plan(1) request(createServer({ dotfiles: 'deny', root: fixtures })) .get('/.mine/.hidden') - .expect(403, () => t.pass()) + .expect(403, err => t.error(err)) }) t.test('should send files in root dotfile directory', function (t) { t.plan(1) request(createServer({ dotfiles: 'deny', root: path.join(fixtures, '.mine') })) .get('/name.txt') - .expect(200, /tobi/, () => t.pass()) + .expect(200, /tobi/, err => t.error(err)) }) t.test('should 403 for dotfile without root', function (t) { @@ -302,7 +302,7 @@ test('send(file, options)', function (t) { request(server) .get('/name.txt') - .expect(403, () => t.pass()) + .expect(403, err => t.error(err)) }) }) @@ -314,7 +314,7 @@ test('send(file, options)', function (t) { request(createServer({ dotfiles: 'ignore', root: fixtures })) .get('/.hidden.txt') - .expect(404, () => t.pass()) + .expect(404, err => t.error(err)) }) t.test('should 404 for dotfile directory', function (t) { @@ -322,7 +322,7 @@ test('send(file, options)', function (t) { request(createServer({ dotfiles: 'ignore', root: fixtures })) .get('/.mine') - .expect(404, () => t.pass()) + .expect(404, err => t.error(err)) }) t.test('should 404 for dotfile directory with trailing slash', function (t) { @@ -330,7 +330,7 @@ test('send(file, options)', function (t) { request(createServer({ dotfiles: 'ignore', root: fixtures })) .get('/.mine/') - .expect(404, () => t.pass()) + .expect(404, err => t.error(err)) }) t.test('should 404 for file within dotfile directory', function (t) { @@ -338,7 +338,7 @@ test('send(file, options)', function (t) { request(createServer({ dotfiles: 'ignore', root: fixtures })) .get('/.mine/name.txt') - .expect(404, () => t.pass()) + .expect(404, err => t.error(err)) }) t.test('should 404 for non-existent dotfile', function (t) { @@ -346,7 +346,7 @@ test('send(file, options)', function (t) { request(createServer({ dotfiles: 'ignore', root: fixtures })) .get('/.nothere') - .expect(404, () => t.pass()) + .expect(404, err => t.error(err)) }) t.test('should 404 for non-existent dotfile directory', function (t) { @@ -354,7 +354,7 @@ test('send(file, options)', function (t) { request(createServer({ dotfiles: 'ignore', root: fixtures })) .get('/.what/name.txt') - .expect(404, () => t.pass()) + .expect(404, err => t.error(err)) }) t.test('should send files in root dotfile directory', function (t) { @@ -362,7 +362,7 @@ test('send(file, options)', function (t) { request(createServer({ dotfiles: 'ignore', root: path.join(fixtures, '.mine') })) .get('/name.txt') - .expect(200, /tobi/, () => t.pass()) + .expect(200, /tobi/, err => t.error(err)) }) t.test('should 404 for dotfile without root', function (t) { @@ -374,7 +374,7 @@ test('send(file, options)', function (t) { request(server) .get('/name.txt') - .expect(404, () => t.pass()) + .expect(404, err => t.error(err)) }) }) }) @@ -397,14 +397,14 @@ test('send(file, options)', function (t) { }) request(app) .get('/.hidden.txt') - .expect(404, 'Not Found', () => t.pass()) + .expect(404, 'Not Found', err => t.error(err)) }) t.test('should default support sending hidden files', function (t) { t.plan(1) request(createServer({ hidden: true, root: fixtures })) .get('/.hidden.txt') - .expect(200, 'secret', () => t.pass()) + .expect(200, 'secret', err => t.error(err)) }) }) @@ -416,7 +416,7 @@ test('send(file, options)', function (t) { request(createServer({ root: fixtures })) .get('/name.txt') - .expect('Cache-Control', 'public, max-age=0', () => t.pass()) + .expect('Cache-Control', 'public, max-age=0', err => t.error(err)) }) t.test('should set immutable directive in Cache-Control', function (t) { @@ -424,7 +424,7 @@ test('send(file, options)', function (t) { request(createServer({ immutable: true, maxAge: '1h', root: fixtures })) .get('/name.txt') - .expect('Cache-Control', 'public, max-age=3600, immutable', () => t.pass()) + .expect('Cache-Control', 'public, max-age=3600, immutable', err => t.error(err)) }) }) @@ -435,28 +435,28 @@ test('send(file, options)', function (t) { t.plan(1) request(createServer({ root: fixtures })) .get('/name.txt') - .expect('Cache-Control', 'public, max-age=0', () => t.pass()) + .expect('Cache-Control', 'public, max-age=0', err => t.error(err)) }) t.test('should floor to integer', function (t) { t.plan(1) request(createServer({ maxAge: 123956, root: fixtures })) .get('/name.txt') - .expect('Cache-Control', 'public, max-age=123', () => t.pass()) + .expect('Cache-Control', 'public, max-age=123', err => t.error(err)) }) t.test('should accept string', function (t) { t.plan(1) request(createServer({ maxAge: '30d', root: fixtures })) .get('/name.txt') - .expect('Cache-Control', 'public, max-age=2592000', () => t.pass()) + .expect('Cache-Control', 'public, max-age=2592000', err => t.error(err)) }) t.test('should max at 1 year', function (t) { t.plan(1) request(createServer({ maxAge: '2y', root: fixtures })) .get('/name.txt') - .expect('Cache-Control', 'public, max-age=31536000', () => t.pass()) + .expect('Cache-Control', 'public, max-age=31536000', err => t.error(err)) }) }) @@ -468,7 +468,7 @@ test('send(file, options)', function (t) { request(createServer({ root: fixtures, index: 42 })) .get('/pets/') - .expect(500, /TypeError: index option/, () => t.pass()) + .expect(500, /TypeError: index option/, err => t.error(err)) }) t.test('should reject true', function (t) { @@ -476,7 +476,7 @@ test('send(file, options)', function (t) { request(createServer({ root: fixtures, index: true })) .get('/pets/') - .expect(500, /TypeError: index option/, () => t.pass()) + .expect(500, /TypeError: index option/, err => t.error(err)) }) t.test('should default to index.html', function (t) { @@ -484,7 +484,7 @@ test('send(file, options)', function (t) { request(createServer({ root: fixtures })) .get('/pets/') - .expect(fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8'), () => t.pass()) + .expect(fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8'), err => t.error(err)) }) t.test('should be configurable', function (t) { @@ -492,7 +492,7 @@ test('send(file, options)', function (t) { request(createServer({ root: fixtures, index: 'tobi.html' })) .get('/') - .expect(200, '

tobi

', () => t.pass()) + .expect(200, '

tobi

', err => t.error(err)) }) t.test('should support disabling', function (t) { @@ -500,7 +500,7 @@ test('send(file, options)', function (t) { request(createServer({ root: fixtures, index: false })) .get('/pets/') - .expect(403, () => t.pass()) + .expect(403, err => t.error(err)) }) t.test('should support fallbacks', function (t) { @@ -508,7 +508,7 @@ test('send(file, options)', function (t) { request(createServer({ root: fixtures, index: ['default.htm', 'index.html'] })) .get('/pets/') - .expect(200, fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8'), () => t.pass()) + .expect(200, fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8'), err => t.error(err)) }) t.test('should 404 if no index file found (file)', function (t) { @@ -516,7 +516,7 @@ test('send(file, options)', function (t) { request(createServer({ root: fixtures, index: 'default.htm' })) .get('/pets/') - .expect(404, () => t.pass()) + .expect(404, err => t.error(err)) }) t.test('should 404 if no index file found (dir)', function (t) { @@ -524,7 +524,7 @@ test('send(file, options)', function (t) { request(createServer({ root: fixtures, index: 'pets' })) .get('/') - .expect(404, () => t.pass()) + .expect(404, err => t.error(err)) }) t.test('should not follow directories', function (t) { @@ -532,7 +532,7 @@ test('send(file, options)', function (t) { request(createServer({ root: fixtures, index: ['pets', 'name.txt'] })) .get('/') - .expect(200, 'tobi', () => t.pass()) + .expect(200, 'tobi', err => t.error(err)) }) t.test('should work without root', function (t) { @@ -546,7 +546,7 @@ test('send(file, options)', function (t) { request(server) .get('/') - .expect(200, /tobi/, () => t.pass()) + .expect(200, /tobi/, err => t.error(err)) }) }) @@ -560,7 +560,7 @@ test('send(file, options)', function (t) { t.plan(1) request(createServer({ root: fixtures })) .get('/pets/../name.txt') - .expect(200, 'tobi', () => t.pass()) + .expect(200, 'tobi', err => t.error(err)) }) t.test('should work with trailing slash', function (t) { @@ -573,7 +573,7 @@ test('send(file, options)', function (t) { request(app) .get('/name.txt') - .expect(200, 'tobi', () => t.pass()) + .expect(200, 'tobi', err => t.error(err)) }) t.test('should work with empty path', function (t) { @@ -586,7 +586,7 @@ test('send(file, options)', function (t) { request(app) .get('/name.txt') - .expect(301, /Redirecting to/, () => t.pass()) + .expect(301, /Redirecting to/, err => t.error(err)) }) // @@ -605,7 +605,7 @@ test('send(file, options)', function (t) { request(app) .get('/') - .expect(200, 'tobi', () => t.pass()) + .expect(200, 'tobi', err => t.error(err)) }) t.test('should restrict paths to within root', function (t) { @@ -613,7 +613,7 @@ test('send(file, options)', function (t) { request(createServer({ root: fixtures })) .get('/pets/../../send.js') - .expect(403, () => t.pass()) + .expect(403, err => t.error(err)) }) t.test('should allow .. in root', function (t) { @@ -626,7 +626,7 @@ test('send(file, options)', function (t) { request(app) .get('/pets/../../send.js') - .expect(403, () => t.pass()) + .expect(403, err => t.error(err)) }) t.test('should not allow root transversal', function (t) { @@ -634,7 +634,7 @@ test('send(file, options)', function (t) { request(createServer({ root: path.join(fixtures, 'name.d') })) .get('/../name.dir/name.txt') - .expect(403, () => t.pass()) + .expect(403, err => t.error(err)) }) t.test('should not allow root path disclosure', function (t) { @@ -642,7 +642,7 @@ test('send(file, options)', function (t) { request(createServer({ root: fixtures })) .get('/pets/../../fixtures/name.txt') - .expect(403, () => t.pass()) + .expect(403, err => t.error(err)) }) }) @@ -659,7 +659,7 @@ test('send(file, options)', function (t) { request(app) .get('/../send.js') - .expect(403, () => t.pass()) + .expect(403, err => t.error(err)) }) t.test('should still serve files with dots in name', function (t) { @@ -672,7 +672,7 @@ test('send(file, options)', function (t) { request(app) .get('/do..ts.txt') - .expect(200, '...', () => t.pass()) + .expect(200, '...', err => t.error(err)) }) }) }) From 3c18fc1874d7c3821f2421f5a5c56800be1d5878 Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Fri, 13 Jan 2023 14:37:41 +0100 Subject: [PATCH 011/109] use listenerCount immediatly (#13) * use listenerCount immediatly * like before --- index.js | 26 ++++---------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/index.js b/index.js index 54ed8e5..d2b7709 100644 --- a/index.js +++ b/index.js @@ -251,14 +251,16 @@ SendStream.prototype.maxage = deprecate.function(function maxage (maxAge) { /** * Emit error with `status`. * + * @memberof SendStream * @param {number} status * @param {Error} [err] + * @this {Stream} * @private */ SendStream.prototype.error = function error (status, err) { // emit if listeners instead of responding - if (hasListeners(this, 'error')) { + if (this.listenerCount('error') > 0) { return this.emit('error', createHttpError(status, err)) } @@ -463,7 +465,7 @@ SendStream.prototype.isRangeFresh = function isRangeFresh () { SendStream.prototype.redirect = function redirect (path) { const res = this.res - if (hasListeners(this, 'directory')) { + if (this.listenerCount('directory') > 0) { this.emit('directory', res, path) return } @@ -1014,26 +1016,6 @@ function getHeaderNames (res) { : res.getHeaderNames() } -/** - * Determine if emitter has listeners of a given type. - * - * The way to do this check is done three different ways in Node.js >= 0.8 - * so this consolidates them into a minimal set using instance methods. - * - * @param {EventEmitter} emitter - * @param {string} type - * @returns {boolean} - * @private - */ - -function hasListeners (emitter, type) { - const count = typeof emitter.listenerCount !== 'function' - ? emitter.listeners(type).length - : emitter.listenerCount(type) - - return count > 0 -} - /** * Determine if the response headers have been sent. * From 8642dfb9d621c92e918cc6092bea2269c47dc2b6 Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Fri, 13 Jan 2023 15:21:26 +0100 Subject: [PATCH 012/109] remove deprecated options and methods (#12) * remove deprecated option and methods * decrease branch coverage to 95 --- .taprc | 2 +- index.js | 83 +-------------------- package.json | 1 - test/mime.test.js | 2 - test/send-pipe.test.js | 166 +---------------------------------------- test/send.test.js | 45 +---------- types/index.d.ts | 36 --------- types/index.test-d.ts | 4 +- 8 files changed, 5 insertions(+), 334 deletions(-) diff --git a/.taprc b/.taprc index 252c3aa..7c34f05 100644 --- a/.taprc +++ b/.taprc @@ -1,4 +1,4 @@ files: - test/**/*.test.js -branches: 96 +branches: 95 diff --git a/index.js b/index.js index d2b7709..d6d275d 100644 --- a/index.js +++ b/index.js @@ -14,7 +14,6 @@ const createError = require('http-errors') const debug = require('debug')('send') -const deprecate = require('depd')('send') const destroy = require('destroy') const encodeUrl = require('encodeurl') const escapeHtml = require('escape-html') @@ -114,12 +113,6 @@ function SendStream (req, path, options) { throw new TypeError('dotfiles option must be "allow", "deny", or "ignore"') } - this._hidden = Boolean(opts.hidden) - - if (opts.hidden !== undefined) { - deprecate('hidden: use dotfiles: \'' + (this._hidden ? 'allow' : 'ignore') + '\' instead') - } - // legacy support if (opts.dotfiles === undefined) { this._dotfiles = undefined @@ -152,10 +145,6 @@ function SendStream (req, path, options) { this._root = opts.root ? resolve(opts.root) : null - - if (!this._root && opts.from) { - this.from(opts.from) - } } /** @@ -164,57 +153,12 @@ function SendStream (req, path, options) { util.inherits(SendStream, Stream) -/** - * Enable or disable etag generation. - * - * @param {Boolean} val - * @return {SendStream} - * @api public - */ - -SendStream.prototype.etag = deprecate.function(function etag (val) { - this._etag = Boolean(val) - debug('etag %s', this._etag) - return this -}, 'send.etag: pass etag as option') - -/** - * Enable or disable "hidden" (dot) files. - * - * @param {Boolean} path - * @return {SendStream} - * @api public - */ - -SendStream.prototype.hidden = deprecate.function(function hidden (val) { - this._hidden = Boolean(val) - this._dotfiles = undefined - debug('hidden %s', this._hidden) - return this -}, 'send.hidden: use dotfiles option') - -/** - * Set index `paths`, set to a falsy - * value to disable index support. - * - * @param {String|Boolean|Array} paths - * @return {SendStream} - * @api public - */ - -SendStream.prototype.index = deprecate.function(function index (paths) { - const index = !paths ? [] : normalizeList(paths, 'paths argument') - debug('index %o', paths) - this._index = index - return this -}, 'send.index: pass index as option') - /** * Set root `path`. * * @param {String} path * @return {SendStream} - * @api public + * @api private */ SendStream.prototype.root = function root (path) { @@ -223,31 +167,6 @@ SendStream.prototype.root = function root (path) { return this } -SendStream.prototype.from = deprecate.function(SendStream.prototype.root, - 'send.from: pass root as option') - -SendStream.prototype.root = deprecate.function(SendStream.prototype.root, - 'send.root: pass root as option') - -/** - * Set max-age to `maxAge`. - * - * @param {Number} maxAge - * @return {SendStream} - * @api public - */ - -SendStream.prototype.maxage = deprecate.function(function maxage (maxAge) { - this._maxage = typeof maxAge === 'string' - ? ms(maxAge) - : Number(maxAge) - this._maxage = !isNaN(this._maxage) - ? Math.min(Math.max(0, this._maxage), MAX_MAXAGE) - : 0 - debug('max-age %d', this._maxage) - return this -}, 'send.maxage: pass maxAge as option') - /** * Emit error with `status`. * diff --git a/package.json b/package.json index e3092e8..67063a5 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ ], "dependencies": { "debug": "^4.3.4", - "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", diff --git a/test/mime.test.js b/test/mime.test.js index b68429c..f93641c 100644 --- a/test/mime.test.js +++ b/test/mime.test.js @@ -1,7 +1,5 @@ 'use strict' -process.env.NO_DEPRECATION = 'send' - const { test } = require('tap') const path = require('path') const request = require('supertest') diff --git a/test/send-pipe.test.js b/test/send-pipe.test.js index 24c5304..b6ecebd 100644 --- a/test/send-pipe.test.js +++ b/test/send-pipe.test.js @@ -1,10 +1,7 @@ 'use strict' -process.env.NO_DEPRECATION = 'send' - const { test } = require('tap') const after = require('after') -const fs = require('fs') const http = require('http') const path = require('path') const request = require('supertest') @@ -15,7 +12,7 @@ const dateRegExp = /^\w{3}, \d+ \w+ \d+ \d+:\d+:\d+ \w+$/ const fixtures = path.join(__dirname, 'fixtures') test('send(file).pipe(res)', function (t) { - t.plan(32) + t.plan(27) t.test('should stream the file contents', function (t) { t.plan(1) @@ -1442,167 +1439,6 @@ test('send(file).pipe(res)', function (t) { }) }) - t.test('.etag()', function (t) { - t.plan(1) - - t.test('should support disabling etags', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) - .etag(false) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(shouldNotHaveHeader('ETag', t)) - .expect(200, err => t.error(err)) - }) - }) - - t.test('.from()', function (t) { - t.plan(1) - - t.test('should set with deprecated from', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - send(req, req.url) - .from(fixtures) - .pipe(res) - }) - - request(app) - .get('/pets/../name.txt') - .expect(200, 'tobi', err => t.error(err)) - }) - }) - - t.test('.hidden()', function (t) { - t.plan(1) - - t.test('should default support sending hidden files', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) - .hidden(true) - .pipe(res) - }) - - request(app) - .get('/.hidden.txt') - .expect(200, 'secret', err => t.error(err)) - }) - }) - - t.test('.index()', function (t) { - t.plan(3) - - t.test('should be configurable', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) - .index('tobi.html') - .pipe(res) - }) - - request(app) - .get('/') - .expect(200, '

tobi

', err => t.error(err)) - }) - - t.test('should support disabling', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) - .index(false) - .pipe(res) - }) - - request(app) - .get('/pets/') - .expect(403, err => t.error(err)) - }) - - t.test('should support fallbacks', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) - .index(['default.htm', 'index.html']) - .pipe(res) - }) - - request(app) - .get('/pets/') - .expect(200, fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8'), err => t.error(err)) - }) - }) - - t.test('.maxage()', function (t) { - t.plan(4) - - t.test('should default to 0', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - send(req, 'test/fixtures/name.txt') - .maxage(undefined) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect('Cache-Control', 'public, max-age=0', err => t.error(err)) - }) - - t.test('should floor to integer', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - send(req, 'test/fixtures/name.txt') - .maxage(1234) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect('Cache-Control', 'public, max-age=1', err => t.error(err)) - }) - - t.test('should accept string', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - send(req, 'test/fixtures/name.txt') - .maxage('30d') - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect('Cache-Control', 'public, max-age=2592000', err => t.error(err)) - }) - - t.test('should max at 1 year', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - send(req, 'test/fixtures/name.txt') - .maxage(Infinity) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect('Cache-Control', 'public, max-age=31536000', err => t.error(err)) - }) - }) - t.test('.root()', function (t) { t.plan(1) diff --git a/test/send.test.js b/test/send.test.js index 1c65aa5..395d12e 100644 --- a/test/send.test.js +++ b/test/send.test.js @@ -1,7 +1,5 @@ 'use strict' -process.env.NO_DEPRECATION = 'send' - const { test } = require('tap') const fs = require('fs') const http = require('http') @@ -15,7 +13,7 @@ const { shouldNotHaveHeader, createServer } = require('./utils') const fixtures = path.join(__dirname, 'fixtures') test('send(file, options)', function (t) { - t.plan(12) + t.plan(10) t.test('acceptRanges', function (t) { t.plan(2) @@ -164,18 +162,6 @@ test('send(file, options)', function (t) { }) }) - t.test('from', function (t) { - t.plan(1) - - t.test('should set with deprecated from', function (t) { - t.plan(1) - - request(createServer({ from: fixtures })) - .get('/pets/../name.txt') - .expect(200, 'tobi', err => t.error(err)) - }) - }) - t.test('dotfiles', function (t) { t.plan(6) @@ -379,35 +365,6 @@ test('send(file, options)', function (t) { }) }) - t.test('hidden', function (t) { - t.plan(2) - - t.test('should default to false', function (t) { - t.plan(1) - - const 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) - }) - request(app) - .get('/.hidden.txt') - .expect(404, 'Not Found', err => t.error(err)) - }) - - t.test('should default support sending hidden files', function (t) { - t.plan(1) - request(createServer({ hidden: true, root: fixtures })) - .get('/.hidden.txt') - .expect(200, 'secret', err => t.error(err)) - }) - }) - t.test('immutable', function (t) { t.plan(2) diff --git a/types/index.d.ts b/types/index.d.ts index 9ceb18b..4f6b670 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -113,42 +113,6 @@ declare namespace send { } export interface SendStream extends stream.Stream { - /** - * @deprecated pass etag as option - * Enable or disable etag generation. - */ - etag(val: boolean): SendStream; - - /** - * @deprecated use dotfiles option - * Enable or disable "hidden" (dot) files. - */ - hidden(val: boolean): SendStream; - - /** - * @deprecated pass index as option - * Set index `paths`, set to a falsy value to disable index support. - */ - index(paths: string[] | string): SendStream; - - /** - * @deprecated pass root as option - * Set root `path`. - */ - root(paths: string): SendStream; - - /** - * @deprecated pass root as option - * Set root `path`. - */ - from(paths: string): SendStream; - - /** - * @deprecated pass maxAge as option - * Set max-age to `maxAge`. - */ - maxage(maxAge: string | number): SendStream; - /** * Emit error with `status`. */ diff --git a/types/index.test-d.ts b/types/index.test-d.ts index f015304..ad87230 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -17,9 +17,7 @@ send(req, '/test.html', { root: __dirname + '/wwwroot' }).pipe(res); -send(req, '/test.html') - .maxage(0) - .root(__dirname + '/wwwroot') +send(req, '/test.html', { maxAge: 0, root: __dirname + '/wwwroot'}) .on('error', (err: any) => { res.statusCode = err.status || 500; res.end(err.message); From ae87c175043112c6c779e307a02b31d32d122026 Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Fri, 13 Jan 2023 15:29:06 +0100 Subject: [PATCH 013/109] remove node < 8 ponyfills for res.headersSent and res.getHeaderNames (#14) --- index.js | 32 ++------------------------------ 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/index.js b/index.js index d6d275d..bf0b53c 100644 --- a/index.js +++ b/index.js @@ -521,7 +521,7 @@ SendStream.prototype.send = function send (path, stat) { let ranges = req.headers.range let offset = options.start || 0 - if (headersSent(res)) { + if (res.headersSent) { // impossible to send now this.headersAlreadySent() return @@ -809,7 +809,7 @@ SendStream.prototype.setHeader = function setHeader (path, stat) { */ function clearHeaders (res) { - const headers = getHeaderNames(res) + const headers = res.getHeaderNames() for (let i = 0; i < headers.length; i++) { res.removeHeader(headers[i]) @@ -921,34 +921,6 @@ function decode (path) { } } -/** - * Get the header names on a respnse. - * - * @param {object} res - * @returns {array[string]} - * @private - */ - -function getHeaderNames (res) { - return typeof res.getHeaderNames !== 'function' - ? Object.keys(res._headers || {}) - : res.getHeaderNames() -} - -/** - * Determine if the response headers have been sent. - * - * @param {object} res - * @returns {boolean} - * @private - */ - -function headersSent (res) { - return typeof res.headersSent !== 'boolean' - ? Boolean(res._header) - : res.headersSent -} - /** * Normalize the index option into an array. * From 8c05363a31d5845093b0c6a8f4dfd53dd7795631 Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Fri, 13 Jan 2023 18:43:56 +0100 Subject: [PATCH 014/109] 100% test coverage (#16) * 100% test coverage * add ENAMETOOLONG test * add test case for ENOTDIR * fix linting * increase longFilename to 512 characters * patch onStatError for further investigation on windows * try to ignore block for coverage * windows switch * fix linting --- .taprc | 2 -- index.js | 9 ++++--- package.json | 1 + test/send-pipe.test.js | 61 ++++++++++++++++++++++++++++++++++++++++-- test/statuses.test.js | 18 +++++++++++++ 5 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 test/statuses.test.js diff --git a/.taprc b/.taprc index 7c34f05..eb6eb3e 100644 --- a/.taprc +++ b/.taprc @@ -1,4 +1,2 @@ files: - test/**/*.test.js - -branches: 95 diff --git a/index.js b/index.js index bf0b53c..25971aa 100644 --- a/index.js +++ b/index.js @@ -184,7 +184,7 @@ SendStream.prototype.error = function error (status, err) { } const res = this.res - const msg = statuses.message[status] || String(status) + const msg = statuses.message[status] const doc = createHtmlDocument('Error', escapeHtml(msg)) // clear existing headers @@ -323,10 +323,12 @@ SendStream.prototype.isCachable = function isCachable () { */ SendStream.prototype.onStatError = function onStatError (error) { + // POSIX throws ENAMETOOLONG and ENOTDIR, Windows only ENOENT + /* istanbul ignore next */ switch (error.code) { case 'ENAMETOOLONG': - case 'ENOENT': case 'ENOTDIR': + case 'ENOENT': this.error(404, error) break default: @@ -474,10 +476,9 @@ SendStream.prototype.pipe = function pipe (res) { if (containsDotFile(parts)) { let access = this._dotfiles - // legacy support if (access === undefined) { access = parts[parts.length - 1][0] === '.' - ? (this._hidden ? 'allow' : 'ignore') + ? 'ignore' : 'allow' } diff --git a/package.json b/package.json index 67063a5..7bb2d8b 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "lint": "standard | snazzy", "lint:fix": "standard --fix | snazzy", "test": "npm run test:unit && npm run test:typescript", + "test:coverage": "tap --coverage-report=html", "test:typescript": "tsd", "test:unit": "tap" } diff --git a/test/send-pipe.test.js b/test/send-pipe.test.js index b6ecebd..41f9f20 100644 --- a/test/send-pipe.test.js +++ b/test/send-pipe.test.js @@ -6,13 +6,14 @@ const http = require('http') const path = require('path') const request = require('supertest') const send = require('..') +const os = require('os') 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).pipe(res)', function (t) { - t.plan(27) + t.plan(29) t.test('should stream the file contents', function (t) { t.plan(1) @@ -296,6 +297,36 @@ test('send(file).pipe(res)', function (t) { .expect(200, '404 ENOENT', err => t.error(err)) }) + t.test('should emit ENAMETOOLONG if the filename is too long', function (t) { + t.plan(1) + + const app = http.createServer(function (req, res) { + send(req, req.url, { root: fixtures }) + .on('error', function (err) { res.end(err.statusCode + ' ' + err.code) }) + .pipe(res) + }) + + const longFilename = new Array(512).fill('a').join('') + + request(app) + .get('/' + longFilename) + .expect(200, os.platform() === 'win32' ? '404 ENOENT' : '404 ENAMETOOLONG', err => t.error(err)) + }) + + t.test('should emit ENOTDIR if the requested resource is not a directory', function (t) { + t.plan(1) + + const 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('/nums.txt/invalid') + .expect(200, os.platform() === 'win32' ? '404 ENOENT' : '404 ENOTDIR', err => t.error(err)) + }) + t.test('should not override content-type', function (t) { t.plan(1) @@ -630,7 +661,7 @@ test('send(file).pipe(res)', function (t) { }) t.test('with conditional-GET', function (t) { - t.plan(6) + t.plan(7) t.test('should remove Content headers with 304', function (t) { t.plan(5) @@ -657,6 +688,32 @@ test('send(file).pipe(res)', function (t) { }) }) + t.test('should remove Content headers with 304 /2', function (t) { + t.plan(5) + + 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') + res.statusCode = 304 + }) + + request(server) + .get('/name.txt') + .expect(304, function (err, res) { + t.error(err) + request(server) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .expect(shouldNotHaveHeader('Content-Language', t)) + .expect(shouldNotHaveHeader('Content-Length', t)) + .expect(shouldNotHaveHeader('Content-Type', t)) + .expect('Content-Location', 'http://localhost/name.txt') + .expect('Contents', 'foo') + .expect(304, err => t.error(err)) + }) + }) + t.test('should not remove all Content-* headers', function (t) { t.plan(4) diff --git a/test/statuses.test.js b/test/statuses.test.js new file mode 100644 index 0000000..40cf8d9 --- /dev/null +++ b/test/statuses.test.js @@ -0,0 +1,18 @@ +'use strict' + +const { test } = require('tap') +const statuses = require('statuses') + +test('statuses', function (t) { + t.plan(1) + + t.test('should have uses statusCodes', function (t) { + t.plan(6) + t.ok(statuses(400)) + t.ok(statuses(403)) + t.ok(statuses(404)) + t.ok(statuses(412)) + t.ok(statuses(416)) + t.ok(statuses(500)) + }) +}) From 7ce38ecdecad124d6766fc8b9a644c2d994a3e02 Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Fri, 13 Jan 2023 21:48:26 +0100 Subject: [PATCH 015/109] avoid deoptimization in range support (#20) --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index 25971aa..88bc7d6 100644 --- a/index.js +++ b/index.js @@ -557,7 +557,7 @@ SendStream.prototype.send = function send (path, stat) { } // Range support - if (this._acceptRanges && BYTES_RANGE_REGEXP.test(ranges)) { + if (this._acceptRanges && ranges !== undefined && BYTES_RANGE_REGEXP.test(ranges)) { // parse ranges = parseRange(len, ranges, { combine: true From 3f4ffb2686a1a76c17f38dd1970c3ae6e141d9a0 Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Fri, 13 Jan 2023 21:50:24 +0100 Subject: [PATCH 016/109] add example (#19) --- examples/index.html | 1 + examples/simple.js | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 examples/index.html create mode 100644 examples/simple.js diff --git a/examples/index.html b/examples/index.html new file mode 100644 index 0000000..7f41cde --- /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 0000000..e9e3614 --- /dev/null +++ b/examples/simple.js @@ -0,0 +1,14 @@ +'use strict' + +const http = require('http') +const send = require('..') +const path = require('path') + +const indexPath = path.join(__dirname, 'index.html') + +const server = http.createServer(function onRequest (req, res) { + send(req, indexPath) + .pipe(res) +}) + +server.listen(3000) From 3ec3d1d4a1166a330f136283541f5dd00eee2e83 Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Fri, 13 Jan 2023 21:52:35 +0100 Subject: [PATCH 017/109] add pre-commit (#18) --- package.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 7bb2d8b..bdad3b4 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "statuses": "2.0.1" }, "devDependencies": { + "@fastify/pre-commit": "^2.0.2", "@types/node": "^18.11.18", "after": "0.8.2", "snazzy": "^9.0.0", @@ -47,5 +48,9 @@ "test:coverage": "tap --coverage-report=html", "test:typescript": "tsd", "test:unit": "tap" - } + }, + "pre-commit": [ + "lint", + "test" + ] } From 75e0ff456fb16030c5a09061b2f5b334d18db1ca Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Sat, 14 Jan 2023 00:20:32 +0100 Subject: [PATCH 018/109] use fast-decode-uri-component (#21) --- index.js | 21 ++------------------- package.json | 1 + 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/index.js b/index.js index 88bc7d6..3998686 100644 --- a/index.js +++ b/index.js @@ -28,6 +28,7 @@ const path = require('path') const statuses = require('statuses') const Stream = require('stream') const util = require('util') +const decode = require('fast-decode-uri-component') /** * Path function references. @@ -427,7 +428,7 @@ SendStream.prototype.pipe = function pipe (res) { // decode the path let path = decode(this.path) - if (path === -1) { + if (path === null) { this.error(400) return res } @@ -904,24 +905,6 @@ function createHttpError (status, err) { : 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 - } -} - /** * Normalize the index option into an array. * diff --git a/package.json b/package.json index bdad3b4..7cf1841 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", + "fast-decode-uri-component": "^1.0.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "^3.0.0", From ae3d3d9b52c2050cc0cd7b8ca5286b3f9ea42722 Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Sat, 14 Jan 2023 09:32:50 +0100 Subject: [PATCH 019/109] remove statuses package, precompute errors (#17) --- index.js | 26 ++++++++++++++++++-------- package.json | 3 +-- test/statuses.test.js | 18 ------------------ 3 files changed, 19 insertions(+), 28 deletions(-) delete mode 100644 test/statuses.test.js diff --git a/index.js b/index.js index 3998686..aa23256 100644 --- a/index.js +++ b/index.js @@ -25,7 +25,6 @@ const ms = require('ms') const onFinished = require('on-finished') const parseRange = require('range-parser') const path = require('path') -const statuses = require('statuses') const Stream = require('stream') const util = require('util') const decode = require('fast-decode-uri-component') @@ -62,6 +61,15 @@ const MAX_MAXAGE = 60 * 60 * 24 * 365 * 1000 // 1 year 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') +} + /** * Return a `SendStream` for `req` and `path`. * @@ -185,8 +193,6 @@ SendStream.prototype.error = function error (status, err) { } const res = this.res - const msg = statuses.message[status] - const doc = createHtmlDocument('Error', escapeHtml(msg)) // clear existing headers clearHeaders(res) @@ -196,13 +202,15 @@ SendStream.prototype.error = function error (status, err) { setHeaders(res, err.headers) } + const doc = ERROR_RESPONSES[status] + // 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-Length', doc[1]) res.setHeader('Content-Security-Policy', "default-src 'none'") res.setHeader('X-Content-Type-Options', 'nosniff') - res.end(doc) + res.end(doc[0]) } /** @@ -404,11 +412,11 @@ SendStream.prototype.redirect = function redirect (path) { // redirect res.statusCode = 301 res.setHeader('Content-Type', 'text/html; charset=UTF-8') - res.setHeader('Content-Length', Buffer.byteLength(doc)) + res.setHeader('Content-Length', doc[1]) res.setHeader('Content-Security-Policy', "default-src 'none'") res.setHeader('X-Content-Type-Options', 'nosniff') res.setHeader('Location', loc) - res.end(doc) + res.end(doc[0]) } /** @@ -875,7 +883,7 @@ function contentRange (type, size, range) { */ function createHtmlDocument (title, body) { - return '\n' + + const html = '\n' + '\n' + '\n' + '\n' + @@ -885,6 +893,8 @@ function createHtmlDocument (title, body) { '
' + body + '
\n' + '\n' + '\n' + + return [html, Buffer.byteLength(html)] } /** diff --git a/package.json b/package.json index 7cf1841..535fd57 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,7 @@ "mime": "^3.0.0", "ms": "2.1.3", "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" + "range-parser": "~1.2.1" }, "devDependencies": { "@fastify/pre-commit": "^2.0.2", diff --git a/test/statuses.test.js b/test/statuses.test.js deleted file mode 100644 index 40cf8d9..0000000 --- a/test/statuses.test.js +++ /dev/null @@ -1,18 +0,0 @@ -'use strict' - -const { test } = require('tap') -const statuses = require('statuses') - -test('statuses', function (t) { - t.plan(1) - - t.test('should have uses statusCodes', function (t) { - t.plan(6) - t.ok(statuses(400)) - t.ok(statuses(403)) - t.ok(statuses(404)) - t.ok(statuses(412)) - t.ok(statuses(416)) - t.ok(statuses(500)) - }) -}) From 5e28adcad19f7b1bdce8bd9afa10dd4095e04184 Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Sat, 14 Jan 2023 16:51:24 +0100 Subject: [PATCH 020/109] remove encodeurl (#23) --- index.js | 3 +-- package.json | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/index.js b/index.js index aa23256..fdb44e2 100644 --- a/index.js +++ b/index.js @@ -15,7 +15,6 @@ const createError = require('http-errors') const debug = require('debug')('send') const destroy = require('destroy') -const encodeUrl = require('encodeurl') const escapeHtml = require('escape-html') const etag = require('etag') const fresh = require('fresh') @@ -405,7 +404,7 @@ SendStream.prototype.redirect = function redirect (path) { return } - const loc = encodeUrl(collapseLeadingSlashes(this.path + '/')) + const loc = encodeURI(collapseLeadingSlashes(this.path + '/')) const doc = createHtmlDocument('Redirecting', 'Redirecting to
' + escapeHtml(loc) + '') diff --git a/package.json b/package.json index 535fd57..84b8005 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "dependencies": { "debug": "^4.3.4", "destroy": "1.2.0", - "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fast-decode-uri-component": "^1.0.1", From 9f0df93774dd54b19bb31d39c83623a011686b65 Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Sat, 14 Jan 2023 20:24:01 +0100 Subject: [PATCH 021/109] remove destroy (#25) --- index.js | 3 +-- package.json | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/index.js b/index.js index fdb44e2..87aba0c 100644 --- a/index.js +++ b/index.js @@ -14,7 +14,6 @@ const createError = require('http-errors') const debug = require('debug')('send') -const destroy = require('destroy') const escapeHtml = require('escape-html') const etag = require('etag') const fresh = require('fresh') @@ -715,7 +714,7 @@ SendStream.prototype.stream = function stream (path, options) { // cleanup function cleanup () { - destroy(stream, true) + stream.destroy() } // response finished, cleanup diff --git a/package.json b/package.json index 84b8005..efd48a5 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ ], "dependencies": { "debug": "^4.3.4", - "destroy": "1.2.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fast-decode-uri-component": "^1.0.1", From 3d6de26b8b637168862c5adb6717528a32ca47ed Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Sat, 14 Jan 2023 20:26:51 +0100 Subject: [PATCH 022/109] remove backwards compatibilty of dotfiles (#22) --- index.js | 17 ++--------------- test/send.test.js | 10 +--------- 2 files changed, 3 insertions(+), 24 deletions(-) diff --git a/index.js b/index.js index 87aba0c..731bc86 100644 --- a/index.js +++ b/index.js @@ -120,11 +120,6 @@ function SendStream (req, path, options) { throw new TypeError('dotfiles option must be "allow", "deny", or "ignore"') } - // legacy support - if (opts.dotfiles === undefined) { - this._dotfiles = undefined - } - this._extensions = opts.extensions !== undefined ? normalizeList(opts.extensions, 'extensions option') : [] @@ -481,16 +476,8 @@ SendStream.prototype.pipe = function pipe (res) { // dotfile handling if (containsDotFile(parts)) { - let access = this._dotfiles - - if (access === undefined) { - access = parts[parts.length - 1][0] === '.' - ? 'ignore' - : 'allow' - } - - debug('%s dotfile "%s"', access, path) - switch (access) { + debug('%s dotfile "%s"', this._dotfiles, path) + switch (this._dotfiles) { case 'allow': break case 'deny': diff --git a/test/send.test.js b/test/send.test.js index 395d12e..dc2bf54 100644 --- a/test/send.test.js +++ b/test/send.test.js @@ -163,7 +163,7 @@ test('send(file, options)', function (t) { }) t.test('dotfiles', function (t) { - t.plan(6) + t.plan(5) t.test('should default to "ignore"', function (t) { t.plan(1) @@ -173,14 +173,6 @@ test('send(file, options)', function (t) { .expect(404, err => t.error(err)) }) - t.test('should allow file within dotfile directory for back-compat', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) - .get('/.mine/name.txt') - .expect(200, /tobi/, err => t.error(err)) - }) - t.test('should reject bad value', function (t) { t.plan(1) From 5ba5860d52899f3f04cc209ed6466c52417515f4 Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Sat, 14 Jan 2023 22:38:15 +0100 Subject: [PATCH 023/109] remove debug (#24) --- README.md | 4 ++-- index.js | 2 +- package.json | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6c6b45b..f7b7293 100644 --- a/README.md +++ b/README.md @@ -153,10 +153,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 diff --git a/index.js b/index.js index 731bc86..8133614 100644 --- a/index.js +++ b/index.js @@ -13,7 +13,7 @@ */ const createError = require('http-errors') -const debug = require('debug')('send') +const debug = require('node:util').debuglog('send') const escapeHtml = require('escape-html') const etag = require('etag') const fresh = require('fresh') diff --git a/package.json b/package.json index efd48a5..2824f45 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "server" ], "dependencies": { - "debug": "^4.3.4", "escape-html": "~1.0.3", "etag": "~1.8.1", "fast-decode-uri-component": "^1.0.1", From 54f74ae313d9da6f2e0c2f4e1ad053a9269db437 Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Sun, 15 Jan 2023 19:45:36 +0100 Subject: [PATCH 024/109] remove etag (#26) --- index.js | 7 +++---- package.json | 1 - 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/index.js b/index.js index 8133614..9f317c1 100644 --- a/index.js +++ b/index.js @@ -15,7 +15,6 @@ const createError = require('http-errors') const debug = require('node:util').debuglog('send') const escapeHtml = require('escape-html') -const etag = require('etag') const fresh = require('fresh') const fs = require('fs') const mime = require('mime') @@ -790,9 +789,9 @@ SendStream.prototype.setHeader = function setHeader (path, stat) { } if (this._etag && !res.getHeader('ETag')) { - const val = etag(stat) - debug('etag %s', val) - res.setHeader('ETag', val) + const etag = 'W/"' + stat.size.toString(16) + '-' + stat.mtime.getTime().toString(16) + '"' + debug('etag %s', etag) + res.setHeader('ETag', etag) } } diff --git a/package.json b/package.json index 2824f45..321784a 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ ], "dependencies": { "escape-html": "~1.0.3", - "etag": "~1.8.1", "fast-decode-uri-component": "^1.0.1", "fresh": "0.5.2", "http-errors": "2.0.0", From 504b98189ba162c10fc38ad63f33a06af7c0b69c Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Mon, 16 Jan 2023 09:09:30 +0100 Subject: [PATCH 025/109] restructure (#27) --- index.js | 967 +----------------- lib/SendStream.js | 796 ++++++++++++++ lib/clearHeaders.js | 21 + lib/collapseLeadingSlashes.js | 23 + lib/containsDotFile.js | 23 + lib/contentRange.js | 18 + lib/createHtmlDocument.js | 29 + lib/createHttpError.js | 23 + lib/isUtf8MimeType.js | 6 + lib/normalizeList.js | 23 + lib/parseHttpDate.js | 18 + lib/parseTokenList.js | 43 + lib/setHeaders.js | 20 + ...d-pipe.test.js => SendStream-pipe.test.js} | 126 +-- test/{send.test.js => SendStream.test.js} | 36 +- test/constructor.test.js | 13 + types/index.d.ts | 5 +- types/index.test-d.ts | 7 +- 18 files changed, 1149 insertions(+), 1048 deletions(-) create mode 100644 lib/SendStream.js create mode 100644 lib/clearHeaders.js create mode 100644 lib/collapseLeadingSlashes.js create mode 100644 lib/containsDotFile.js create mode 100644 lib/contentRange.js create mode 100644 lib/createHtmlDocument.js create mode 100644 lib/createHttpError.js create mode 100644 lib/isUtf8MimeType.js create mode 100644 lib/normalizeList.js create mode 100644 lib/parseHttpDate.js create mode 100644 lib/parseTokenList.js create mode 100644 lib/setHeaders.js rename test/{send-pipe.test.js => SendStream-pipe.test.js} (91%) rename test/{send.test.js => SendStream.test.js} (93%) create mode 100644 test/constructor.test.js diff --git a/index.js b/index.js index 9f317c1..da7e627 100644 --- a/index.js +++ b/index.js @@ -11,61 +11,9 @@ * Module dependencies. * @private */ - -const createError = require('http-errors') -const debug = require('node:util').debuglog('send') -const escapeHtml = require('escape-html') -const fresh = require('fresh') -const fs = require('fs') +const isUtf8MimeType = require('./lib/isUtf8MimeType') const mime = require('mime') -const ms = require('ms') -const onFinished = require('on-finished') -const parseRange = require('range-parser') -const path = require('path') -const Stream = require('stream') -const util = require('util') -const decode = require('fast-decode-uri-component') - -/** - * Path function references. - * @private - */ - -const extname = path.extname -const join = path.join -const normalize = path.normalize -const resolve = path.resolve -const sep = path.sep - -/** - * 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 SendStream = require('./lib/SendStream') /** * Return a `SendStream` for `req` and `path`. @@ -81,916 +29,6 @@ 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) - - const 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) - -/** - * Set root `path`. - * - * @param {String} path - * @return {SendStream} - * @api private - */ - -SendStream.prototype.root = function root (path) { - this._root = resolve(String(path)) - debug('root %s', this._root) - return this -} - -/** - * Emit error with `status`. - * - * @memberof SendStream - * @param {number} status - * @param {Error} [err] - * @this {Stream} - * @private - */ - -SendStream.prototype.error = function error (status, err) { - // emit if listeners instead of responding - if (this.listenerCount('error') > 0) { - return this.emit('error', createHttpError(status, err)) - } - - const res = this.res - - // clear existing headers - clearHeaders(res) - - // add error headers - if (err && err.headers) { - setHeaders(res, err.headers) - } - - const doc = ERROR_RESPONSES[status] - - // send basic response - res.statusCode = status - res.setHeader('Content-Type', 'text/html; charset=UTF-8') - res.setHeader('Content-Length', doc[1]) - res.setHeader('Content-Security-Policy', "default-src 'none'") - res.setHeader('X-Content-Type-Options', 'nosniff') - res.end(doc[0]) -} - -/** - * 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 () { - const req = this.req - const res = this.res - - // if-match - const match = req.headers['if-match'] - if (match) { - const etag = res.getHeader('ETag') - return !etag || (match !== '*' && parseTokenList(match).every(function (match) { - return match !== etag && match !== 'W/' + etag && 'W/' + match !== etag - })) - } - - // if-unmodified-since - const unmodifiedSince = parseHttpDate(req.headers['if-unmodified-since']) - if (!isNaN(unmodifiedSince)) { - const 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 () { - const 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 () { - const 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 () { - const 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 () { - const statusCode = this.res.statusCode - return (statusCode >= 200 && statusCode < 300) || - statusCode === 304 -} - -/** - * Handle stat() error. - * - * @param {Error} error - * @private - */ - -SendStream.prototype.onStatError = function onStatError (error) { - // POSIX throws ENAMETOOLONG and ENOTDIR, Windows only ENOENT - /* istanbul ignore next */ - switch (error.code) { - case 'ENAMETOOLONG': - case 'ENOTDIR': - case 'ENOENT': - 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 () { - const ifRange = this.req.headers['if-range'] - - if (!ifRange) { - return true - } - - // if-range as etag - if (ifRange.indexOf('"') !== -1) { - const etag = this.res.getHeader('ETag') - return Boolean(etag && ifRange.indexOf(etag) !== -1) - } - - // if-range as modified date - const lastModified = this.res.getHeader('Last-Modified') - return parseHttpDate(lastModified) <= parseHttpDate(ifRange) -} - -/** - * Redirect to path. - * - * @param {string} path - * @private - */ - -SendStream.prototype.redirect = function redirect (path) { - const res = this.res - - if (this.listenerCount('directory') > 0) { - this.emit('directory', res, path) - return - } - - if (this.hasTrailingSlash()) { - this.error(403) - return - } - - const loc = encodeURI(collapseLeadingSlashes(this.path + '/')) - const doc = createHtmlDocument('Redirecting', 'Redirecting to ' + - escapeHtml(loc) + '') - - // redirect - res.statusCode = 301 - res.setHeader('Content-Type', 'text/html; charset=UTF-8') - res.setHeader('Content-Length', doc[1]) - res.setHeader('Content-Security-Policy', "default-src 'none'") - res.setHeader('X-Content-Type-Options', 'nosniff') - res.setHeader('Location', loc) - res.end(doc[0]) -} - -/** - * Pipe to `res. - * - * @param {Stream} res - * @return {Stream} res - * @api public - */ - -SendStream.prototype.pipe = function pipe (res) { - // root path - const root = this._root - - // references - this.res = res - - // decode the path - let path = decode(this.path) - if (path === null) { - this.error(400) - return res - } - - // null byte(s) - if (~path.indexOf('\0')) { - this.error(400) - return res - } - - 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) - 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) { - let len = stat.size - const options = this.options - const opts = {} - const res = this.res - const req = this.req - let ranges = req.headers.range - let 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) { - const bytes = options.end - offset + 1 - if (len > bytes) len = bytes - } - - // Range support - if (this._acceptRanges && ranges !== undefined && 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 (const 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) { - let i = 0 - const self = this - - debug('stat "%s"', path) - fs.stat(path, function onstat (err, stat) { - if (err && err.code === 'ENOENT' && !extname(path) && path[path.length - 1] !== sep) { - // not found, check extensions - return next(err) - } - if (err) return self.onStatError(err) - if (stat.isDirectory()) return self.redirect(path) - 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) - } - - const 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) { - let i = -1 - const self = this - - function next (err) { - if (++i >= self._index.length) { - if (err) return self.onStatError(err) - return self.error(404) - } - - const 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) { - const self = this - const res = this.res - - // pipe - const 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') - }) -} - -const utf8MimeTypeRE = /^text\/|^application\/(javascript|json)/ -const isUtf8MimeType = utf8MimeTypeRE.test.bind(utf8MimeTypeRE) - -/** - * Set content-type based on `path` - * if it hasn't been explicitly set. - * - * @param {String} path - * @api private - */ - -SendStream.prototype.type = function type (path) { - const res = this.res - - if (res.getHeader('Content-Type')) return - - const type = mime.getType(path) || mime.default_type - - if (!type) { - debug('no content-type') - return - } - - debug('content-type %s', type) - if (isUtf8MimeType(type)) { - res.setHeader('Content-Type', type + '; charset=UTF-8') - } else { - 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) { - const 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')) { - let 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')) { - const modified = stat.mtime.toUTCString() - debug('modified %s', modified) - res.setHeader('Last-Modified', modified) - } - - if (this._etag && !res.getHeader('ETag')) { - const etag = 'W/"' + stat.size.toString(16) + '-' + stat.mtime.getTime().toString(16) + '"' - debug('etag %s', etag) - res.setHeader('ETag', etag) - } -} - -/** - * Clear all headers from a response. - * - * @param {object} res - * @private - */ - -function clearHeaders (res) { - const headers = res.getHeaderNames() - - for (let i = 0; i < headers.length; i++) { - res.removeHeader(headers[i]) - } -} - -/** - * Collapse all leading slashes into a single slash - * - * @param {string} str - * @private - */ -function collapseLeadingSlashes (str) { - let i = 0 - for (i; 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 (let i = 0; i < parts.length; i++) { - const 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) { - const html = '\n' + - '\n' + - '\n' + - '\n' + - '' + title + '\n' + - '\n' + - '\n' + - '
' + body + '
\n' + - '\n' + - '\n' - - return [html, Buffer.byteLength(html)] -} - -/** - * 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) -} - -/** - * Normalize the index option into an array. - * - * @param {boolean|string|array} val - * @param {string} name - * @private - */ - -function normalizeList (val, name) { - const list = [].concat(val || []) - - for (let 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) { - const timestamp = date && Date.parse(date) - - return typeof timestamp === 'number' - ? timestamp - : NaN -} - -/** - * Parse a HTTP token list. - * - * @param {string} str - * @private - */ - -function parseTokenList (str) { - let end = 0 - const list = [] - let start = 0 - - // 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) { - 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) { - const keys = Object.keys(headers) - - for (let i = 0; i < keys.length; i++) { - const key = keys[i] - res.setHeader(key, headers[key]) - } -} - /** * Module exports. * @public @@ -999,6 +37,7 @@ function setHeaders (res, headers) { module.exports = send module.exports.default = send module.exports.send = send +module.exports.SendStream = SendStream module.exports.isUtf8MimeType = isUtf8MimeType module.exports.mime = mime diff --git a/lib/SendStream.js b/lib/SendStream.js new file mode 100644 index 0000000..69cf46b --- /dev/null +++ b/lib/SendStream.js @@ -0,0 +1,796 @@ +/*! + * send + * Copyright(c) 2012 TJ Holowaychuk + * Copyright(c) 2014-2022 Douglas Christopher Wilson + * MIT Licensed + */ + +'use strict' + +const fs = require('node:fs') +const path = require('node:path') +const Stream = require('node:stream') +const util = require('node:util') +const debug = require('node:util').debuglog('send') + +const decode = require('fast-decode-uri-component') +const escapeHtml = require('escape-html') +const fresh = require('fresh') +const mime = require('mime') +const ms = require('ms') +const onFinished = require('on-finished') +const parseRange = require('range-parser') + +const { clearHeaders } = require('./clearHeaders') +const { collapseLeadingSlashes } = require('./collapseLeadingSlashes') +const { containsDotFile } = require('./containsDotFile') +const { contentRange } = require('./contentRange') +const { createHtmlDocument } = require('./createHtmlDocument') +const { createHttpError } = require('./createHttpError') +const { isUtf8MimeType } = require('./isUtf8MimeType') +const { normalizeList } = require('./normalizeList') +const { parseHttpDate } = require('./parseHttpDate') +const { parseTokenList } = require('./parseTokenList') +const { setHeaders } = require('./setHeaders') + +/** + * Path function references. + * @private + */ + +const extname = path.extname +const join = path.join +const normalize = path.normalize +const resolve = path.resolve +const sep = path.sep + +/** + * 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') +} + +/** + * Initialize a `SendStream` with the given `path`. + * + * @param {Request} req + * @param {String} path + * @param {object} [options] + * @private + */ + +function SendStream (req, path, options) { + if (!new.target) { + return new SendStream(req, path, options) + } + Stream.call(this) + + const 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) + +/** + * Set root `path`. + * + * @param {String} path + * @return {SendStream} + * @api private + */ + +SendStream.prototype.root = function root (path) { + this._root = resolve(String(path)) + debug('root %s', this._root) + return this +} + +/** + * Emit error with `status`. + * + * @memberof SendStream + * @param {number} status + * @param {Error} [err] + * @this {Stream} + * @private + */ + +SendStream.prototype.error = function error (status, err) { + // emit if listeners instead of responding + if (this.listenerCount('error') > 0) { + return this.emit('error', createHttpError(status, err)) + } + + const res = this.res + + // clear existing headers + clearHeaders(res) + + // add error headers + if (err && err.headers) { + setHeaders(res, err.headers) + } + + const doc = ERROR_RESPONSES[status] + + // send basic response + res.statusCode = status + res.setHeader('Content-Type', 'text/html; charset=UTF-8') + res.setHeader('Content-Length', doc[1]) + res.setHeader('Content-Security-Policy', "default-src 'none'") + res.setHeader('X-Content-Type-Options', 'nosniff') + res.end(doc[0]) +} + +/** + * 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 () { + const req = this.req + const res = this.res + + // if-match + const match = req.headers['if-match'] + if (match) { + const etag = res.getHeader('ETag') + return !etag || (match !== '*' && parseTokenList(match).every(function (match) { + return match !== etag && match !== 'W/' + etag && 'W/' + match !== etag + })) + } + + // if-unmodified-since + const unmodifiedSince = parseHttpDate(req.headers['if-unmodified-since']) + if (!isNaN(unmodifiedSince)) { + const 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 () { + const 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 () { + const 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 () { + const 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 () { + const statusCode = this.res.statusCode + return (statusCode >= 200 && statusCode < 300) || + statusCode === 304 +} + +/** + * Handle stat() error. + * + * @param {Error} error + * @private + */ + +SendStream.prototype.onStatError = function onStatError (error) { + // POSIX throws ENAMETOOLONG and ENOTDIR, Windows only ENOENT + /* istanbul ignore next */ + switch (error.code) { + case 'ENAMETOOLONG': + case 'ENOTDIR': + case 'ENOENT': + 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 () { + const ifRange = this.req.headers['if-range'] + + if (!ifRange) { + return true + } + + // if-range as etag + if (ifRange.indexOf('"') !== -1) { + const etag = this.res.getHeader('ETag') + return Boolean(etag && ifRange.indexOf(etag) !== -1) + } + + // if-range as modified date + const lastModified = this.res.getHeader('Last-Modified') + return parseHttpDate(lastModified) <= parseHttpDate(ifRange) +} + +/** + * Redirect to path. + * + * @param {string} path + * @private + */ + +SendStream.prototype.redirect = function redirect (path) { + const res = this.res + + if (this.listenerCount('directory') > 0) { + this.emit('directory', res, path) + return + } + + if (this.hasTrailingSlash()) { + this.error(403) + return + } + + const loc = encodeURI(collapseLeadingSlashes(this.path + '/')) + const doc = createHtmlDocument('Redirecting', 'Redirecting to ' + + escapeHtml(loc) + '') + + // redirect + res.statusCode = 301 + res.setHeader('Content-Type', 'text/html; charset=UTF-8') + res.setHeader('Content-Length', doc[1]) + res.setHeader('Content-Security-Policy', "default-src 'none'") + res.setHeader('X-Content-Type-Options', 'nosniff') + res.setHeader('Location', loc) + res.end(doc[0]) +} + +/** + * Pipe to `res. + * + * @param {Stream} res + * @return {Stream} res + * @api public + */ + +SendStream.prototype.pipe = function pipe (res) { + // root path + const root = this._root + + // references + this.res = res + + // decode the path + let path = decode(this.path) + if (path === null) { + this.error(400) + return res + } + + // null byte(s) + if (~path.indexOf('\0')) { + this.error(400) + return res + } + + 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) + 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) { + let len = stat.size + const options = this.options + const opts = {} + const res = this.res + const req = this.req + let ranges = req.headers.range + let 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) { + const bytes = options.end - offset + 1 + if (len > bytes) len = bytes + } + + // Range support + if (this._acceptRanges && ranges !== undefined && 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 (const 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) { + let i = 0 + const self = this + + debug('stat "%s"', path) + fs.stat(path, function onstat (err, stat) { + if (err && err.code === 'ENOENT' && !extname(path) && path[path.length - 1] !== sep) { + // not found, check extensions + return next(err) + } + if (err) return self.onStatError(err) + if (stat.isDirectory()) return self.redirect(path) + 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) + } + + const 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) { + let i = -1 + const self = this + + function next (err) { + if (++i >= self._index.length) { + if (err) return self.onStatError(err) + return self.error(404) + } + + const 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) { + const self = this + const res = this.res + + // pipe + const 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) { + const res = this.res + + if (res.getHeader('Content-Type')) return + + const type = mime.getType(path) || mime.default_type + + if (!type) { + debug('no content-type') + return + } + + debug('content-type %s', type) + if (isUtf8MimeType(type)) { + res.setHeader('Content-Type', type + '; charset=UTF-8') + } else { + 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) { + const 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')) { + let 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')) { + const modified = stat.mtime.toUTCString() + debug('modified %s', modified) + res.setHeader('Last-Modified', modified) + } + + if (this._etag && !res.getHeader('ETag')) { + const etag = 'W/"' + stat.size.toString(16) + '-' + stat.mtime.getTime().toString(16) + '"' + debug('etag %s', etag) + res.setHeader('ETag', etag) + } +} + +/** + * Module exports. + * @public + */ + +module.exports = SendStream diff --git a/lib/clearHeaders.js b/lib/clearHeaders.js new file mode 100644 index 0000000..6d9ac92 --- /dev/null +++ b/lib/clearHeaders.js @@ -0,0 +1,21 @@ +/*! + * send + * Copyright(c) 2012 TJ Holowaychuk + * Copyright(c) 2014-2022 Douglas Christopher Wilson + * MIT Licensed + */ +'use strict' +/** + * Clear all headers from a response. + * + * @param {object} res + * @private + */ +function clearHeaders (res) { + const headers = res.getHeaderNames() + + for (let i = 0; i < headers.length; i++) { + res.removeHeader(headers[i]) + } +} +exports.clearHeaders = clearHeaders diff --git a/lib/collapseLeadingSlashes.js b/lib/collapseLeadingSlashes.js new file mode 100644 index 0000000..d0fd796 --- /dev/null +++ b/lib/collapseLeadingSlashes.js @@ -0,0 +1,23 @@ +'use strict' + +/** + * Collapse all leading slashes into a single slash + * + * @param {string} str + * @private + */ + +function collapseLeadingSlashes (str) { + let i = 0 + for (i; i < str.length; i++) { + if (str[i] !== '/') { + break + } + } + + return i > 1 + ? '/' + str.substr(i) + : str +} + +module.exports.collapseLeadingSlashes = collapseLeadingSlashes diff --git a/lib/containsDotFile.js b/lib/containsDotFile.js new file mode 100644 index 0000000..f19d5f4 --- /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; i < parts.length; i++) { + const part = parts[i] + if (part.length > 1 && part[0] === '.') { + return true + } + } + + return false +} +exports.containsDotFile = containsDotFile diff --git a/lib/contentRange.js b/lib/contentRange.js new file mode 100644 index 0000000..a2183ed --- /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 0000000..d4d64d2 --- /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' + + '' + title + '\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 0000000..ba7bcca --- /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 0000000..93a7c5b --- /dev/null +++ b/lib/isUtf8MimeType.js @@ -0,0 +1,6 @@ +'use strict' + +const utf8MimeTypeRE = /^text\/|^application\/(javascript|json)/ +const isUtf8MimeType = utf8MimeTypeRE.test.bind(utf8MimeTypeRE) + +module.exports.isUtf8MimeType = isUtf8MimeType diff --git a/lib/normalizeList.js b/lib/normalizeList.js new file mode 100644 index 0000000..8e49ac1 --- /dev/null +++ b/lib/normalizeList.js @@ -0,0 +1,23 @@ +'use strict' + +/** + * Normalize the index option into an array. + * + * @param {boolean|string|array} val + * @param {string} name + * @private + */ + +function normalizeList (val, name) { + const list = [].concat(val || []) + + for (let i = 0; i < list.length; i++) { + if (typeof list[i] !== 'string') { + throw new TypeError(name + ' must be array of strings or false') + } + } + + return list +} + +module.exports.normalizeList = normalizeList diff --git a/lib/parseHttpDate.js b/lib/parseHttpDate.js new file mode 100644 index 0000000..d1e6789 --- /dev/null +++ b/lib/parseHttpDate.js @@ -0,0 +1,18 @@ +'use strict' + +/** + * Parse an HTTP Date into a number. + * + * @param {string} date + * @private + */ + +function parseHttpDate (date) { + const timestamp = date && Date.parse(date) + + return typeof timestamp === 'number' + ? timestamp + : NaN +} + +module.exports.parseHttpDate = parseHttpDate diff --git a/lib/parseTokenList.js b/lib/parseTokenList.js new file mode 100644 index 0000000..ad8c569 --- /dev/null +++ b/lib/parseTokenList.js @@ -0,0 +1,43 @@ +'use strict' + +/** + * Parse a HTTP token list. + * + * @param {string} str + * @private + */ + +function parseTokenList (str) { + let end = 0 + const list = [] + let start = 0 + + // 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) { + 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 +} + +module.exports.parseTokenList = parseTokenList diff --git a/lib/setHeaders.js b/lib/setHeaders.js new file mode 100644 index 0000000..4ddd6dc --- /dev/null +++ b/lib/setHeaders.js @@ -0,0 +1,20 @@ +'use strict' + +/** + * Set an object of headers on a response. + * + * @param {object} res + * @param {object} headers + * @private + */ + +function setHeaders (res, headers) { + const keys = Object.keys(headers) + + for (let i = 0; i < keys.length; i++) { + const key = keys[i] + res.setHeader(key, headers[key]) + } +} + +module.exports.setHeaders = setHeaders diff --git a/test/send-pipe.test.js b/test/SendStream-pipe.test.js similarity index 91% rename from test/send-pipe.test.js rename to test/SendStream-pipe.test.js index 41f9f20..f2d4f58 100644 --- a/test/send-pipe.test.js +++ b/test/SendStream-pipe.test.js @@ -5,7 +5,7 @@ const after = require('after') const http = require('http') const path = require('path') const request = require('supertest') -const send = require('..') +const SendStream = require('../lib/SendStream') const os = require('os') const { shouldNotHaveBody, createServer, shouldNotHaveHeader } = require('./utils') @@ -24,7 +24,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -44,7 +44,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -64,7 +64,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -83,7 +83,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -102,7 +102,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -121,7 +121,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -140,7 +140,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -156,7 +156,7 @@ test('send(file).pipe(res)', function (t) { const app = http.createServer(function (req, res) { res.write('0') - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', function (err) { res.end(' - ' + err.message) }) .pipe(res) }) @@ -174,7 +174,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -196,7 +196,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -216,7 +216,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -235,7 +235,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -254,7 +254,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -273,7 +273,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -287,7 +287,7 @@ test('send(file).pipe(res)', function (t) { t.plan(1) const app = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', function (err) { res.end(err.statusCode + ' ' + err.code) }) .pipe(res) }) @@ -301,7 +301,7 @@ test('send(file).pipe(res)', function (t) { t.plan(1) const app = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', function (err) { res.end(err.statusCode + ' ' + err.code) }) .pipe(res) }) @@ -317,7 +317,7 @@ test('send(file).pipe(res)', function (t) { t.plan(1) const app = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', function (err) { res.end(err.statusCode + ' ' + err.code) }) .pipe(res) }) @@ -332,7 +332,7 @@ test('send(file).pipe(res)', function (t) { const app = http.createServer(function (req, res) { res.setHeader('Content-Type', 'application/x-custom') - send(req, req.url, { root: fixtures }).pipe(res) + new SendStream(req, req.url, { root: fixtures }).pipe(res) }) request(app) .get('/name.txt') @@ -348,7 +348,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -369,7 +369,7 @@ test('send(file).pipe(res)', function (t) { t.plan(1) const app = http.createServer(function (req, res) { - send(req, req.url, { root: 'test/fixtures' }) + new SendStream(req, req.url, { root: 'test/fixtures' }) .on('file', function () { // simulate file ENOENT after on open, after stat const fn = this.send @@ -389,7 +389,7 @@ test('send(file).pipe(res)', function (t) { t.plan(1) const app = http.createServer(function (req, res) { - send(req, req.url, { root: 'test/fixtures' }) + new SendStream(req, req.url, { root: 'test/fixtures' }) .on('stream', function (stream) { // simulate file error stream.on('open', function () { @@ -410,7 +410,7 @@ test('send(file).pipe(res)', function (t) { t.plan(1) const cb = after(2, err => t.error(err)) const server = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('headers', function () { cb() }) .pipe(res) }) @@ -424,7 +424,7 @@ test('send(file).pipe(res)', function (t) { t.plan(1) const cb = after(1, err => t.error(err)) const server = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('headers', function () { cb() }) .pipe(res) }) @@ -438,7 +438,7 @@ test('send(file).pipe(res)', function (t) { t.plan(1) const cb = after(2, err => t.error(err)) const server = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('headers', function () { cb() }) .pipe(res) }) @@ -452,7 +452,7 @@ test('send(file).pipe(res)', function (t) { t.plan(1) const cb = after(1, err => t.error(err)) const server = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('headers', function () { cb() }) .pipe(res) }) @@ -466,7 +466,7 @@ test('send(file).pipe(res)', function (t) { t.plan(3) const cb = after(2, err => t.error(err)) const server = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('headers', onHeaders) .pipe(res) }) @@ -486,7 +486,7 @@ test('send(file).pipe(res)', function (t) { t.plan(4) const cb = after(2, err => t.error(err)) const server = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('headers', onHeaders) .pipe(res) }) @@ -506,7 +506,7 @@ test('send(file).pipe(res)', function (t) { t.test('should allow altering headers', function (t) { t.plan(1) const server = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('headers', onHeaders) .pipe(res) }) @@ -536,7 +536,7 @@ test('send(file).pipe(res)', function (t) { t.test('should be called when sending directory', function (t) { t.plan(1) const server = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('directory', onDirectory) .pipe(res) }) @@ -554,7 +554,7 @@ test('send(file).pipe(res)', function (t) { t.test('should be called with path', function (t) { t.plan(1) const server = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('directory', onDirectory) .pipe(res) }) @@ -614,7 +614,7 @@ test('send(file).pipe(res)', function (t) { t.plan(1) const app = http.createServer(function (req, res) { - send(req, req.url.replace('/snow', '/snow ☃'), { root: 'test/fixtures' }) + new SendStream(req, req.url.replace('/snow', '/snow ☃'), { root: 'test/fixtures' }) .pipe(res) }) @@ -749,7 +749,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -769,7 +769,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -789,7 +789,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -818,7 +818,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -843,7 +843,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -874,7 +874,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -899,7 +899,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -928,7 +928,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -953,7 +953,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -980,7 +980,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -1005,7 +1005,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -1025,7 +1025,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -1045,7 +1045,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -1065,7 +1065,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -1086,7 +1086,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -1106,7 +1106,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -1126,7 +1126,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -1146,7 +1146,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -1170,7 +1170,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -1191,7 +1191,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -1216,7 +1216,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -1232,7 +1232,7 @@ test('send(file).pipe(res)', function (t) { t.plan(1) const server = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', function (err) { res.setHeader('X-Content-Range', err.headers['Content-Range']) res.statusCode = err.statusCode @@ -1261,7 +1261,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -1285,7 +1285,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -1306,7 +1306,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -1331,7 +1331,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -1359,7 +1359,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -1387,7 +1387,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -1415,7 +1415,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -1443,7 +1443,7 @@ test('send(file).pipe(res)', function (t) { res.end(http.STATUS_CODES[err.status]) } - send(req, req.url, { root: fixtures }) + new SendStream(req, req.url, { root: fixtures }) .on('error', error) .pipe(res) }) @@ -1503,7 +1503,7 @@ test('send(file).pipe(res)', function (t) { t.plan(1) const app = http.createServer(function (req, res) { - send(req, req.url) + new SendStream(req, req.url) .root(fixtures) .pipe(res) }) diff --git a/test/send.test.js b/test/SendStream.test.js similarity index 93% rename from test/send.test.js rename to test/SendStream.test.js index dc2bf54..b86dc51 100644 --- a/test/send.test.js +++ b/test/SendStream.test.js @@ -5,14 +5,14 @@ const fs = require('fs') const http = require('http') const path = require('path') const request = require('supertest') -const send = require('..') +const SendStream = require('..').SendStream const { shouldNotHaveHeader, createServer } = require('./utils') // test server const fixtures = path.join(__dirname, 'fixtures') -test('send(file, options)', function (t) { +test('SendStream(file, options)', function (t) { t.plan(10) t.test('acceptRanges', function (t) { @@ -184,14 +184,14 @@ test('send(file, options)', function (t) { t.test('when "allow"', function (t) { t.plan(3) - t.test('should send dotfile', function (t) { + t.test('should SendStream dotfile', function (t) { t.plan(1) request(createServer({ dotfiles: 'allow', root: fixtures })) .get('/.hidden.txt') .expect(200, 'secret', err => t.error(err)) }) - t.test('should send within dotfile directory', function (t) { + t.test('should SendStream within dotfile directory', function (t) { t.plan(1) request(createServer({ dotfiles: 'allow', root: fixtures })) .get('/.mine/name.txt') @@ -265,7 +265,7 @@ test('send(file, options)', function (t) { .expect(403, err => t.error(err)) }) - t.test('should send files in root dotfile directory', function (t) { + t.test('should SendStream files in root dotfile directory', function (t) { t.plan(1) request(createServer({ dotfiles: 'deny', root: path.join(fixtures, '.mine') })) .get('/name.txt') @@ -275,7 +275,7 @@ test('send(file, options)', function (t) { t.test('should 403 for dotfile without root', function (t) { t.plan(1) const server = http.createServer(function onRequest (req, res) { - send(req, fixtures + '/.mine' + req.url, { dotfiles: 'deny' }).pipe(res) + new SendStream(req, fixtures + '/.mine' + req.url, { dotfiles: 'deny' }).pipe(res) }) request(server) @@ -335,7 +335,7 @@ test('send(file, options)', function (t) { .expect(404, err => t.error(err)) }) - t.test('should send files in root dotfile directory', function (t) { + t.test('should SendStream files in root dotfile directory', function (t) { t.plan(1) request(createServer({ dotfiles: 'ignore', root: path.join(fixtures, '.mine') })) @@ -347,7 +347,7 @@ test('send(file, options)', function (t) { t.plan(1) const server = http.createServer(function onRequest (req, res) { - send(req, fixtures + '/.mine' + req.url, { dotfiles: 'ignore' }).pipe(res) + new SendStream(req, fixtures + '/.mine' + req.url, { dotfiles: 'ignore' }).pipe(res) }) request(server) @@ -489,7 +489,7 @@ test('send(file, options)', function (t) { const server = http.createServer(function (req, res) { const p = path.join(fixtures, 'pets').replace(/\\/g, '/') + '/' - send(req, p, { index: ['index.html'] }) + new SendStream(req, p, { index: ['index.html'] }) .pipe(res) }) @@ -516,7 +516,7 @@ test('send(file, options)', function (t) { t.plan(1) const app = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures + '/' }) + new SendStream(req, req.url, { root: fixtures + '/' }) .pipe(res) }) @@ -529,7 +529,7 @@ test('send(file, options)', function (t) { t.plan(1) const app = http.createServer(function (req, res) { - send(req, '', { root: fixtures }) + new SendStream(req, '', { root: fixtures }) .pipe(res) }) @@ -548,7 +548,7 @@ test('send(file, options)', function (t) { t.plan(1) const app = http.createServer(function (req, res) { - send(req, '', { root: path.join(fixtures, 'name.txt') }) + new SendStream(req, '', { root: path.join(fixtures, 'name.txt') }) .pipe(res) }) @@ -561,7 +561,7 @@ test('send(file, options)', function (t) { t.plan(1) request(createServer({ root: fixtures })) - .get('/pets/../../send.js') + .get('/pets/../../SendStream.js') .expect(403, err => t.error(err)) }) @@ -569,12 +569,12 @@ test('send(file, options)', function (t) { t.plan(1) const app = http.createServer(function (req, res) { - send(req, req.url, { root: fixtures + '/../fixtures' }) + new SendStream(req, req.url, { root: fixtures + '/../fixtures' }) .pipe(res) }) request(app) - .get('/pets/../../send.js') + .get('/pets/../../SendStream.js') .expect(403, err => t.error(err)) }) @@ -602,12 +602,12 @@ test('send(file, options)', function (t) { t.plan(1) const app = http.createServer(function (req, res) { - send(req, fixtures + req.url) + new SendStream(req, fixtures + req.url) .pipe(res) }) request(app) - .get('/../send.js') + .get('/../SendStream.js') .expect(403, err => t.error(err)) }) @@ -615,7 +615,7 @@ test('send(file, options)', function (t) { t.plan(1) const app = http.createServer(function (req, res) { - send(req, fixtures + req.url) + new SendStream(req, fixtures + req.url) .pipe(res) }) diff --git a/test/constructor.test.js b/test/constructor.test.js new file mode 100644 index 0000000..fa7c5e2 --- /dev/null +++ b/test/constructor.test.js @@ -0,0 +1,13 @@ +'use strict' + +const { test } = require('tap') +const SendStream = require('../index').SendStream + +test('constructor', function (t) { + t.plan(1) + + t.test('SendStream without new returns SendStream instance', function (t) { + t.plan(1) + t.ok(SendStream({}, '/', {}) instanceof SendStream) + }) +}) diff --git a/types/index.d.ts b/types/index.d.ts index 4f6b670..5f35c59 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -112,7 +112,10 @@ declare namespace send { start?: number | undefined; } - export interface SendStream extends stream.Stream { + export class SendStream extends stream.Stream { + + constructor(req: stream.Readable, path: string, options?: SendOptions); + /** * Emit error with `status`. */ diff --git a/types/index.test-d.ts b/types/index.test-d.ts index ad87230..28698c5 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -1,5 +1,6 @@ import { expectType } from 'tsd' import send from '..' +import { SendStream } from '..'; send.mime.define({ 'application/x-my-type': ['x-mt', 'x-mtt'] @@ -17,7 +18,7 @@ send(req, '/test.html', { root: __dirname + '/wwwroot' }).pipe(res); -send(req, '/test.html', { maxAge: 0, root: __dirname + '/wwwroot'}) +send(req, '/test.html', { maxAge: 0, root: __dirname + '/wwwroot' }) .on('error', (err: any) => { res.statusCode = err.status || 500; res.end(err.message); @@ -30,4 +31,6 @@ send(req, '/test.html', { maxAge: 0, root: __dirname + '/wwwroot'}) .on('headers', (res: any, path: string, stat: any) => { res.setHeader('Content-Disposition', 'attachment'); }) - .pipe(res); \ No newline at end of file + .pipe(res); + +const test = new SendStream(req, '/test.html', { maxAge: 0, root: __dirname + '/wwwroot' }); From d70d7ca8a32eae764f9722f8a4d95cac151579fc Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Mon, 16 Jan 2023 11:46:29 +0000 Subject: [PATCH 026/109] chore(license): use github mit license template; add fastify team (#28) --- LICENSE | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/LICENSE b/LICENSE index b6ea1c1..74e9601 100644 --- a/LICENSE +++ b/LICENSE @@ -1,23 +1,23 @@ -(The MIT License) +MIT License Copyright (c) 2012 TJ Holowaychuk Copyright (c) 2014-2022 Douglas Christopher Wilson +Copyright (c) 2023 The Fastify Team -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. From 123e7e5b4b8bf470a57b99ebfd416d06d2b425e1 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Mon, 16 Jan 2023 14:29:50 +0000 Subject: [PATCH 027/109] docs(readme): grammar and clarity fixes (#29) --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f7b7293..87106e4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![NPM version](https://img.shields.io/npm/v/@fastify/send.svg?style=flat)](https://www.npmjs.com/package/@fastify/send) [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](https://standardjs.com/) -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 @@ -49,9 +49,9 @@ true. Disabling this will ignore the `immutable` and `maxAge` options. 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 when set +checked (i.e. the root itself can be within a dotfile when set to "deny"). - `'allow'` No special treatment for dotfiles. @@ -100,7 +100,7 @@ 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. @@ -131,7 +131,7 @@ object, typically `send(req, path, options).pipe(res)`. ### .mime -The `mime` export is the global instance of of the +The `mime` export is the global instance of the [`mime` npm module](https://www.npmjs.com/package/mime). This is used to configure the MIME types that are associated with file extensions @@ -228,7 +228,7 @@ server.listen(3000) ### Custom directory index view -This is a example of serving up a structure of directories with a +This is an example of serving up a structure of directories with a custom function to render a listing of a directory. ```js From ff81e962a28781273efbd87c1e823623dc330c6d Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Tue, 17 Jan 2023 11:54:23 +0100 Subject: [PATCH 028/109] integrate range-parser (#30) * integrate rangeg-parser * named export parseRange --- benchmarks/parseRange.js | 12 ++++ lib/SendStream.js | 10 ++- lib/parseRange.js | 135 +++++++++++++++++++++++++++++++++++++++ package.json | 4 +- test/parseRange.test.js | 114 +++++++++++++++++++++++++++++++++ 5 files changed, 267 insertions(+), 8 deletions(-) create mode 100644 benchmarks/parseRange.js create mode 100644 lib/parseRange.js create mode 100644 test/parseRange.test.js diff --git a/benchmarks/parseRange.js b/benchmarks/parseRange.js new file mode 100644 index 0000000..248b39a --- /dev/null +++ b/benchmarks/parseRange.js @@ -0,0 +1,12 @@ +'use strict' + +const benchmark = require('benchmark') +const parseRange = require('../lib/parseRange') + +const size = 150 +const range = 'bytes=0-4,90-99,5-75,100-199,101-102' + +new benchmark.Suite() + .add('parseRange', function () { parseRange(size, range) }, { minSamples: 100 }) + .on('cycle', function onCycle (event) { console.log(String(event.target)) }) + .run({ async: false }) diff --git a/lib/SendStream.js b/lib/SendStream.js index 69cf46b..7c55ad9 100644 --- a/lib/SendStream.js +++ b/lib/SendStream.js @@ -19,7 +19,6 @@ const fresh = require('fresh') const mime = require('mime') const ms = require('ms') const onFinished = require('on-finished') -const parseRange = require('range-parser') const { clearHeaders } = require('./clearHeaders') const { collapseLeadingSlashes } = require('./collapseLeadingSlashes') @@ -30,6 +29,7 @@ const { createHttpError } = require('./createHttpError') const { isUtf8MimeType } = require('./isUtf8MimeType') const { normalizeList } = require('./normalizeList') const { parseHttpDate } = require('./parseHttpDate') +const { parseRange } = require('./parseRange') const { parseTokenList } = require('./parseTokenList') const { setHeaders } = require('./setHeaders') @@ -547,15 +547,13 @@ SendStream.prototype.send = function send (path, stat) { // Range support if (this._acceptRanges && ranges !== undefined && BYTES_RANGE_REGEXP.test(ranges)) { - // parse - ranges = parseRange(len, ranges, { - combine: true - }) - // If-Range support if (!this.isRangeFresh()) { debug('range stale') ranges = -2 + } else { + // parse + ranges = parseRange(len, ranges) } // unsatisfiable diff --git a/lib/parseRange.js b/lib/parseRange.js new file mode 100644 index 0000000..995c4b2 --- /dev/null +++ b/lib/parseRange.js @@ -0,0 +1,135 @@ +'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 + */ + +module.exports.parseRange = function parseRange (size, str) { + const index = str.indexOf('=') + + // split the range string + const arr = str.slice(index + 1).split(',') + const ranges = [] + + // parse all ranges + for (let i = 0; i < arr.length; i++) { + const range = arr[i].split('-') + let start = parseInt(range[0], 10) + let end = parseInt(range[1], 10) + + // -nnn + if (isNaN(start)) { + start = size - end + end = size - 1 + // nnn- + } else if (isNaN(end)) { + end = size - 1 + } + + // limit last-byte-pos to current length + if (end > size - 1) { + end = size - 1 + } + + // invalid or unsatisfiable + if (isNaN(start) || isNaN(end) || start > end || start < 0) { + continue + } + + // add range + ranges.push({ + start, + end + }) + } + + if (ranges.length === 0) { + // unsatisifiable + return -1 + } + + const ordered = ranges.map(mapWithIndex).sort(sortByRangeStart) + + let j = 0 + const il = ordered.length + for (let i = 1; i < il; ++i) { + const range = ordered[i] + const current = ordered[j] + + if (range.start > current.end + 1) { + // next range + ordered[++j] = range + } else if (range.end > current.end) { + // extend range + current.end = range.end + current.index = Math.min(current.index, range.index) + } + } + + // trim ordered array + ordered.length = j + 1 + + // generate combined range + const combined = ordered.sort(sortByRangeIndex).map(mapWithoutIndex) + + // copy ranges type + combined.type = str.slice(0, index) + + return combined +} + +/** + * Map function to add index value to ranges. + * @private + */ + +function mapWithIndex (range, index) { + return { + start: range.start, + end: range.end, + index + } +} + +/** + * Map function to remove index value from ranges. + * @private + */ + +function mapWithoutIndex (range) { + return { + start: range.start, + end: range.end + } +} + +/** + * 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 +} diff --git a/package.json b/package.json index 321784a..8471aaa 100644 --- a/package.json +++ b/package.json @@ -18,14 +18,14 @@ "server" ], "dependencies": { + "benchmark": "^2.1.4", "escape-html": "~1.0.3", "fast-decode-uri-component": "^1.0.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "^3.0.0", "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1" + "on-finished": "2.4.1" }, "devDependencies": { "@fastify/pre-commit": "^2.0.2", diff --git a/test/parseRange.test.js b/test/parseRange.test.js new file mode 100644 index 0000000..1fd2dad --- /dev/null +++ b/test/parseRange.test.js @@ -0,0 +1,114 @@ +'use strict' + +const { test } = require('tap') +const { parseRange } = require('../lib/parseRange') + +test('parseRange', function (t) { + t.plan(13) + + t.test('should return -1 if all specified ranges are invalid', function (t) { + t.plan(3) + t.equal(parseRange(200, 'bytes=500-20'), -1) + t.equal(parseRange(200, 'bytes=500-999'), -1) + t.equal(parseRange(200, 'bytes=500-999,1000-1499'), -1) + }) + + t.test('should parse str', function (t) { + t.plan(3) + const range = parseRange(1000, 'bytes=0-499') + t.equal(range.type, 'bytes') + t.equal(range.length, 1) + t.strictSame(range[0], { start: 0, end: 499 }) + }) + + t.test('should cap end at size', function (t) { + t.plan(3) + const range = parseRange(200, 'bytes=0-499') + t.equal(range.type, 'bytes') + t.equal(range.length, 1) + t.strictSame(range[0], { start: 0, end: 199 }) + }) + + t.test('should parse str', function (t) { + t.plan(3) + const range = parseRange(1000, 'bytes=40-80') + t.equal(range.type, 'bytes') + t.equal(range.length, 1) + t.strictSame(range[0], { start: 40, end: 80 }) + }) + + t.test('should parse str asking for last n bytes', function (t) { + t.plan(3) + const range = parseRange(1000, 'bytes=-400') + t.equal(range.type, 'bytes') + t.equal(range.length, 1) + t.strictSame(range[0], { start: 600, end: 999 }) + }) + + t.test('should parse str with only start', function (t) { + t.plan(3) + const range = parseRange(1000, 'bytes=400-') + t.equal(range.type, 'bytes') + t.equal(range.length, 1) + t.strictSame(range[0], { start: 400, end: 999 }) + }) + + t.test('should parse "bytes=0-"', function (t) { + t.plan(3) + const range = parseRange(1000, 'bytes=0-') + t.equal(range.type, 'bytes') + t.equal(range.length, 1) + t.strictSame(range[0], { start: 0, end: 999 }) + }) + + t.test('should parse str with no bytes', function (t) { + t.plan(3) + const range = parseRange(1000, 'bytes=0-0') + t.equal(range.type, 'bytes') + t.equal(range.length, 1) + t.strictSame(range[0], { start: 0, end: 0 }) + }) + + t.test('should parse str asking for last byte', function (t) { + t.plan(3) + const range = parseRange(1000, 'bytes=-1') + t.equal(range.type, 'bytes') + t.equal(range.length, 1) + t.strictSame(range[0], { start: 999, end: 999 }) + }) + + t.test('should parse str with some invalid ranges', function (t) { + t.plan(3) + const range = parseRange(200, 'bytes=0-499,1000-,500-999') + t.equal(range.type, 'bytes') + t.equal(range.length, 1) + t.strictSame(range[0], { start: 0, end: 199 }) + }) + + t.test('should parse non-byte range', function (t) { + t.plan(3) + const range = parseRange(1000, 'items=0-5') + t.equal(range.type, 'items') + t.equal(range.length, 1) + t.strictSame(range[0], { start: 0, end: 5 }) + }) + + t.test('should combine overlapping ranges', function (t) { + t.plan(4) + const range = parseRange(150, 'bytes=0-4,90-99,5-75,100-199,101-102', { combine: true }) + t.equal(range.type, 'bytes') + t.equal(range.length, 2) + t.strictSame(range[0], { start: 0, end: 75 }) + t.strictSame(range[1], { start: 90, end: 149 }) + }) + + t.test('should retain original order', function (t) { + t.plan(5) + const range = parseRange(150, 'bytes=-1,20-100,0-1,101-120', { combine: true }) + t.equal(range.type, 'bytes') + t.equal(range.length, 3) + t.strictSame(range[0], { start: 149, end: 149 }) + t.strictSame(range[1], { start: 20, end: 120 }) + t.strictSame(range[2], { start: 0, end: 1 }) + }) +}) From 0c3307b349ea16772b3f188cea4be5785a499df3 Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Tue, 17 Jan 2023 21:27:39 +0100 Subject: [PATCH 029/109] fix benchmark (#32) --- benchmarks/parseRange.js | 2 +- lib/parseRange.js | 4 +++- package.json | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/benchmarks/parseRange.js b/benchmarks/parseRange.js index 248b39a..e8d928b 100644 --- a/benchmarks/parseRange.js +++ b/benchmarks/parseRange.js @@ -1,7 +1,7 @@ 'use strict' const benchmark = require('benchmark') -const parseRange = require('../lib/parseRange') +const { parseRange } = require('../lib/parseRange') const size = 150 const range = 'bytes=0-4,90-99,5-75,100-199,101-102' diff --git a/lib/parseRange.js b/lib/parseRange.js index 995c4b2..1a09c47 100644 --- a/lib/parseRange.js +++ b/lib/parseRange.js @@ -17,7 +17,7 @@ * @public */ -module.exports.parseRange = function parseRange (size, str) { +function parseRange (size, str) { const index = str.indexOf('=') // split the range string @@ -133,3 +133,5 @@ function sortByRangeIndex (a, b) { function sortByRangeStart (a, b) { return a.start - b.start } + +module.exports.parseRange = parseRange diff --git a/package.json b/package.json index 8471aaa..48928cb 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "server" ], "dependencies": { - "benchmark": "^2.1.4", "escape-html": "~1.0.3", "fast-decode-uri-component": "^1.0.1", "fresh": "0.5.2", @@ -31,6 +30,7 @@ "@fastify/pre-commit": "^2.0.2", "@types/node": "^18.11.18", "after": "0.8.2", + "benchmark": "^2.1.4", "snazzy": "^9.0.0", "standard": "^17.0.0", "supertest": "6.3.3", From f78aa0922af89661c165ab556b3855bcd662ba4d Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Tue, 17 Jan 2023 21:27:52 +0100 Subject: [PATCH 030/109] replace ms with @lukeed/ms (#33) --- lib/SendStream.js | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/SendStream.js b/lib/SendStream.js index 7c55ad9..537b1a9 100644 --- a/lib/SendStream.js +++ b/lib/SendStream.js @@ -17,7 +17,7 @@ const decode = require('fast-decode-uri-component') const escapeHtml = require('escape-html') const fresh = require('fresh') const mime = require('mime') -const ms = require('ms') +const ms = require('@lukeed/ms') const onFinished = require('on-finished') const { clearHeaders } = require('./clearHeaders') @@ -133,7 +133,7 @@ function SendStream (req, path, options) { this._maxage = opts.maxAge || opts.maxage this._maxage = typeof this._maxage === 'string' - ? ms(this._maxage) + ? ms.parse(this._maxage) : Number(this._maxage) this._maxage = !isNaN(this._maxage) ? Math.min(Math.max(0, this._maxage), MAX_MAXAGE) diff --git a/package.json b/package.json index 48928cb..ab61b06 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "^3.0.0", - "ms": "2.1.3", + "@lukeed/ms": "^2.0.1", "on-finished": "2.4.1" }, "devDependencies": { From 9da628f99ee9cebd61febf13902ded6f371ed339 Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Tue, 17 Jan 2023 22:05:48 +0100 Subject: [PATCH 031/109] remove on-finished (#34) --- lib/SendStream.js | 16 ++++++++++------ package.json | 3 +-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/SendStream.js b/lib/SendStream.js index 537b1a9..27f13a6 100644 --- a/lib/SendStream.js +++ b/lib/SendStream.js @@ -18,7 +18,6 @@ const escapeHtml = require('escape-html') const fresh = require('fresh') const mime = require('mime') const ms = require('@lukeed/ms') -const onFinished = require('on-finished') const { clearHeaders } = require('./clearHeaders') const { collapseLeadingSlashes } = require('./collapseLeadingSlashes') @@ -692,18 +691,23 @@ SendStream.prototype.stream = function stream (path, options) { this.emit('stream', stream) stream.pipe(res) - // cleanup - function cleanup () { + let destroyed = false + + // destroy piped stream + function destroy () { + if (destroyed) { + return + } + destroyed = true stream.destroy() } - // response finished, cleanup - onFinished(res, cleanup) + res.once('finish', destroy) // error handling stream.on('error', function onerror (err) { // clean up stream early - cleanup() + destroy() // error self.onStatError(err) diff --git a/package.json b/package.json index ab61b06..9c02d9e 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,7 @@ "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "^3.0.0", - "@lukeed/ms": "^2.0.1", - "on-finished": "2.4.1" + "@lukeed/ms": "^2.0.1" }, "devDependencies": { "@fastify/pre-commit": "^2.0.2", From 8795f25f2de645afd96cb7a5bb5f8f428e5bd249 Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Wed, 18 Jan 2023 12:27:23 +0100 Subject: [PATCH 032/109] integrate fresh (#31) * add unit test for freshness following integration * integrate fresh * remove else --- lib/SendStream.js | 143 ++++++++++++++++++++++++-------- lib/parseHttpDate.js | 18 ---- lib/parseTokenList.js | 15 ++-- package.json | 1 - test/SendStream-pipe.test.js | 155 ++++++++++++++++++++++++++++++++++- 5 files changed, 271 insertions(+), 61 deletions(-) delete mode 100644 lib/parseHttpDate.js diff --git a/lib/SendStream.js b/lib/SendStream.js index 27f13a6..b1c9320 100644 --- a/lib/SendStream.js +++ b/lib/SendStream.js @@ -15,7 +15,6 @@ const debug = require('node:util').debuglog('send') const decode = require('fast-decode-uri-component') const escapeHtml = require('escape-html') -const fresh = require('fresh') const mime = require('mime') const ms = require('@lukeed/ms') @@ -27,7 +26,6 @@ const { createHtmlDocument } = require('./createHtmlDocument') const { createHttpError } = require('./createHttpError') const { isUtf8MimeType } = require('./isUtf8MimeType') const { normalizeList } = require('./normalizeList') -const { parseHttpDate } = require('./parseHttpDate') const { parseRange } = require('./parseRange') const { parseTokenList } = require('./parseTokenList') const { setHeaders } = require('./setHeaders') @@ -225,6 +223,75 @@ SendStream.prototype.isConditionalGET = function isConditionalGET () { this.req.headers['if-modified-since'] } +SendStream.prototype.isNotModifiedFailure = function isNotModifiedFailure () { + const req = this.req + const res = this.res + + // 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 req.headers && + req.headers['cache-control'].indexOf('no-cache') !== -1 + ) { + return false + } + + // if-none-match + if ('if-none-match' in req.headers) { + const ifNoneMatch = req.headers['if-none-match'] + + if (ifNoneMatch === '*') { + return true + } + + const etag = res.getHeader('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 req.headers) { + const ifModifiedSince = req.headers['if-modified-since'] + const lastModified = res.getHeader('last-modified') + + if (!lastModified || (Date.parse(lastModified) <= Date.parse(ifModifiedSince))) { + return true + } + } + + return false +} + /** * Check if the request preconditions failed. * @@ -237,19 +304,39 @@ SendStream.prototype.isPreconditionFailure = function isPreconditionFailure () { const res = this.res // if-match - const match = req.headers['if-match'] - if (match) { + const ifMatch = req.headers['if-match'] + if (ifMatch) { const etag = res.getHeader('ETag') - return !etag || (match !== '*' && parseTokenList(match).every(function (match) { - return match !== etag && match !== 'W/' + etag && 'W/' + match !== 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 - const unmodifiedSince = parseHttpDate(req.headers['if-unmodified-since']) - if (!isNaN(unmodifiedSince)) { - const lastModified = parseHttpDate(res.getHeader('Last-Modified')) - return isNaN(lastModified) || lastModified > unmodifiedSince + if ('if-unmodified-since' in req.headers) { + const ifUnmodifiedSince = req.headers['if-unmodified-since'] + const unmodifiedSince = Date.parse(ifUnmodifiedSince) + if (!Number.isNaN(unmodifiedSince)) { + const lastModified = Date.parse(res.getHeader('Last-Modified')) + if ( + Number.isNaN(lastModified) || + lastModified > unmodifiedSince + ) { + return true + } + } } return false @@ -333,20 +420,6 @@ SendStream.prototype.onStatError = function onStatError (error) { } } -/** - * 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. * @@ -355,21 +428,27 @@ SendStream.prototype.isFresh = function isFresh () { */ SendStream.prototype.isRangeFresh = function isRangeFresh () { - const ifRange = this.req.headers['if-range'] - - if (!ifRange) { + if (!('if-range' in this.req.headers)) { return true } + const ifRange = this.req.headers['if-range'] + // if-range as etag if (ifRange.indexOf('"') !== -1) { const etag = this.res.getHeader('ETag') - return Boolean(etag && ifRange.indexOf(etag) !== -1) + return (etag && ifRange.indexOf(etag) !== -1) || false + } + + const ifRangeTimestamp = Date.parse(ifRange) + if (Number.isNaN(ifRangeTimestamp)) { + return false } // if-range as modified date - const lastModified = this.res.getHeader('Last-Modified') - return parseHttpDate(lastModified) <= parseHttpDate(ifRange) + const lastModified = Date.parse(this.res.getHeader('Last-Modified')) + + return Number.isNaN(lastModified) || lastModified <= ifRangeTimestamp } /** @@ -531,7 +610,7 @@ SendStream.prototype.send = function send (path, stat) { return } - if (this.isCachable() && this.isFresh()) { + if (this.isCachable() && this.isNotModifiedFailure()) { this.notModified() return } diff --git a/lib/parseHttpDate.js b/lib/parseHttpDate.js deleted file mode 100644 index d1e6789..0000000 --- a/lib/parseHttpDate.js +++ /dev/null @@ -1,18 +0,0 @@ -'use strict' - -/** - * Parse an HTTP Date into a number. - * - * @param {string} date - * @private - */ - -function parseHttpDate (date) { - const timestamp = date && Date.parse(date) - - return typeof timestamp === 'number' - ? timestamp - : NaN -} - -module.exports.parseHttpDate = parseHttpDate diff --git a/lib/parseTokenList.js b/lib/parseTokenList.js index ad8c569..eb3e436 100644 --- a/lib/parseTokenList.js +++ b/lib/parseTokenList.js @@ -7,10 +7,12 @@ * @private */ -function parseTokenList (str) { +const slice = String.prototype.slice + +function parseTokenList (str, cb) { let end = 0 - const list = [] let start = 0 + let result // gather tokens for (let i = 0, len = str.length; i < len; i++) { @@ -22,7 +24,10 @@ function parseTokenList (str) { break case 0x2c: /* , */ if (start !== end) { - list.push(str.substring(start, end)) + result = cb(slice.call(str, start, end)) + if (result !== undefined) { + return result + } } start = end = i + 1 break @@ -34,10 +39,8 @@ function parseTokenList (str) { // final token if (start !== end) { - list.push(str.substring(start, end)) + return cb(slice.call(str, start, end)) } - - return list } module.exports.parseTokenList = parseTokenList diff --git a/package.json b/package.json index 9c02d9e..eb93ceb 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "dependencies": { "escape-html": "~1.0.3", "fast-decode-uri-component": "^1.0.1", - "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "^3.0.0", "@lukeed/ms": "^2.0.1" diff --git a/test/SendStream-pipe.test.js b/test/SendStream-pipe.test.js index f2d4f58..6967ed0 100644 --- a/test/SendStream-pipe.test.js +++ b/test/SendStream-pipe.test.js @@ -738,7 +738,7 @@ test('send(file).pipe(res)', function (t) { }) t.test('where "If-Match" is set', function (t) { - t.plan(3) + t.plan(4) t.test('should respond with 200 when "*"', function (t) { t.plan(1) @@ -780,7 +780,7 @@ test('send(file).pipe(res)', function (t) { .expect(412, err => t.error(err)) }) - t.test('should respond with 200 when ETag matched', function (t) { + t.test('should respond with 200 when ETag matched /1', function (t) { t.plan(2) const app = http.createServer(function (req, res) { @@ -804,10 +804,35 @@ test('send(file).pipe(res)', function (t) { .expect(200, err => t.error(err)) }) }) + + t.test('should respond with 200 when ETag matched /2', function (t) { + t.plan(2) + + const app = http.createServer(function (req, res) { + function error (err) { + res.statusCode = err.status + res.end(http.STATUS_CODES[err.status]) + } + + new SendStream(req, req.url, { root: fixtures }) + .on('error', error) + .pipe(res) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-Match', '"foo", ' + res.headers.etag + ', "bar"') + .expect(200, err => t.error(err)) + }) + }) }) t.test('where "If-Modified-Since" is set', function (t) { - t.plan(2) + t.plan(3) t.test('should respond with 304 when unmodified', function (t) { t.plan(2) @@ -860,10 +885,36 @@ test('send(file).pipe(res)', function (t) { .expect(200, 'tobi', err => t.error(err)) }) }) + + t.test('should respond with 200 when modified', function (t) { + t.plan(2) + + const app = http.createServer(function (req, res) { + function error (err) { + res.statusCode = err.status + res.end(http.STATUS_CODES[err.status]) + } + + new SendStream(req, req.url, { root: fixtures }) + .on('error', error) + .pipe(res) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-Modified-Since', res.headers['last-modified']) + .set('cache-control', 'no-cache') + .expect(200, 'tobi', err => t.error(err)) + }) + }) }) t.test('where "If-None-Match" is set', function (t) { - t.plan(2) + t.plan(6) t.test('should respond with 304 when ETag matched', function (t) { t.plan(2) @@ -914,6 +965,102 @@ test('send(file).pipe(res)', function (t) { .expect(200, 'tobi', err => t.error(err)) }) }) + + t.test('should respond with 200 when ETag is not generated', function (t) { + t.plan(2) + + const app = http.createServer(function (req, res) { + function error (err) { + res.statusCode = err.status + res.end(http.STATUS_CODES[err.status]) + } + + new SendStream(req, req.url, { etag: false, root: fixtures }) + .on('error', error) + .pipe(res) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-None-Match', '"123"') + .expect(200, 'tobi', err => t.error(err)) + }) + }) + + t.test('should respond with 306 Not Modified when using wildcard * on existing file', function (t) { + t.plan(2) + + const app = http.createServer(function (req, res) { + function error (err) { + res.statusCode = err.status + res.end(http.STATUS_CODES[err.status]) + } + + new SendStream(req, req.url, { etag: false, root: fixtures }) + .on('error', error) + .pipe(res) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-None-Match', '*') + .expect(304, '', err => t.error(err)) + }) + }) + + t.test('should respond with 404 Not Found when using wildcard * on non-existing file', function (t) { + t.plan(1) + + const app = http.createServer(function (req, res) { + function error (err) { + res.statusCode = err.status + res.end(http.STATUS_CODES[err.status]) + } + + new SendStream(req, req.url, { etag: false, root: fixtures }) + .on('error', error) + .pipe(res) + }) + + request(app) + .get('/asdf.txt') + .set('If-None-Match', '*') + .expect(404, 'Not Found', err => t.error(err)) + }) + + t.test('should respond with 200 cache-control is set to no-cache', function (t) { + t.plan(2) + + const app = http.createServer(function (req, res) { + function error (err) { + res.statusCode = err.status + res.end(http.STATUS_CODES[err.status]) + } + + new SendStream(req, req.url, { root: fixtures }) + .on('error', error) + .pipe(res) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .set('cache-control', 'no-cache') + .expect(200, 'tobi', err => t.error(err)) + }) + }) }) t.test('where "If-Unmodified-Since" is set', function (t) { From f52d44c4f905845d69ebbc43ea4a6880fc3b967b Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 29 Jan 2023 23:20:31 +0100 Subject: [PATCH 033/109] Bumped v2.0.0 Signed-off-by: Matteo Collina --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index eb93ceb..2918230 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@fastify/send", "description": "Better streaming static file server with Range and conditional-GET support", - "version": "1.0.0", + "version": "2.0.0", "author": "TJ Holowaychuk ", "contributors": [ "Douglas Christopher Wilson ", From 5a5fc01ed4c622b7ca17e062b8c4340f688427e2 Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Mon, 30 Jan 2023 14:33:33 +0100 Subject: [PATCH 034/109] fix export of isUtf8MimeType (#35) --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index da7e627..b7b27f1 100644 --- a/index.js +++ b/index.js @@ -11,7 +11,7 @@ * Module dependencies. * @private */ -const isUtf8MimeType = require('./lib/isUtf8MimeType') +const isUtf8MimeType = require('./lib/isUtf8MimeType').isUtf8MimeType const mime = require('mime') const SendStream = require('./lib/SendStream') From 0cda6e4d68142ddf66a617abf1c9152b9f2768a7 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Mon, 30 Jan 2023 14:33:52 +0100 Subject: [PATCH 035/109] Bumped v2.0.1 Signed-off-by: Matteo Collina --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2918230..10799f7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@fastify/send", "description": "Better streaming static file server with Range and conditional-GET support", - "version": "2.0.0", + "version": "2.0.1", "author": "TJ Holowaychuk ", "contributors": [ "Douglas Christopher Wilson ", From 0d2e5e8eb23d9596817c269e71b0f0cfb313889b Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Thu, 2 Feb 2023 22:45:25 +0100 Subject: [PATCH 036/109] improve performance of normalizeList (#38) --- benchmarks/normalizeList.js | 14 ++++++++++++++ lib/normalizeList.js | 19 ++++++++++++------- test/normalizeList.test.js | 28 ++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 benchmarks/normalizeList.js create mode 100644 test/normalizeList.test.js diff --git a/benchmarks/normalizeList.js b/benchmarks/normalizeList.js new file mode 100644 index 0000000..9926ca3 --- /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/lib/normalizeList.js b/lib/normalizeList.js index 8e49ac1..b18eac9 100644 --- a/lib/normalizeList.js +++ b/lib/normalizeList.js @@ -9,15 +9,20 @@ */ function normalizeList (val, name) { - const list = [].concat(val || []) - - for (let i = 0; i < list.length; i++) { - if (typeof list[i] !== 'string') { - throw new TypeError(name + ' must be array of strings or false') + 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') } - - return list } module.exports.normalizeList = normalizeList diff --git a/test/normalizeList.test.js b/test/normalizeList.test.js new file mode 100644 index 0000000..7473e9c --- /dev/null +++ b/test/normalizeList.test.js @@ -0,0 +1,28 @@ +'use strict' + +const { test } = require('tap') +const { normalizeList } = require('../lib/normalizeList') + +test('normalizeList', function (t) { + const testCases = [ + [undefined, new Error('test must be array of strings or false')], + [false, []], + [[], []], + ['', ['']], + [[''], ['']], + [['a'], ['a']], + ['a', ['a']], + [true, new Error('test must be array of strings or false')], + [1, new Error('test must be array of strings or false')], + [[1], new Error('test must be array of strings or false')] + ] + t.plan(testCases.length) + + for (let i = 0; i < testCases.length; ++i) { + if (testCases[i][1] instanceof Error) { + t.throws(() => normalizeList(testCases[i][0], 'test'), testCases[i][1]) + } else { + t.strictSame(normalizeList(testCases[i][0], 'test'), testCases[i][1]) + } + } +}) From cbe096afbd4e6c7fb9095945f297e04cc06535dd Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Fri, 3 Feb 2023 12:05:11 +0100 Subject: [PATCH 037/109] improve performance of isUtf8MimeType (#39) --- benchmarks/isUtf8MimeType.js | 23 +++++++++++++++++++++++ lib/isUtf8MimeType.js | 10 ++++++++-- test/isUtf8MimeType.test.js | 22 ++++++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 benchmarks/isUtf8MimeType.js create mode 100644 test/isUtf8MimeType.test.js diff --git a/benchmarks/isUtf8MimeType.js b/benchmarks/isUtf8MimeType.js new file mode 100644 index 0000000..a68a8bc --- /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/lib/isUtf8MimeType.js b/lib/isUtf8MimeType.js index 93a7c5b..d24978a 100644 --- a/lib/isUtf8MimeType.js +++ b/lib/isUtf8MimeType.js @@ -1,6 +1,12 @@ 'use strict' -const utf8MimeTypeRE = /^text\/|^application\/(javascript|json)/ -const isUtf8MimeType = utf8MimeTypeRE.test.bind(utf8MimeTypeRE) +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/test/isUtf8MimeType.test.js b/test/isUtf8MimeType.test.js new file mode 100644 index 0000000..f718c2a --- /dev/null +++ b/test/isUtf8MimeType.test.js @@ -0,0 +1,22 @@ +'use strict' + +const { test } = require('tap') +const { isUtf8MimeType } = require('../lib/isUtf8MimeType') + +test('isUtf8MimeType', function (t) { + const testCases = [ + ['application/json', true], + ['text/json', true], + ['application/javascript', true], + ['text/javascript', true], + ['application/json+v5', true], + ['text/xml', true], + ['text/html', true], + ['image/png', false] + ] + t.plan(testCases.length) + + for (let i = 0; i < testCases.length; ++i) { + t.strictSame(isUtf8MimeType(testCases[i][0], 'test'), testCases[i][1]) + } +}) From 5c33ef01f59b29654e0f0569f80d69b147cfb791 Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Fri, 3 Feb 2023 18:35:29 +0100 Subject: [PATCH 038/109] improve performance of collapseLeadingSlashes (#40) --- benchmarks/collapseLeadingSlashes.js | 13 +++++++++++++ lib/collapseLeadingSlashes.js | 15 ++++++++------- test/collapseLeadingSlashes.test.js | 22 ++++++++++++++++++++++ 3 files changed, 43 insertions(+), 7 deletions(-) create mode 100644 benchmarks/collapseLeadingSlashes.js create mode 100644 test/collapseLeadingSlashes.test.js diff --git a/benchmarks/collapseLeadingSlashes.js b/benchmarks/collapseLeadingSlashes.js new file mode 100644 index 0000000..2b56dd4 --- /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/lib/collapseLeadingSlashes.js b/lib/collapseLeadingSlashes.js index d0fd796..97c8e26 100644 --- a/lib/collapseLeadingSlashes.js +++ b/lib/collapseLeadingSlashes.js @@ -8,16 +8,17 @@ */ function collapseLeadingSlashes (str) { - let i = 0 - for (i; i < str.length; i++) { + if ( + str[0] !== '/' || + str[1] !== '/' + ) { + return str + } + for (let i = 2, il = str.length; i < il; ++i) { if (str[i] !== '/') { - break + return str.slice(i - 1) } } - - return i > 1 - ? '/' + str.substr(i) - : str } module.exports.collapseLeadingSlashes = collapseLeadingSlashes diff --git a/test/collapseLeadingSlashes.test.js b/test/collapseLeadingSlashes.test.js new file mode 100644 index 0000000..6f8dd93 --- /dev/null +++ b/test/collapseLeadingSlashes.test.js @@ -0,0 +1,22 @@ +'use strict' + +const { test } = require('tap') +const { collapseLeadingSlashes } = require('../lib/collapseLeadingSlashes') + +test('collapseLeadingSlashes', function (t) { + const testCases = [ + ['abcd', 'abcd'], + ['text/json', 'text/json'], + ['/text/json', '/text/json'], + ['//text/json', '/text/json'], + ['///text/json', '/text/json'], + ['/.//text/json', '/.//text/json'], + ['//./text/json', '/./text/json'], + ['///./text/json', '/./text/json'] + ] + t.plan(testCases.length) + + for (let i = 0; i < testCases.length; ++i) { + t.strictSame(collapseLeadingSlashes(testCases[i][0]), testCases[i][1]) + } +}) From 3c25ec01935c3d565a08f70a6f5b509f457d943a Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Sat, 4 Feb 2023 08:30:45 +0100 Subject: [PATCH 039/109] improve performance of parseRange (#37) * improve performance of parseRange * rename parseRange to parseBytesRange * improve comments of isNaN fast path * rename benchmark --- benchmarks/parseBytesRange.js | 15 ++++ benchmarks/parseRange.js | 12 --- lib/SendStream.js | 93 +++++++++++++---------- lib/parseBytesRange.js | 133 +++++++++++++++++++++++++++++++++ lib/parseRange.js | 137 ---------------------------------- test/parseBytesRange.test.js | 103 +++++++++++++++++++++++++ test/parseRange.test.js | 114 ---------------------------- 7 files changed, 303 insertions(+), 304 deletions(-) create mode 100644 benchmarks/parseBytesRange.js delete mode 100644 benchmarks/parseRange.js create mode 100644 lib/parseBytesRange.js delete mode 100644 lib/parseRange.js create mode 100644 test/parseBytesRange.test.js delete mode 100644 test/parseRange.test.js diff --git a/benchmarks/parseBytesRange.js b/benchmarks/parseBytesRange.js new file mode 100644 index 0000000..48d59ab --- /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/benchmarks/parseRange.js b/benchmarks/parseRange.js deleted file mode 100644 index e8d928b..0000000 --- a/benchmarks/parseRange.js +++ /dev/null @@ -1,12 +0,0 @@ -'use strict' - -const benchmark = require('benchmark') -const { parseRange } = require('../lib/parseRange') - -const size = 150 -const range = 'bytes=0-4,90-99,5-75,100-199,101-102' - -new benchmark.Suite() - .add('parseRange', function () { parseRange(size, range) }, { minSamples: 100 }) - .on('cycle', function onCycle (event) { console.log(String(event.target)) }) - .run({ async: false }) diff --git a/lib/SendStream.js b/lib/SendStream.js index b1c9320..1feaee6 100644 --- a/lib/SendStream.js +++ b/lib/SendStream.js @@ -26,7 +26,7 @@ const { createHtmlDocument } = require('./createHtmlDocument') const { createHttpError } = require('./createHttpError') const { isUtf8MimeType } = require('./isUtf8MimeType') const { normalizeList } = require('./normalizeList') -const { parseRange } = require('./parseRange') +const { parseBytesRange } = require('./parseBytesRange') const { parseTokenList } = require('./parseTokenList') const { setHeaders } = require('./setHeaders') @@ -132,7 +132,8 @@ function SendStream (req, path, options) { this._maxage = typeof this._maxage === 'string' ? ms.parse(this._maxage) : Number(this._maxage) - this._maxage = !isNaN(this._maxage) + // eslint-disable-next-line no-self-compare + this._maxage = this._maxage === this._maxage // fast path of isNaN(number) ? Math.min(Math.max(0, this._maxage), MAX_MAXAGE) : 0 @@ -328,10 +329,12 @@ SendStream.prototype.isPreconditionFailure = function isPreconditionFailure () { if ('if-unmodified-since' in req.headers) { const ifUnmodifiedSince = req.headers['if-unmodified-since'] const unmodifiedSince = Date.parse(ifUnmodifiedSince) - if (!Number.isNaN(unmodifiedSince)) { + // eslint-disable-next-line no-self-compare + if (unmodifiedSince === unmodifiedSince) { // fast path of isNaN(number) const lastModified = Date.parse(res.getHeader('Last-Modified')) if ( - Number.isNaN(lastModified) || + // eslint-disable-next-line no-self-compare + lastModified !== lastModified ||// fast path of isNaN(number) lastModified > unmodifiedSince ) { return true @@ -441,14 +444,19 @@ SendStream.prototype.isRangeFresh = function isRangeFresh () { } const ifRangeTimestamp = Date.parse(ifRange) - if (Number.isNaN(ifRangeTimestamp)) { + // 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(this.res.getHeader('Last-Modified')) - return Number.isNaN(lastModified) || lastModified <= ifRangeTimestamp + return ( + // eslint-disable-next-line no-self-compare + lastModified !== lastModified || // fast path of isNaN(number) + lastModified <= ifRangeTimestamp + ) } /** @@ -586,7 +594,6 @@ SendStream.prototype.send = function send (path, stat) { const opts = {} const res = this.res const req = this.req - let ranges = req.headers.range let offset = options.start || 0 if (res.headersSent) { @@ -624,40 +631,44 @@ SendStream.prototype.send = function send (path, stat) { } // Range support - if (this._acceptRanges && ranges !== undefined && BYTES_RANGE_REGEXP.test(ranges)) { - // If-Range support - if (!this.isRangeFresh()) { - debug('range stale') - ranges = -2 - } else { - // parse - ranges = parseRange(len, ranges) - } - - // 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 + if (this._acceptRanges) { + const rangeHeader = req.headers.range + + if ( + rangeHeader !== undefined && + BYTES_RANGE_REGEXP.test(rangeHeader) + ) { + // If-Range support + if (this.isRangeFresh()) { + // parse + const ranges = parseBytesRange(len, rangeHeader) + + // unsatisfiable + if (ranges.length === 0) { + 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) + } else if (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 + } + } else { + debug('range stale') + } } } diff --git a/lib/parseBytesRange.js b/lib/parseBytesRange.js new file mode 100644 index 0000000..5235c3e --- /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/parseRange.js b/lib/parseRange.js deleted file mode 100644 index 1a09c47..0000000 --- a/lib/parseRange.js +++ /dev/null @@ -1,137 +0,0 @@ -'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 parseRange (size, str) { - const index = str.indexOf('=') - - // split the range string - const arr = str.slice(index + 1).split(',') - const ranges = [] - - // parse all ranges - for (let i = 0; i < arr.length; i++) { - const range = arr[i].split('-') - let start = parseInt(range[0], 10) - let end = parseInt(range[1], 10) - - // -nnn - if (isNaN(start)) { - start = size - end - end = size - 1 - // nnn- - } else if (isNaN(end)) { - end = size - 1 - } - - // limit last-byte-pos to current length - if (end > size - 1) { - end = size - 1 - } - - // invalid or unsatisfiable - if (isNaN(start) || isNaN(end) || start > end || start < 0) { - continue - } - - // add range - ranges.push({ - start, - end - }) - } - - if (ranges.length === 0) { - // unsatisifiable - return -1 - } - - const ordered = ranges.map(mapWithIndex).sort(sortByRangeStart) - - let j = 0 - const il = ordered.length - for (let i = 1; i < il; ++i) { - const range = ordered[i] - const current = ordered[j] - - if (range.start > current.end + 1) { - // next range - ordered[++j] = range - } else if (range.end > current.end) { - // extend range - current.end = range.end - current.index = Math.min(current.index, range.index) - } - } - - // trim ordered array - ordered.length = j + 1 - - // generate combined range - const combined = ordered.sort(sortByRangeIndex).map(mapWithoutIndex) - - // copy ranges type - combined.type = str.slice(0, index) - - return combined -} - -/** - * Map function to add index value to ranges. - * @private - */ - -function mapWithIndex (range, index) { - return { - start: range.start, - end: range.end, - index - } -} - -/** - * Map function to remove index value from ranges. - * @private - */ - -function mapWithoutIndex (range) { - return { - start: range.start, - end: range.end - } -} - -/** - * 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.parseRange = parseRange diff --git a/test/parseBytesRange.test.js b/test/parseBytesRange.test.js new file mode 100644 index 0000000..0d075b7 --- /dev/null +++ b/test/parseBytesRange.test.js @@ -0,0 +1,103 @@ +'use strict' + +const { test } = require('tap') +const { parseBytesRange } = require('../lib/parseBytesRange') + +test('parseBytesRange', function (t) { + t.plan(13) + + t.test('should return empty array if all specified ranges are invalid', function (t) { + t.plan(3) + t.strictSame(parseBytesRange(200, 'bytes=500-20'), []) + t.strictSame(parseBytesRange(200, 'bytes=500-999'), []) + t.strictSame(parseBytesRange(200, 'bytes=500-999,1000-1499'), []) + }) + + t.test('should parse str', function (t) { + t.plan(2) + const range = parseBytesRange(1000, 'bytes=0-499') + t.equal(range.length, 1) + t.strictSame(range[0], { start: 0, end: 499, index: 0 }) + }) + + t.test('should cap end at size', function (t) { + t.plan(2) + const range = parseBytesRange(200, 'bytes=0-499') + t.equal(range.length, 1) + t.strictSame(range[0], { start: 0, end: 199, index: 0 }) + }) + + t.test('should parse str', function (t) { + t.plan(2) + const range = parseBytesRange(1000, 'bytes=40-80') + t.equal(range.length, 1) + t.strictSame(range[0], { start: 40, end: 80, index: 0 }) + }) + + t.test('should parse str asking for last n bytes', function (t) { + t.plan(2) + const range = parseBytesRange(1000, 'bytes=-400') + t.equal(range.length, 1) + t.strictSame(range[0], { start: 600, end: 999, index: 0 }) + }) + + t.test('should parse str with only start', function (t) { + t.plan(2) + const range = parseBytesRange(1000, 'bytes=400-') + t.equal(range.length, 1) + t.strictSame(range[0], { start: 400, end: 999, index: 0 }) + }) + + t.test('should parse "bytes=0-"', function (t) { + t.plan(2) + const range = parseBytesRange(1000, 'bytes=0-') + t.equal(range.length, 1) + t.strictSame(range[0], { start: 0, end: 999, index: 0 }) + }) + + t.test('should parse str with no bytes', function (t) { + t.plan(2) + const range = parseBytesRange(1000, 'bytes=0-0') + t.equal(range.length, 1) + t.strictSame(range[0], { start: 0, end: 0, index: 0 }) + }) + + t.test('should parse str asking for last byte', function (t) { + t.plan(2) + const range = parseBytesRange(1000, 'bytes=-1') + t.equal(range.length, 1) + t.strictSame(range[0], { start: 999, end: 999, index: 0 }) + }) + + t.test('should parse str with some invalid ranges', function (t) { + t.plan(2) + const range = parseBytesRange(200, 'bytes=0-499,1000-,500-999') + t.equal(range.length, 1) + t.strictSame(range[0], { start: 0, end: 199, index: 0 }) + }) + + t.test('should combine overlapping ranges', function (t) { + t.plan(3) + const range = parseBytesRange(150, 'bytes=0-4,90-99,5-75,100-199,101-102') + t.equal(range.length, 2) + t.strictSame(range[0], { start: 0, end: 75, index: 0 }) + t.strictSame(range[1], { start: 90, end: 149, index: 1 }) + }) + + t.test('should retain original order /1', function (t) { + t.plan(3) + const range = parseBytesRange(150, 'bytes=90-99,5-75,100-199,101-102,0-4') + t.equal(range.length, 2) + t.strictSame(range[0], { start: 90, end: 149, index: 0 }) + t.strictSame(range[1], { start: 0, end: 75, index: 1 }) + }) + + t.test('should retain original order /2', function (t) { + t.plan(4) + const range = parseBytesRange(150, 'bytes=-1,20-100,0-1,101-120') + t.equal(range.length, 3) + t.strictSame(range[0], { start: 149, end: 149, index: 0 }) + t.strictSame(range[1], { start: 20, end: 120, index: 1 }) + t.strictSame(range[2], { start: 0, end: 1, index: 2 }) + }) +}) diff --git a/test/parseRange.test.js b/test/parseRange.test.js deleted file mode 100644 index 1fd2dad..0000000 --- a/test/parseRange.test.js +++ /dev/null @@ -1,114 +0,0 @@ -'use strict' - -const { test } = require('tap') -const { parseRange } = require('../lib/parseRange') - -test('parseRange', function (t) { - t.plan(13) - - t.test('should return -1 if all specified ranges are invalid', function (t) { - t.plan(3) - t.equal(parseRange(200, 'bytes=500-20'), -1) - t.equal(parseRange(200, 'bytes=500-999'), -1) - t.equal(parseRange(200, 'bytes=500-999,1000-1499'), -1) - }) - - t.test('should parse str', function (t) { - t.plan(3) - const range = parseRange(1000, 'bytes=0-499') - t.equal(range.type, 'bytes') - t.equal(range.length, 1) - t.strictSame(range[0], { start: 0, end: 499 }) - }) - - t.test('should cap end at size', function (t) { - t.plan(3) - const range = parseRange(200, 'bytes=0-499') - t.equal(range.type, 'bytes') - t.equal(range.length, 1) - t.strictSame(range[0], { start: 0, end: 199 }) - }) - - t.test('should parse str', function (t) { - t.plan(3) - const range = parseRange(1000, 'bytes=40-80') - t.equal(range.type, 'bytes') - t.equal(range.length, 1) - t.strictSame(range[0], { start: 40, end: 80 }) - }) - - t.test('should parse str asking for last n bytes', function (t) { - t.plan(3) - const range = parseRange(1000, 'bytes=-400') - t.equal(range.type, 'bytes') - t.equal(range.length, 1) - t.strictSame(range[0], { start: 600, end: 999 }) - }) - - t.test('should parse str with only start', function (t) { - t.plan(3) - const range = parseRange(1000, 'bytes=400-') - t.equal(range.type, 'bytes') - t.equal(range.length, 1) - t.strictSame(range[0], { start: 400, end: 999 }) - }) - - t.test('should parse "bytes=0-"', function (t) { - t.plan(3) - const range = parseRange(1000, 'bytes=0-') - t.equal(range.type, 'bytes') - t.equal(range.length, 1) - t.strictSame(range[0], { start: 0, end: 999 }) - }) - - t.test('should parse str with no bytes', function (t) { - t.plan(3) - const range = parseRange(1000, 'bytes=0-0') - t.equal(range.type, 'bytes') - t.equal(range.length, 1) - t.strictSame(range[0], { start: 0, end: 0 }) - }) - - t.test('should parse str asking for last byte', function (t) { - t.plan(3) - const range = parseRange(1000, 'bytes=-1') - t.equal(range.type, 'bytes') - t.equal(range.length, 1) - t.strictSame(range[0], { start: 999, end: 999 }) - }) - - t.test('should parse str with some invalid ranges', function (t) { - t.plan(3) - const range = parseRange(200, 'bytes=0-499,1000-,500-999') - t.equal(range.type, 'bytes') - t.equal(range.length, 1) - t.strictSame(range[0], { start: 0, end: 199 }) - }) - - t.test('should parse non-byte range', function (t) { - t.plan(3) - const range = parseRange(1000, 'items=0-5') - t.equal(range.type, 'items') - t.equal(range.length, 1) - t.strictSame(range[0], { start: 0, end: 5 }) - }) - - t.test('should combine overlapping ranges', function (t) { - t.plan(4) - const range = parseRange(150, 'bytes=0-4,90-99,5-75,100-199,101-102', { combine: true }) - t.equal(range.type, 'bytes') - t.equal(range.length, 2) - t.strictSame(range[0], { start: 0, end: 75 }) - t.strictSame(range[1], { start: 90, end: 149 }) - }) - - t.test('should retain original order', function (t) { - t.plan(5) - const range = parseRange(150, 'bytes=-1,20-100,0-1,101-120', { combine: true }) - t.equal(range.type, 'bytes') - t.equal(range.length, 3) - t.strictSame(range[0], { start: 149, end: 149 }) - t.strictSame(range[1], { start: 20, end: 120 }) - t.strictSame(range[2], { start: 0, end: 1 }) - }) -}) From bd1d0bedd25a45571502ff06ef0d4fe1929c68a1 Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Sat, 4 Feb 2023 13:09:03 +0100 Subject: [PATCH 040/109] improve containsDotFile (#41) --- benchmarks/containsDotFile.js | 15 +++++++++++++++ lib/SendStream.js | 31 +++++++++++++++++++++++-------- lib/containsDotFile.js | 8 ++++---- test/containsDotFile.test.js | 18 ++++++++++++++++++ 4 files changed, 60 insertions(+), 12 deletions(-) create mode 100644 benchmarks/containsDotFile.js create mode 100644 test/containsDotFile.test.js diff --git a/benchmarks/containsDotFile.js b/benchmarks/containsDotFile.js new file mode 100644 index 0000000..a9cbfc4 --- /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/lib/SendStream.js b/lib/SendStream.js index 1feaee6..3bbcecc 100644 --- a/lib/SendStream.js +++ b/lib/SendStream.js @@ -71,6 +71,12 @@ const ERROR_RESPONSES = { 500: createHtmlDocument('Error', 'Internal Server Error') } +const validDotFilesOptions = [ + 'allow', + 'ignore', + 'deny' +] + /** * Initialize a `SendStream` with the given `path`. * @@ -105,10 +111,10 @@ function SendStream (req, path, options) { : true this._dotfiles = opts.dotfiles !== undefined - ? opts.dotfiles - : 'ignore' + ? validDotFilesOptions.indexOf(opts.dotfiles) + : 1 // 'ignore' - if (this._dotfiles !== 'ignore' && this._dotfiles !== 'allow' && this._dotfiles !== 'deny') { + if (this._dotfiles === -1) { throw new TypeError('dotfiles option must be "allow", "deny", or "ignore"') } @@ -556,16 +562,25 @@ SendStream.prototype.pipe = function pipe (res) { } // dotfile handling - if (containsDotFile(parts)) { - debug('%s dotfile "%s"', this._dotfiles, path) + if ( + ( + debug.enabled || // if debugging is enabled, then check for all cases to log allow case + this._dotfiles !== 0 // if debugging is not enabled, then only check if 'deny' or 'ignore' is set + ) && + containsDotFile(parts) + ) { switch (this._dotfiles) { - case 'allow': + /* istanbul ignore next: unreachable, because NODE_DEBUG can not be set after process is running */ + case 0: // 'allow' + debug('allow dotfile "%s"', path) break - case 'deny': + case 2: // 'deny' + debug('deny dotfile "%s"', path) this.error(403) return res - case 'ignore': + case 1: // 'ignore' default: + debug('ignore dotfile "%s"', path) this.error(404) return res } diff --git a/lib/containsDotFile.js b/lib/containsDotFile.js index f19d5f4..c446aa0 100644 --- a/lib/containsDotFile.js +++ b/lib/containsDotFile.js @@ -11,13 +11,13 @@ * @api private */ function containsDotFile (parts) { - for (let i = 0; i < parts.length; i++) { - const part = parts[i] - if (part.length > 1 && part[0] === '.') { + for (let i = 0, il = parts.length; i < il; ++i) { + if (parts[i].length !== 1 && parts[i][0] === '.') { return true } } return false } -exports.containsDotFile = containsDotFile + +module.exports.containsDotFile = containsDotFile diff --git a/test/containsDotFile.test.js b/test/containsDotFile.test.js new file mode 100644 index 0000000..03e5f50 --- /dev/null +++ b/test/containsDotFile.test.js @@ -0,0 +1,18 @@ +'use strict' + +const { test } = require('tap') +const { containsDotFile } = require('../lib/containsDotFile') + +test('containsDotFile', function (t) { + const testCases = [ + ['/.github', true], + ['.github', true], + ['index.html', false], + ['./index.html', false] + ] + t.plan(testCases.length) + + for (let i = 0; i < testCases.length; ++i) { + t.strictSame(containsDotFile(testCases[i][0].split('/')), testCases[i][1], testCases[i][0]) + } +}) From aa00ede5c940dfa376b6bf935931da83a150a334 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Sun, 5 Mar 2023 11:23:47 +0000 Subject: [PATCH 041/109] chore(.gitignore): add bun lockfile (#43) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1fb3e10..8344ae6 100644 --- a/.gitignore +++ b/.gitignore @@ -139,6 +139,7 @@ dist .clinic # lock files +bun.lockb package-lock.json pnpm-lock.yaml yarn.lock From b46e529e1b87a63559316668015be0932c842e68 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Mar 2023 06:13:51 +0000 Subject: [PATCH 042/109] build(deps-dev): bump tsd from 0.25.0 to 0.26.0 (#44) Bumps [tsd](https://github.com/SamVerschueren/tsd) from 0.25.0 to 0.26.0. - [Release notes](https://github.com/SamVerschueren/tsd/releases) - [Commits](https://github.com/SamVerschueren/tsd/compare/v0.25.0...v0.26.0) --- updated-dependencies: - dependency-name: tsd dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 10799f7..ce30bbf 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "standard": "^17.0.0", "supertest": "6.3.3", "tap": "^16.3.3", - "tsd": "^0.25.0" + "tsd": "^0.26.0" }, "scripts": { "lint": "standard | snazzy", From a8d538d2ef1fdb20322b8b964d983df85b677db1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Mar 2023 06:19:47 +0000 Subject: [PATCH 043/109] build(deps-dev): bump tsd from 0.26.1 to 0.27.0 (#45) Bumps [tsd](https://github.com/SamVerschueren/tsd) from 0.26.1 to 0.27.0. - [Release notes](https://github.com/SamVerschueren/tsd/releases) - [Commits](https://github.com/SamVerschueren/tsd/compare/v0.26.1...v0.27.0) --- updated-dependencies: - dependency-name: tsd dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ce30bbf..9da1a79 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "standard": "^17.0.0", "supertest": "6.3.3", "tap": "^16.3.3", - "tsd": "^0.26.0" + "tsd": "^0.27.0" }, "scripts": { "lint": "standard | snazzy", From 980e12eeb6020b2699c349a22b7dee00235cc271 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Mar 2023 06:19:38 +0000 Subject: [PATCH 044/109] build(deps-dev): bump tsd from 0.27.0 to 0.28.0 (#46) Bumps [tsd](https://github.com/SamVerschueren/tsd) from 0.27.0 to 0.28.0. - [Release notes](https://github.com/SamVerschueren/tsd/releases) - [Commits](https://github.com/SamVerschueren/tsd/compare/v0.27.0...v0.28.0) --- updated-dependencies: - dependency-name: tsd dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9da1a79..6deb316 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "standard": "^17.0.0", "supertest": "6.3.3", "tap": "^16.3.3", - "tsd": "^0.27.0" + "tsd": "^0.28.0" }, "scripts": { "lint": "standard | snazzy", From ee9eb2adc389b953caa27663111bf9805a577cca Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Thu, 13 Apr 2023 16:05:22 +0100 Subject: [PATCH 045/109] test(fixtures/images): compress test image (#47) --- test/fixtures/images/node-js.png | Bin 569 -> 522 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/test/fixtures/images/node-js.png b/test/fixtures/images/node-js.png index e652bd0a15dd377c9c37e151890206bf9bcf9ce2..49f17ca39b7e0432768aa02d93782808dda894ab 100644 GIT binary patch delta 497 zcmV7PuNGdqWW(+2f~wqM#5(spU1PEmm)0!3>#=X zH|E5hpa|k)`fni&f=NWDAV$b{<_dE3w_gR}hg{r&?J?p%u!NYM?3@R42wjBi0W!6N z8irrR;&ucEh=1?t8eg#6d)xW#55b zuKp6!xLU_xpL~&%hzqfQwOnZ>X9?Iew|kVTH`?wI_))z00000NkvXXu0mjf(oFMf delta 545 zcmV++0^a?K1i1u|BYyx1a7bBm000XT000XT0n*)m`~Uy}>q$gGR5;6JliMpqQ543{ znK3TqHbp7kguP#s@-p&DNs>!bL`k8%@kFl8DSrTk7am=5E2Sj2$lI9|xfP8EW%5_} zow3f$#;LEh&RXkR>)Y$>V>xkc`HvGr3=?nx6}?KQWBtZjo>58;~R3%1)()=BP#_!X$jC=f2wi%G0g_z9+r zh9Un*^3HKolG+ti4&WHz#+s4 z@m2^=AVJf2h<^*Rvk|asZvO;AR3_#k`xV;exdR&LE;&C9QnI7?6{r}(E`bALU)To7!(b=Vz||*Ajf&Jf zNViCzhVTr^jX@98PD1Yg)qs~vl1=FbuJRPLfC+L*Vt+S|r^;2=Qa?y+&s^dLQ_o2h z$|4gq>5%=Dfe1C#qM`u*7MPz=6+vFZas%}l(>|G2V*PoD&l3Nlc9{nG|E@BAEe+VV zY6gvlLT{n3-Zgj3tL)!W&-wi`sY<5YW4+IN%+v(UdkrN)$0z&0GleNN-u?HLfl1!y jDGo`k?HU-rGjZ!1m}^L8Z8DFF00000NkvXXu0mjf@hApb From 49f3ea44e1bd5c953d01fc4e471e00118d7e4ba7 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Thu, 13 Apr 2023 19:08:49 +0200 Subject: [PATCH 046/109] chore: fix package url --- package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 6deb316..342efbe 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,10 @@ "main": "index.js", "types": "types/index.d.ts", "license": "MIT", - "repository": "fastify/send", + "repository": { + "type": "git", + "url": "git+https://github.com/fastify/send.git" + }, "keywords": [ "static", "file", From cf190d743d9ea07be0eab5f66b8793297c773858 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Thu, 13 Apr 2023 19:10:39 +0200 Subject: [PATCH 047/109] Bumped v2.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 342efbe..860e920 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@fastify/send", "description": "Better streaming static file server with Range and conditional-GET support", - "version": "2.0.1", + "version": "2.1.0", "author": "TJ Holowaychuk ", "contributors": [ "Douglas Christopher Wilson ", From 58406d0b240fe3f6690bd9e0495843545ab51bb8 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Fri, 28 Apr 2023 18:24:23 +0100 Subject: [PATCH 048/109] ci: only trigger on pushes to main branches (#48) --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 907c06a..df3f56c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,11 @@ name: CI on: push: + branches: + - main + - master + - next + - 'v*' paths-ignore: - 'docs/**' - '*.md' From 1b603e9784591d82a7b95b1bd88c65718411a6a7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 May 2023 06:09:21 +0000 Subject: [PATCH 049/109] build(deps-dev): bump @types/node from 18.16.5 to 20.1.0 (#49) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 18.16.5 to 20.1.0. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 860e920..34c12e6 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ }, "devDependencies": { "@fastify/pre-commit": "^2.0.2", - "@types/node": "^18.11.18", + "@types/node": "^20.1.0", "after": "0.8.2", "benchmark": "^2.1.4", "snazzy": "^9.0.0", From 45df0ef6b74bb9dab577825bcffeafbec2b18f36 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Sep 2023 05:42:14 +0000 Subject: [PATCH 050/109] build(deps-dev): bump tsd from 0.28.1 to 0.29.0 (#52) Bumps [tsd](https://github.com/SamVerschueren/tsd) from 0.28.1 to 0.29.0. - [Release notes](https://github.com/SamVerschueren/tsd/releases) - [Commits](https://github.com/SamVerschueren/tsd/compare/v0.28.1...v0.29.0) --- updated-dependencies: - dependency-name: tsd dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 34c12e6..d3019db 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "standard": "^17.0.0", "supertest": "6.3.3", "tap": "^16.3.3", - "tsd": "^0.28.0" + "tsd": "^0.29.0" }, "scripts": { "lint": "standard | snazzy", From 163e4b2ecab467790f9885d177eaf7d04902564a Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Sun, 10 Sep 2023 10:29:47 +0100 Subject: [PATCH 051/109] perf: use `node:` prefix to bypass require.cache call for builtins (#53) * perf: use `node:` prefix to bypass require.cache call for builtins See https://github.com/fastify/fastify-static/pull/407 * docs: use `node:` prefix to bypass require.cache call for builtins --- README.md | 12 ++++++------ examples/simple.js | 4 ++-- test/SendStream-pipe.test.js | 6 +++--- test/SendStream.test.js | 6 +++--- test/mime.test.js | 2 +- test/utils.js | 2 +- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 87106e4..d4b2758 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ $ npm test This simple example will send a specific file to all requests. ```js -var http = require('http') +var http = require('node:http') var send = require('send') var server = http.createServer(function onRequest (req, res) { @@ -191,7 +191,7 @@ 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 http = require('node:http') var parseUrl = require('parseurl') var send = require('@fastify/send') @@ -206,7 +206,7 @@ server.listen(3000) ### Custom file types ```js -var http = require('http') +var http = require('node:http') var parseUrl = require('parseurl') var send = require('@fastify/send') @@ -232,8 +232,8 @@ 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 http = require('node:http') +var fs = require('node:fs') var parseUrl = require('parseurl') var send = require('@fastify/send') @@ -270,7 +270,7 @@ function directory (res, path) { ### Serving from a root directory with custom error-handling ```js -var http = require('http') +var http = require('node:http') var parseUrl = require('parseurl') var send = require('@fastify/send') diff --git a/examples/simple.js b/examples/simple.js index e9e3614..2c79119 100644 --- a/examples/simple.js +++ b/examples/simple.js @@ -1,8 +1,8 @@ 'use strict' -const http = require('http') +const http = require('node:http') const send = require('..') -const path = require('path') +const path = require('node:path') const indexPath = path.join(__dirname, 'index.html') diff --git a/test/SendStream-pipe.test.js b/test/SendStream-pipe.test.js index 6967ed0..0c58fa9 100644 --- a/test/SendStream-pipe.test.js +++ b/test/SendStream-pipe.test.js @@ -2,11 +2,11 @@ const { test } = require('tap') const after = require('after') -const http = require('http') -const path = require('path') +const http = require('node:http') +const path = require('node:path') const request = require('supertest') const SendStream = require('../lib/SendStream') -const os = require('os') +const os = require('node:os') const { shouldNotHaveBody, createServer, shouldNotHaveHeader } = require('./utils') const dateRegExp = /^\w{3}, \d+ \w+ \d+ \d+:\d+:\d+ \w+$/ diff --git a/test/SendStream.test.js b/test/SendStream.test.js index b86dc51..fc5c1c1 100644 --- a/test/SendStream.test.js +++ b/test/SendStream.test.js @@ -1,9 +1,9 @@ 'use strict' const { test } = require('tap') -const fs = require('fs') -const http = require('http') -const path = require('path') +const fs = require('node:fs') +const http = require('node:http') +const path = require('node:path') const request = require('supertest') const SendStream = require('..').SendStream const { shouldNotHaveHeader, createServer } = require('./utils') diff --git a/test/mime.test.js b/test/mime.test.js index f93641c..476c293 100644 --- a/test/mime.test.js +++ b/test/mime.test.js @@ -1,7 +1,7 @@ 'use strict' const { test } = require('tap') -const path = require('path') +const path = require('node:path') const request = require('supertest') const send = require('..') const { shouldNotHaveHeader, createServer } = require('./utils') diff --git a/test/utils.js b/test/utils.js index 923905f..218d38f 100644 --- a/test/utils.js +++ b/test/utils.js @@ -1,6 +1,6 @@ 'use strict' -const http = require('http') +const http = require('node:http') const send = require('..') module.exports.shouldNotHaveHeader = function shouldNotHaveHeader (header, t) { From 7ac977b0e6159debf07181b992af47b9a3f52c45 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Sat, 7 Oct 2023 07:39:56 +0100 Subject: [PATCH 052/109] chore: add `.gitattributes` file (#57) --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a0e7df9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Set default behavior to automatically convert line endings +* text=auto eol=lf From 2cedc02a1371eb80b1826237d6cc5623eb491fb4 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Fri, 27 Oct 2023 14:52:51 +0100 Subject: [PATCH 053/109] chore(package): explicitly declare js module type (#58) --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index d3019db..70a5d25 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "Jesús Leganés Combarro " ], "main": "index.js", + "type": "commonjs", "types": "types/index.d.ts", "license": "MIT", "repository": { From fc1917a64d14e99e529b6a1b2250225635a1f61e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Dec 2023 05:59:54 +0000 Subject: [PATCH 054/109] build(deps-dev): bump tsd from 0.29.0 to 0.30.0 (#62) Bumps [tsd](https://github.com/tsdjs/tsd) from 0.29.0 to 0.30.0. - [Release notes](https://github.com/tsdjs/tsd/releases) - [Commits](https://github.com/tsdjs/tsd/compare/v0.29.0...v0.30.0) --- updated-dependencies: - dependency-name: tsd dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 70a5d25..8346c88 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "standard": "^17.0.0", "supertest": "6.3.3", "tap": "^16.3.3", - "tsd": "^0.29.0" + "tsd": "^0.30.0" }, "scripts": { "lint": "standard | snazzy", From 80ff9376f6fa7aab6ea6d8b181b85354f3f2ec91 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 05:24:20 +0000 Subject: [PATCH 055/109] build(deps-dev): bump supertest from 6.3.3 to 6.3.4 (#63) Bumps [supertest](https://github.com/ladjs/supertest) from 6.3.3 to 6.3.4. - [Release notes](https://github.com/ladjs/supertest/releases) - [Commits](https://github.com/ladjs/supertest/compare/v6.3.3...v6.3.4) --- updated-dependencies: - dependency-name: supertest dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8346c88..59a4e7b 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "benchmark": "^2.1.4", "snazzy": "^9.0.0", "standard": "^17.0.0", - "supertest": "6.3.3", + "supertest": "6.3.4", "tap": "^16.3.3", "tsd": "^0.30.0" }, From cfc92c9300052f8e1fe5c094792a02588634c021 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Thu, 8 Feb 2024 19:51:55 +0000 Subject: [PATCH 056/109] chore(.gitignore): add .tap/ dir (#64) --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8344ae6..7105678 100644 --- a/.gitignore +++ b/.gitignore @@ -146,4 +146,7 @@ yarn.lock # editor files .vscode -.idea \ No newline at end of file +.idea + +#tap files +.tap/ \ No newline at end of file From 2081e6e3b08b7b41e619103332b3a01f01eb836f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Apr 2024 05:13:50 +0000 Subject: [PATCH 057/109] build(deps-dev): bump tsd from 0.30.7 to 0.31.0 (#65) Bumps [tsd](https://github.com/tsdjs/tsd) from 0.30.7 to 0.31.0. - [Release notes](https://github.com/tsdjs/tsd/releases) - [Commits](https://github.com/tsdjs/tsd/compare/v0.30.7...v0.31.0) --- updated-dependencies: - dependency-name: tsd dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 59a4e7b..6520028 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "standard": "^17.0.0", "supertest": "6.3.4", "tap": "^16.3.3", - "tsd": "^0.30.0" + "tsd": "^0.31.0" }, "scripts": { "lint": "standard | snazzy", From 6fd44c8810790ca02cb7ae25a630ab63df53721a Mon Sep 17 00:00:00 2001 From: KaKa <23028015+climba03003@users.noreply.github.com> Date: Wed, 3 Apr 2024 02:36:51 +0800 Subject: [PATCH 058/109] chore: add TypeScript about @types/mime (#67) --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index d4b2758..2e5ab3e 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,20 @@ This is a [Node.js](https://nodejs.org/en/) module available through the $ npm install @fastify/send ``` +### TypeScript + +Due to the updates on `@types/mime@4` to remove the typing +support of `mime`. If you need to install the types, you +must use `@types/mime@3`. + +```bash +$ npm install -D @types/mime@3 +``` + +If you facing the problem of invalid types of `mime`, you +should check which exact package is linked to `@types/mime@4` +and file an issue to that package. + ## API ```js From ca19a72378f2fb405955b23cca9c789e6c3a804c Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Wed, 3 Apr 2024 21:38:16 +0100 Subject: [PATCH 059/109] docs(readme): tidy typescript section (#68) * docs(readme): tidy typescript section Didn't get to https://github.com/fastify/send/pull/67 fast enough to review it last night Signed-off-by: Frazer Smith * Update README.md Signed-off-by: Frazer Smith * Update README.md Signed-off-by: Frazer Smith --------- Signed-off-by: Frazer Smith --- README.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2e5ab3e..9fb6d03 100644 --- a/README.md +++ b/README.md @@ -22,18 +22,13 @@ $ npm install @fastify/send ### TypeScript -Due to the updates on `@types/mime@4` to remove the typing -support of `mime`. If you need to install the types, you -must use `@types/mime@3`. +`@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 ``` -If you facing the problem of invalid types of `mime`, you -should check which exact package is linked to `@types/mime@4` -and file an issue to that package. - ## API ```js From f01ef24840bbe0eaea544527e477d1b5448ce162 Mon Sep 17 00:00:00 2001 From: James Sumners <321201+jsumners@users.noreply.github.com> Date: Fri, 5 Jul 2024 06:34:22 -0400 Subject: [PATCH 060/109] Merge `next` into `master` (#76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update for Fastify v5 (#66) * next * fix istanbul * update workflows (#70) * Update .github/workflows/ci.yml Co-authored-by: Frazer Smith Signed-off-by: Gürgün Dayıoğlu --------- Signed-off-by: Gürgün Dayıoğlu Co-authored-by: Gürgün Dayıoğlu Co-authored-by: Frazer Smith --- .github/workflows/ci.yml | 5 +++-- lib/SendStream.js | 6 ++++-- lib/collapseLeadingSlashes.js | 1 + package.json | 12 ++++++------ test/mime.test.js | 4 ++-- 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df3f56c..ff54ddc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,8 @@ on: jobs: test: - uses: fastify/workflows/.github/workflows/plugins-ci.yml@v3 + uses: fastify/workflows/.github/workflows/plugins-ci.yml@v4.2.0 with: license-check: true - lint: true \ No newline at end of file + lint: true + node-versions: '["18", "20", "22"]' diff --git a/lib/SendStream.js b/lib/SendStream.js index 3bbcecc..a88d95b 100644 --- a/lib/SendStream.js +++ b/lib/SendStream.js @@ -416,13 +416,14 @@ SendStream.prototype.isCachable = function isCachable () { SendStream.prototype.onStatError = function onStatError (error) { // POSIX throws ENAMETOOLONG and ENOTDIR, Windows only ENOENT - /* istanbul ignore next */ switch (error.code) { + /* c8 ignore start */ case 'ENAMETOOLONG': case 'ENOTDIR': case 'ENOENT': this.error(404, error) break + /* c8 ignore stop */ default: this.error(500, error) break @@ -570,10 +571,11 @@ SendStream.prototype.pipe = function pipe (res) { containsDotFile(parts) ) { switch (this._dotfiles) { - /* istanbul ignore next: unreachable, because NODE_DEBUG can not be set after process is running */ + /* 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) this.error(403) diff --git a/lib/collapseLeadingSlashes.js b/lib/collapseLeadingSlashes.js index 97c8e26..b611a9c 100644 --- a/lib/collapseLeadingSlashes.js +++ b/lib/collapseLeadingSlashes.js @@ -19,6 +19,7 @@ function collapseLeadingSlashes (str) { return str.slice(i - 1) } } + /* c8 ignore next */ } module.exports.collapseLeadingSlashes = collapseLeadingSlashes diff --git a/package.json b/package.json index 6520028..4999bb6 100644 --- a/package.json +++ b/package.json @@ -25,18 +25,18 @@ "escape-html": "~1.0.3", "fast-decode-uri-component": "^1.0.1", "http-errors": "2.0.0", - "mime": "^3.0.0", - "@lukeed/ms": "^2.0.1" + "mime": "^3", + "@lukeed/ms": "^2.0.2" }, "devDependencies": { - "@fastify/pre-commit": "^2.0.2", - "@types/node": "^20.1.0", + "@fastify/pre-commit": "^2.1.0", + "@types/node": "^20.12.2", "after": "0.8.2", "benchmark": "^2.1.4", "snazzy": "^9.0.0", - "standard": "^17.0.0", + "standard": "^17.1.0", "supertest": "6.3.4", - "tap": "^16.3.3", + "tap": "^18.7.2", "tsd": "^0.31.0" }, "scripts": { diff --git a/test/mime.test.js b/test/mime.test.js index 476c293..6031d32 100644 --- a/test/mime.test.js +++ b/test/mime.test.js @@ -19,11 +19,11 @@ test('send.mime', function (t) { t.test('.default_type', function (t) { t.plan(3) - t.before(function () { + t.before(() => { this.default_type = send.mime.default_type }) - t.afterEach(function () { + t.afterEach(() => { send.mime.default_type = this.default_type }) From 7a2fc6a9ba6d604ea419982c3baefe54f167b444 Mon Sep 17 00:00:00 2001 From: James Sumners Date: Fri, 5 Jul 2024 06:34:58 -0400 Subject: [PATCH 061/109] v3.0.0-pre.fv5.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4999bb6..48017e8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@fastify/send", "description": "Better streaming static file server with Range and conditional-GET support", - "version": "2.1.0", + "version": "3.0.0-pre.fv5.1", "author": "TJ Holowaychuk ", "contributors": [ "Douglas Christopher Wilson ", From dc20cfaaef81e646ade3ae96a3c130a47eaecf28 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Jul 2024 10:37:18 +0000 Subject: [PATCH 062/109] build(deps-dev): bump tap from 16.3.10 to 20.0.3 (#75) Bumps [tap](https://github.com/tapjs/tapjs) from 16.3.10 to 20.0.3. - [Release notes](https://github.com/tapjs/tapjs/releases) - [Commits](https://github.com/tapjs/tapjs/compare/v16.3.10...tap@20.0.3) --- updated-dependencies: - dependency-name: tap dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 48017e8..b60bea4 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "snazzy": "^9.0.0", "standard": "^17.1.0", "supertest": "6.3.4", - "tap": "^18.7.2", + "tap": "^20.0.3", "tsd": "^0.31.0" }, "scripts": { From 5019e3e6f39c9d2a3026046a59f799dda55d399b Mon Sep 17 00:00:00 2001 From: KaKa <23028015+climba03003@users.noreply.github.com> Date: Tue, 9 Jul 2024 17:38:20 +0800 Subject: [PATCH 063/109] refactor!: new implementation (#77) --- README.md | 127 +- examples/simple.js | 7 +- index.js | 17 +- lib/SendStream.js | 905 ---------- lib/clearHeaders.js | 21 - lib/createHttpError.js | 23 - lib/send.js | 685 ++++++++ lib/setHeaders.js | 20 - package.json | 1 - test/SendStream-pipe.test.js | 1663 ------------------- test/constructor.test.js | 13 - test/{SendStream.test.js => send.1.test.js} | 79 +- test/send.2.test.js | 1126 +++++++++++++ test/utils.js | 6 +- types/index.d.ts | 100 +- types/index.test-d.ts | 53 +- 16 files changed, 1910 insertions(+), 2936 deletions(-) delete mode 100644 lib/SendStream.js delete mode 100644 lib/clearHeaders.js delete mode 100644 lib/createHttpError.js create mode 100644 lib/send.js delete mode 100644 lib/setHeaders.js delete mode 100644 test/SendStream-pipe.test.js delete mode 100644 test/constructor.test.js rename test/{SendStream.test.js => send.1.test.js} (86%) create mode 100644 test/send.2.test.js diff --git a/README.md b/README.md index 9fb6d03..acf1d51 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,9 @@ var send = require('@fastify/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 @@ -122,22 +122,6 @@ 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 - -The `SendStream` is an event emitter and will emit the following events: - - - `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 - -#### .pipe - -The `pipe` method is used to pipe the response into the Node.js HTTP response -object, typically `send(req, path, options).pipe(res)`. - ### .mime The `mime` export is the global instance of the @@ -147,12 +131,6 @@ This is used to configure the MIME types that are associated with file extension 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 - -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 @@ -185,9 +163,10 @@ This simple example will send a specific file to all requests. var http = require('node:http') var send = require('send') -var server = http.createServer(function onRequest (req, res) { - send(req, '/path/to/index.html') - .pipe(res) +var 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) @@ -204,9 +183,10 @@ var http = require('node:http') var parseUrl = require('parseurl') var send = require('@fastify/send') -var server = http.createServer(function onRequest (req, res) { - send(req, parseUrl(req).pathname, { root: '/www/public' }) - .pipe(res) +var 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) @@ -228,88 +208,9 @@ send.mime.define({ }) var server = http.createServer(function onRequest (req, res) { - send(req, parseUrl(req).pathname, { root: '/www/public' }) - .pipe(res) -}) - -server.listen(3000) -``` - -### Custom directory index view - -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('node:http') -var fs = require('node:fs') -var parseUrl = require('parseurl') -var send = require('@fastify/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) -}) - -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('node:http') -var parseUrl = require('parseurl') -var send = require('@fastify/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 + '/') - } - - // 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 } = await send(req, parseUrl(req).pathname, { root: '/www/public' }) + res.writeHead(statusCode, headers) + stream.pipe(res) }) server.listen(3000) diff --git a/examples/simple.js b/examples/simple.js index 2c79119..5297b75 100644 --- a/examples/simple.js +++ b/examples/simple.js @@ -6,9 +6,10 @@ const path = require('node:path') const indexPath = path.join(__dirname, 'index.html') -const server = http.createServer(function onRequest (req, res) { - send(req, indexPath) - .pipe(res) +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 b7b27f1..c1c2b01 100644 --- a/index.js +++ b/index.js @@ -13,21 +13,7 @@ */ const isUtf8MimeType = require('./lib/isUtf8MimeType').isUtf8MimeType const mime = require('mime') -const SendStream = require('./lib/SendStream') - -/** - * 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) -} +const send = require('./lib/send').send /** * Module exports. @@ -37,7 +23,6 @@ function send (req, path, options) { module.exports = send module.exports.default = send module.exports.send = send -module.exports.SendStream = SendStream module.exports.isUtf8MimeType = isUtf8MimeType module.exports.mime = mime diff --git a/lib/SendStream.js b/lib/SendStream.js deleted file mode 100644 index a88d95b..0000000 --- a/lib/SendStream.js +++ /dev/null @@ -1,905 +0,0 @@ -/*! - * send - * Copyright(c) 2012 TJ Holowaychuk - * Copyright(c) 2014-2022 Douglas Christopher Wilson - * MIT Licensed - */ - -'use strict' - -const fs = require('node:fs') -const path = require('node:path') -const Stream = require('node:stream') -const util = require('node:util') -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 { clearHeaders } = require('./clearHeaders') -const { collapseLeadingSlashes } = require('./collapseLeadingSlashes') -const { containsDotFile } = require('./containsDotFile') -const { contentRange } = require('./contentRange') -const { createHtmlDocument } = require('./createHtmlDocument') -const { createHttpError } = require('./createHttpError') -const { isUtf8MimeType } = require('./isUtf8MimeType') -const { normalizeList } = require('./normalizeList') -const { parseBytesRange } = require('./parseBytesRange') -const { parseTokenList } = require('./parseTokenList') -const { setHeaders } = require('./setHeaders') - -/** - * Path function references. - * @private - */ - -const extname = path.extname -const join = path.join -const normalize = path.normalize -const resolve = path.resolve -const sep = path.sep - -/** - * 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' -] - -/** - * Initialize a `SendStream` with the given `path`. - * - * @param {Request} req - * @param {String} path - * @param {object} [options] - * @private - */ - -function SendStream (req, path, options) { - if (!new.target) { - return new SendStream(req, path, options) - } - Stream.call(this) - - const 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 - ? validDotFilesOptions.indexOf(opts.dotfiles) - : 1 // 'ignore' - - if (this._dotfiles === -1) { - 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.parse(this._maxage) - : Number(this._maxage) - // eslint-disable-next-line no-self-compare - this._maxage = this._maxage === this._maxage // fast path of isNaN(number) - ? 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) - -/** - * Set root `path`. - * - * @param {String} path - * @return {SendStream} - * @api private - */ - -SendStream.prototype.root = function root (path) { - this._root = resolve(String(path)) - debug('root %s', this._root) - return this -} - -/** - * Emit error with `status`. - * - * @memberof SendStream - * @param {number} status - * @param {Error} [err] - * @this {Stream} - * @private - */ - -SendStream.prototype.error = function error (status, err) { - // emit if listeners instead of responding - if (this.listenerCount('error') > 0) { - return this.emit('error', createHttpError(status, err)) - } - - const res = this.res - - // clear existing headers - clearHeaders(res) - - // add error headers - if (err && err.headers) { - setHeaders(res, err.headers) - } - - const doc = ERROR_RESPONSES[status] - - // send basic response - res.statusCode = status - res.setHeader('Content-Type', 'text/html; charset=UTF-8') - res.setHeader('Content-Length', doc[1]) - res.setHeader('Content-Security-Policy', "default-src 'none'") - res.setHeader('X-Content-Type-Options', 'nosniff') - res.end(doc[0]) -} - -/** - * 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'] -} - -SendStream.prototype.isNotModifiedFailure = function isNotModifiedFailure () { - const req = this.req - const res = this.res - - // 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 req.headers && - req.headers['cache-control'].indexOf('no-cache') !== -1 - ) { - return false - } - - // if-none-match - if ('if-none-match' in req.headers) { - const ifNoneMatch = req.headers['if-none-match'] - - if (ifNoneMatch === '*') { - return true - } - - const etag = res.getHeader('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 req.headers) { - const ifModifiedSince = req.headers['if-modified-since'] - const lastModified = res.getHeader('last-modified') - - if (!lastModified || (Date.parse(lastModified) <= Date.parse(ifModifiedSince))) { - return true - } - } - - return false -} - -/** - * Check if the request preconditions failed. - * - * @return {boolean} - * @private - */ - -SendStream.prototype.isPreconditionFailure = function isPreconditionFailure () { - const req = this.req - const res = this.res - - // if-match - const ifMatch = req.headers['if-match'] - if (ifMatch) { - const etag = res.getHeader('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 req.headers) { - const ifUnmodifiedSince = req.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(res.getHeader('Last-Modified')) - if ( - // eslint-disable-next-line no-self-compare - lastModified !== lastModified ||// fast path of isNaN(number) - lastModified > unmodifiedSince - ) { - return true - } - } - } - - return false -} - -/** - * Strip various content header fields for a change in entity. - * - * @private - */ - -SendStream.prototype.removeContentHeaderFields = function removeContentHeaderFields () { - const 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 () { - const 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 () { - const 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 () { - const statusCode = this.res.statusCode - return (statusCode >= 200 && statusCode < 300) || - statusCode === 304 -} - -/** - * Handle stat() error. - * - * @param {Error} error - * @private - */ - -SendStream.prototype.onStatError = function onStatError (error) { - // POSIX throws ENAMETOOLONG and ENOTDIR, Windows only ENOENT - switch (error.code) { - /* c8 ignore start */ - case 'ENAMETOOLONG': - case 'ENOTDIR': - case 'ENOENT': - this.error(404, error) - break - /* c8 ignore stop */ - default: - this.error(500, error) - break - } -} - -/** - * Check if the range is fresh. - * - * @return {Boolean} - * @api private - */ - -SendStream.prototype.isRangeFresh = function isRangeFresh () { - if (!('if-range' in this.req.headers)) { - return true - } - - const ifRange = this.req.headers['if-range'] - - // if-range as etag - if (ifRange.indexOf('"') !== -1) { - const etag = this.res.getHeader('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(this.res.getHeader('Last-Modified')) - - return ( - // eslint-disable-next-line no-self-compare - lastModified !== lastModified || // fast path of isNaN(number) - lastModified <= ifRangeTimestamp - ) -} - -/** - * Redirect to path. - * - * @param {string} path - * @private - */ - -SendStream.prototype.redirect = function redirect (path) { - const res = this.res - - if (this.listenerCount('directory') > 0) { - this.emit('directory', res, path) - return - } - - if (this.hasTrailingSlash()) { - this.error(403) - return - } - - const loc = encodeURI(collapseLeadingSlashes(this.path + '/')) - const doc = createHtmlDocument('Redirecting', 'Redirecting to ' + - escapeHtml(loc) + '') - - // redirect - res.statusCode = 301 - res.setHeader('Content-Type', 'text/html; charset=UTF-8') - res.setHeader('Content-Length', doc[1]) - res.setHeader('Content-Security-Policy', "default-src 'none'") - res.setHeader('X-Content-Type-Options', 'nosniff') - res.setHeader('Location', loc) - res.end(doc[0]) -} - -/** - * Pipe to `res. - * - * @param {Stream} res - * @return {Stream} res - * @api public - */ - -SendStream.prototype.pipe = function pipe (res) { - // root path - const root = this._root - - // references - this.res = res - - // decode the path - let path = decode(this.path) - if (path === null) { - this.error(400) - return res - } - - // null byte(s) - if (~path.indexOf('\0')) { - this.error(400) - return res - } - - 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) - 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 ( - ( - debug.enabled || // if debugging is enabled, then check for all cases to log allow case - this._dotfiles !== 0 // if debugging is not enabled, then only check if 'deny' or 'ignore' is set - ) && - containsDotFile(parts) - ) { - switch (this._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) - this.error(403) - return res - case 1: // 'ignore' - default: - debug('ignore dotfile "%s"', path) - 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) { - let len = stat.size - const options = this.options - const opts = {} - const res = this.res - const req = this.req - let 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.isNotModifiedFailure()) { - this.notModified() - return - } - } - - // 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 (this._acceptRanges) { - const rangeHeader = req.headers.range - - if ( - rangeHeader !== undefined && - BYTES_RANGE_REGEXP.test(rangeHeader) - ) { - // If-Range support - if (this.isRangeFresh()) { - // parse - const ranges = parseBytesRange(len, rangeHeader) - - // unsatisfiable - if (ranges.length === 0) { - 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) - } else if (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 - } - } else { - debug('range stale') - } - } - } - - // clone options - for (const 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) { - let i = 0 - const self = this - - debug('stat "%s"', path) - fs.stat(path, function onstat (err, stat) { - if (err && err.code === 'ENOENT' && !extname(path) && path[path.length - 1] !== sep) { - // not found, check extensions - return next(err) - } - if (err) return self.onStatError(err) - if (stat.isDirectory()) return self.redirect(path) - 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) - } - - const 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) { - let i = -1 - const self = this - - function next (err) { - if (++i >= self._index.length) { - if (err) return self.onStatError(err) - return self.error(404) - } - - const 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) { - const self = this - const res = this.res - - // pipe - const stream = fs.createReadStream(path, options) - this.emit('stream', stream) - stream.pipe(res) - - let destroyed = false - - // destroy piped stream - function destroy () { - if (destroyed) { - return - } - destroyed = true - stream.destroy() - } - - res.once('finish', destroy) - - // error handling - stream.on('error', function onerror (err) { - // clean up stream early - destroy() - - // 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) { - const res = this.res - - if (res.getHeader('Content-Type')) return - - const type = mime.getType(path) || mime.default_type - - if (!type) { - debug('no content-type') - return - } - - debug('content-type %s', type) - if (isUtf8MimeType(type)) { - res.setHeader('Content-Type', type + '; charset=UTF-8') - } else { - 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) { - const 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')) { - let 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')) { - const modified = stat.mtime.toUTCString() - debug('modified %s', modified) - res.setHeader('Last-Modified', modified) - } - - if (this._etag && !res.getHeader('ETag')) { - const etag = 'W/"' + stat.size.toString(16) + '-' + stat.mtime.getTime().toString(16) + '"' - debug('etag %s', etag) - res.setHeader('ETag', etag) - } -} - -/** - * Module exports. - * @public - */ - -module.exports = SendStream diff --git a/lib/clearHeaders.js b/lib/clearHeaders.js deleted file mode 100644 index 6d9ac92..0000000 --- a/lib/clearHeaders.js +++ /dev/null @@ -1,21 +0,0 @@ -/*! - * send - * Copyright(c) 2012 TJ Holowaychuk - * Copyright(c) 2014-2022 Douglas Christopher Wilson - * MIT Licensed - */ -'use strict' -/** - * Clear all headers from a response. - * - * @param {object} res - * @private - */ -function clearHeaders (res) { - const headers = res.getHeaderNames() - - for (let i = 0; i < headers.length; i++) { - res.removeHeader(headers[i]) - } -} -exports.clearHeaders = clearHeaders diff --git a/lib/createHttpError.js b/lib/createHttpError.js deleted file mode 100644 index ba7bcca..0000000 --- a/lib/createHttpError.js +++ /dev/null @@ -1,23 +0,0 @@ -'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/send.js b/lib/send.js new file mode 100644 index 0000000..f98d375 --- /dev/null +++ b/lib/send.js @@ -0,0 +1,685 @@ +'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') + +/** + * 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 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 root = options.root + ? resolve(options.root) + : null + + return { + acceptRanges, + cacheControl, + etag, + dotfiles, + extensions, + immutable, + index, + lastModified, + maxage, + root, + 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]) + } +} + +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) { + 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('') + } +} + +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 + 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) + } + } + + // 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 + 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('') } + } + + const stream = fs.createReadStream(path, { + start: offset, + end: Math.max(offset, offset + len - 1) + }) + + return { statusCode, headers, stream } +} + +function sendRedirect (path) { + if (hasTrailingSlash(path)) { + return sendError(403) + } + + const loc = encodeURI(collapseLeadingSlashes(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]) + } +} + +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(options.path) + 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/lib/setHeaders.js b/lib/setHeaders.js deleted file mode 100644 index 4ddd6dc..0000000 --- a/lib/setHeaders.js +++ /dev/null @@ -1,20 +0,0 @@ -'use strict' - -/** - * Set an object of headers on a response. - * - * @param {object} res - * @param {object} headers - * @private - */ - -function setHeaders (res, headers) { - const keys = Object.keys(headers) - - for (let i = 0; i < keys.length; i++) { - const key = keys[i] - res.setHeader(key, headers[key]) - } -} - -module.exports.setHeaders = setHeaders diff --git a/package.json b/package.json index b60bea4..55146ba 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "dependencies": { "escape-html": "~1.0.3", "fast-decode-uri-component": "^1.0.1", - "http-errors": "2.0.0", "mime": "^3", "@lukeed/ms": "^2.0.2" }, diff --git a/test/SendStream-pipe.test.js b/test/SendStream-pipe.test.js deleted file mode 100644 index 0c58fa9..0000000 --- a/test/SendStream-pipe.test.js +++ /dev/null @@ -1,1663 +0,0 @@ -'use strict' - -const { test } = require('tap') -const after = require('after') -const http = require('node:http') -const path = require('node:path') -const request = require('supertest') -const SendStream = require('../lib/SendStream') -const os = require('node:os') -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).pipe(res)', function (t) { - t.plan(29) - - t.test('should stream the file contents', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect('Content-Length', '4') - .expect(200, 'tobi', err => t.error(err)) - }) - - t.test('should stream a zero-length file', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/empty.txt') - .expect('Content-Length', '0') - .expect(200, '', err => t.error(err)) - }) - - t.test('should decode the given path as a URI', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/some%20thing.txt') - .expect(200, 'hey', err => t.error(err)) - }) - - t.test('should serve files with dots in name', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/do..ts.txt') - .expect(200, '...', err => t.error(err)) - }) - - t.test('should treat a malformed URI as a bad request', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/some%99thing.txt') - .expect(400, 'Bad Request', err => t.error(err)) - }) - - t.test('should 400 on NULL bytes', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/some%00thing.txt') - .expect(400, 'Bad Request', err => t.error(err)) - }) - - t.test('should treat an ENAMETOOLONG as a 404', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - const path = Array(100).join('foobar') - request(app) - .get('/' + path) - .expect(404, err => t.error(err)) - }) - - t.test('should handle headers already sent error', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - res.write('0') - new SendStream(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.', err => t.error(err)) - }) - - t.test('should support HEAD', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .head('/name.txt') - .expect(200) - .expect('Content-Length', '4') - .expect(shouldNotHaveBody(t)) - .end(err => t.error(err)) - }) - - t.test('should add an ETag header field', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect('etag', /^W\/"[^"]+"$/) - .end(err => t.error(err)) - }) - - t.test('should add a Date header field', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect('date', dateRegExp, err => t.error(err)) - }) - - t.test('should add a Last-Modified header field', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect('last-modified', dateRegExp, err => t.error(err)) - }) - - t.test('should add a Accept-Ranges header field', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect('Accept-Ranges', 'bytes', err => t.error(err)) - }) - - t.test('should 404 if the file does not exist', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/meow') - .expect(404, 'Not Found', err => t.error(err)) - }) - - t.test('should emit ENOENT if the file does not exist', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - new SendStream(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', err => t.error(err)) - }) - - t.test('should emit ENAMETOOLONG if the filename is too long', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures }) - .on('error', function (err) { res.end(err.statusCode + ' ' + err.code) }) - .pipe(res) - }) - - const longFilename = new Array(512).fill('a').join('') - - request(app) - .get('/' + longFilename) - .expect(200, os.platform() === 'win32' ? '404 ENOENT' : '404 ENAMETOOLONG', err => t.error(err)) - }) - - t.test('should emit ENOTDIR if the requested resource is not a directory', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures }) - .on('error', function (err) { res.end(err.statusCode + ' ' + err.code) }) - .pipe(res) - }) - - request(app) - .get('/nums.txt/invalid') - .expect(200, os.platform() === 'win32' ? '404 ENOENT' : '404 ENOTDIR', err => t.error(err)) - }) - - t.test('should not override content-type', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - res.setHeader('Content-Type', 'application/x-custom') - new SendStream(req, req.url, { root: fixtures }).pipe(res) - }) - request(app) - .get('/name.txt') - .expect('Content-Type', 'application/x-custom', err => t.error(err)) - }) - - t.test('should set Content-Type via mime map', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect('Content-Type', 'text/plain; charset=UTF-8') - .expect(200, function (err) { - t.error(err) - request(app) - .get('/tobi.html') - .expect('Content-Type', 'text/html; charset=UTF-8') - .expect(200, err => t.error(err)) - }) - }) - - t.test('should 404 if file disappears after stat, before open', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: 'test/fixtures' }) - .on('file', function () { - // simulate file ENOENT after on open, after stat - const 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, err => t.error(err)) - }) - - t.test('should 500 on file stream error', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - new SendStream(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, err => t.error(err)) - }) - - t.test('"headers" event', function (t) { - t.plan(7) - t.test('should fire when sending file', function (t) { - t.plan(1) - const cb = after(2, err => t.error(err)) - const server = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures }) - .on('headers', function () { cb() }) - .pipe(res) - }) - - request(server) - .get('/name.txt') - .expect(200, 'tobi', cb) - }) - - t.test('should not fire on 404', function (t) { - t.plan(1) - const cb = after(1, err => t.error(err)) - const server = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures }) - .on('headers', function () { cb() }) - .pipe(res) - }) - - request(server) - .get('/bogus') - .expect(404, cb) - }) - - t.test('should fire on index', function (t) { - t.plan(1) - const cb = after(2, err => t.error(err)) - const server = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures }) - .on('headers', function () { cb() }) - .pipe(res) - }) - - request(server) - .get('/pets/') - .expect(200, /tobi/, cb) - }) - - t.test('should not fire on redirect', function (t) { - t.plan(1) - const cb = after(1, err => t.error(err)) - const server = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures }) - .on('headers', function () { cb() }) - .pipe(res) - }) - - request(server) - .get('/pets') - .expect(301, cb) - }) - - t.test('should provide path', function (t) { - t.plan(3) - const cb = after(2, err => t.error(err)) - const server = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures }) - .on('headers', onHeaders) - .pipe(res) - }) - - function onHeaders (res, filePath) { - t.ok(filePath) - t.strictSame(path.normalize(filePath), path.normalize(path.join(fixtures, 'name.txt'))) - cb() - } - - request(server) - .get('/name.txt') - .expect(200, 'tobi', cb) - }) - - t.test('should provide stat', function (t) { - t.plan(4) - const cb = after(2, err => t.error(err)) - const server = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures }) - .on('headers', onHeaders) - .pipe(res) - }) - - function onHeaders (res, path, stat) { - t.ok(stat) - t.ok('ctime' in stat) - t.ok('mtime' in stat) - cb() - } - - request(server) - .get('/name.txt') - .expect(200, 'tobi', cb) - }) - - t.test('should allow altering headers', function (t) { - t.plan(1) - const server = http.createServer(function (req, res) { - new SendStream(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(err => t.error(err)) - }) - }) - - t.test('when "directory" listeners are present', function (t) { - t.plan(2) - - t.test('should be called when sending directory', function (t) { - t.plan(1) - const server = http.createServer(function (req, res) { - new SendStream(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', err => t.error(err)) - }) - - t.test('should be called with path', function (t) { - t.plan(1) - const server = http.createServer(function (req, res) { - new SendStream(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')), err => t.error(err)) - }) - }) - - t.test('when no "directory" listeners are present', function (t) { - t.plan(5) - - t.test('should redirect directories to trailing slash', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) - .get('/pets') - .expect('Location', '/pets/') - .expect(301, err => t.error(err)) - }) - - t.test('should respond with an HTML redirect', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) - .get('/pets') - .expect('Location', '/pets/') - .expect('Content-Type', /html/) - .expect(301, />Redirecting to \/pets\/<\/a> t.error(err)) - }) - - t.test('should respond with default Content-Security-Policy', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) - .get('/pets') - .expect('Location', '/pets/') - .expect('Content-Security-Policy', "default-src 'none'") - .expect(301, err => t.error(err)) - }) - - t.test('should not redirect to protocol-relative locations', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) - .get('//pets') - .expect('Location', '/pets/') - .expect(301, err => t.error(err)) - }) - - t.test('should respond with an HTML redirect', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - new SendStream(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\/<\/a> t.error(err)) - }) - }) - - t.test('when no "error" listeners are present', function (t) { - t.plan(3) - - t.test('should respond to errors directly', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) - .get('/foobar') - .expect(404, />Not Found t.error(err)) - }) - - t.test('should respond with default Content-Security-Policy', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) - .get('/foobar') - .expect('Content-Security-Policy', "default-src 'none'") - .expect(404, err => t.error(err)) - }) - - t.test('should remove all previously-set headers', function (t) { - t.plan(2) - - const server = createServer({ root: fixtures }, function (req, res) { - res.setHeader('X-Foo', 'bar') - }) - - request(server) - .get('/foobar') - .expect(shouldNotHaveHeader('X-Foo', t)) - .expect(404, err => t.error(err)) - }) - }) - - t.test('with conditional-GET', function (t) { - t.plan(7) - - t.test('should remove Content headers with 304', function (t) { - t.plan(5) - - 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') - }) - - request(server) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(server) - .get('/name.txt') - .set('If-None-Match', res.headers.etag) - .expect(shouldNotHaveHeader('Content-Language', t)) - .expect(shouldNotHaveHeader('Content-Length', t)) - .expect(shouldNotHaveHeader('Content-Type', t)) - .expect('Content-Location', 'http://localhost/name.txt') - .expect('Contents', 'foo') - .expect(304, err => t.error(err)) - }) - }) - - t.test('should remove Content headers with 304 /2', function (t) { - t.plan(5) - - 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') - res.statusCode = 304 - }) - - request(server) - .get('/name.txt') - .expect(304, function (err, res) { - t.error(err) - request(server) - .get('/name.txt') - .set('If-None-Match', res.headers.etag) - .expect(shouldNotHaveHeader('Content-Language', t)) - .expect(shouldNotHaveHeader('Content-Length', t)) - .expect(shouldNotHaveHeader('Content-Type', t)) - .expect('Content-Location', 'http://localhost/name.txt') - .expect('Contents', 'foo') - .expect(304, err => t.error(err)) - }) - }) - - t.test('should not remove all Content-* headers', function (t) { - t.plan(4) - - const 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) { - t.error(err) - request(server) - .get('/name.txt') - .set('If-None-Match', res.headers.etag) - .expect(shouldNotHaveHeader('Content-Length', t)) - .expect(shouldNotHaveHeader('Content-Type', t)) - .expect('Content-Location', 'http://localhost/name.txt') - .expect('Content-Security-Policy', 'default-src \'self\'') - .expect(304, err => t.error(err)) - }) - }) - - t.test('where "If-Match" is set', function (t) { - t.plan(4) - - t.test('should respond with 200 when "*"', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .set('If-Match', '*') - .expect(200, err => t.error(err)) - }) - - t.test('should respond with 412 when ETag unmatched', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .set('If-Match', ' "foo",, "bar" ,') - .expect(412, err => t.error(err)) - }) - - t.test('should respond with 200 when ETag matched /1', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-Match', '"foo", "bar", ' + res.headers.etag) - .expect(200, err => t.error(err)) - }) - }) - - t.test('should respond with 200 when ETag matched /2', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-Match', '"foo", ' + res.headers.etag + ', "bar"') - .expect(200, err => t.error(err)) - }) - }) - }) - - t.test('where "If-Modified-Since" is set', function (t) { - t.plan(3) - - t.test('should respond with 304 when unmodified', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-Modified-Since', res.headers['last-modified']) - .expect(304, err => t.error(err)) - }) - }) - - t.test('should respond with 200 when modified', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - const lmod = new Date(res.headers['last-modified']) - const date = new Date(lmod - 60000) - request(app) - .get('/name.txt') - .set('If-Modified-Since', date.toUTCString()) - .expect(200, 'tobi', err => t.error(err)) - }) - }) - - t.test('should respond with 200 when modified', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-Modified-Since', res.headers['last-modified']) - .set('cache-control', 'no-cache') - .expect(200, 'tobi', err => t.error(err)) - }) - }) - }) - - t.test('where "If-None-Match" is set', function (t) { - t.plan(6) - - t.test('should respond with 304 when ETag matched', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-None-Match', res.headers.etag) - .expect(304, err => t.error(err)) - }) - }) - - t.test('should respond with 200 when ETag unmatched', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-None-Match', '"123"') - .expect(200, 'tobi', err => t.error(err)) - }) - }) - - t.test('should respond with 200 when ETag is not generated', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { etag: false, root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-None-Match', '"123"') - .expect(200, 'tobi', err => t.error(err)) - }) - }) - - t.test('should respond with 306 Not Modified when using wildcard * on existing file', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { etag: false, root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-None-Match', '*') - .expect(304, '', err => t.error(err)) - }) - }) - - t.test('should respond with 404 Not Found when using wildcard * on non-existing file', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { etag: false, root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/asdf.txt') - .set('If-None-Match', '*') - .expect(404, 'Not Found', err => t.error(err)) - }) - - t.test('should respond with 200 cache-control is set to no-cache', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-None-Match', res.headers.etag) - .set('cache-control', 'no-cache') - .expect(200, 'tobi', err => t.error(err)) - }) - }) - }) - - t.test('where "If-Unmodified-Since" is set', function (t) { - t.plan(3) - - t.test('should respond with 200 when unmodified', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-Unmodified-Since', res.headers['last-modified']) - .expect(200, err => t.error(err)) - }) - }) - - t.test('should respond with 412 when modified', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - const lmod = new Date(res.headers['last-modified']) - const date = new Date(lmod - 60000).toUTCString() - request(app) - .get('/name.txt') - .set('If-Unmodified-Since', date) - .expect(412, err => t.error(err)) - }) - }) - - t.test('should respond with 200 when invalid date', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .set('If-Unmodified-Since', 'foo') - .expect(200, err => t.error(err)) - }) - }) - }) - - t.test('with Range request', function (t) { - t.plan(13) - - t.test('should support byte ranges', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=0-4') - .expect(206, '12345', err => t.error(err)) - }) - - t.test('should ignore non-byte ranges', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'items=0-4') - .expect(200, '123456789', err => t.error(err)) - }) - - t.test('should be inclusive', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=0-0') - .expect(206, '1', err => t.error(err)) - }) - - t.test('should set Content-Range', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=2-5') - .expect('Content-Range', 'bytes 2-5/9') - .expect(206, err => t.error(err)) - }) - - t.test('should support -n', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=-3') - .expect(206, '789', err => t.error(err)) - }) - - t.test('should support n-', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=3-') - .expect(206, '456789', err => t.error(err)) - }) - - t.test('should respond with 206 "Partial Content"', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=0-4') - .expect(206, err => t.error(err)) - }) - - t.test('should set Content-Length to the # of octets transferred', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=2-3') - .expect('Content-Length', '2') - .expect(206, '34', err => t.error(err)) - }) - - t.test('when last-byte-pos of the range is greater the length', function (t) { - t.plan(2) - - t.test('is taken to be equal to one less than the length', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=2-50') - .expect('Content-Range', 'bytes 2-8/9') - .expect(206, err => t.error(err)) - }) - - t.test('should adapt the Content-Length accordingly', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=2-50') - .expect('Content-Length', '7') - .expect(206, err => t.error(err)) - }) - }) - - t.test('when the first- byte-pos of the range is greater length', function (t) { - t.plan(2) - - t.test('should respond with 416', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=9-50') - .expect('Content-Range', 'bytes */9') - .expect(416, err => t.error(err)) - }) - - t.test('should emit error 416 with content-range header', function (t) { - t.plan(1) - - const server = http.createServer(function (req, res) { - new SendStream(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, err => t.error(err)) - }) - }) - - t.test('when syntactically invalid', function (t) { - t.plan(1) - - t.test('should respond with 200 and the entire contents', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'asdf') - .expect(200, '123456789', err => t.error(err)) - }) - }) - - t.test('when multiple ranges', function (t) { - t.plan(2) - - t.test('should respond with 200 and the entire contents', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=1-1,3-') - .expect(shouldNotHaveHeader('Content-Range', t)) - .expect(200, '123456789', err => t.error(err)) - }) - - t.test('should respond with 206 is all ranges can be combined', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=1-2,3-5') - .expect('Content-Range', 'bytes 1-5/9') - .expect(206, '23456', err => t.error(err)) - }) - }) - - t.test('when if-range present', function (t) { - t.plan(5) - - t.test('should respond with parts when etag unchanged', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .expect(200, function (err, res) { - t.error(err) - const etag = res.headers.etag - - request(app) - .get('/nums.txt') - .set('If-Range', etag) - .set('Range', 'bytes=0-0') - .expect(206, '1', err => t.error(err)) - }) - }) - - t.test('should respond with 200 when etag changed', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .expect(200, function (err, res) { - t.error(err) - const etag = res.headers.etag.replace(/"(.)/, '"0$1') - - request(app) - .get('/nums.txt') - .set('If-Range', etag) - .set('Range', 'bytes=0-0') - .expect(200, '123456789', err => t.error(err)) - }) - }) - - t.test('should respond with parts when modified unchanged', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .expect(200, function (err, res) { - t.error(err) - const modified = res.headers['last-modified'] - - request(app) - .get('/nums.txt') - .set('If-Range', modified) - .set('Range', 'bytes=0-0') - .expect(206, '1', err => t.error(err)) - }) - }) - - t.test('should respond with 200 when modified changed', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .expect(200, function (err, res) { - t.error(err) - const 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', err => t.error(err)) - }) - }) - - t.test('should respond with 200 when invalid value', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('If-Range', 'foo') - .set('Range', 'bytes=0-0') - .expect(200, '123456789', err => t.error(err)) - }) - }) - }) - - t.test('when "options" is specified', function (t) { - t.plan(4) - - t.test('should support start/end', function (t) { - t.plan(1) - - request(createServer({ root: fixtures, start: 3, end: 5 })) - .get('/nums.txt') - .expect(200, '456', err => t.error(err)) - }) - - t.test('should adjust too large end', function (t) { - t.plan(1) - - request(createServer({ root: fixtures, start: 3, end: 90 })) - .get('/nums.txt') - .expect(200, '456789', err => t.error(err)) - }) - - t.test('should support start/end with Range request', function (t) { - t.plan(1) - - request(createServer({ root: fixtures, start: 0, end: 2 })) - .get('/nums.txt') - .set('Range', 'bytes=-2') - .expect(206, '23', err => t.error(err)) - }) - - t.test('should support start/end with unsatisfiable Range request', function (t) { - t.plan(1) - - request(createServer({ root: fixtures, start: 0, end: 2 })) - .get('/nums.txt') - .set('Range', 'bytes=5-9') - .expect('Content-Range', 'bytes */3') - .expect(416, err => t.error(err)) - }) - }) - - t.test('.root()', function (t) { - t.plan(1) - - t.test('should set root', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - new SendStream(req, req.url) - .root(fixtures) - .pipe(res) - }) - - request(app) - .get('/pets/../name.txt') - .expect(200, 'tobi', err => t.error(err)) - }) - }) -}) diff --git a/test/constructor.test.js b/test/constructor.test.js deleted file mode 100644 index fa7c5e2..0000000 --- a/test/constructor.test.js +++ /dev/null @@ -1,13 +0,0 @@ -'use strict' - -const { test } = require('tap') -const SendStream = require('../index').SendStream - -test('constructor', function (t) { - t.plan(1) - - t.test('SendStream without new returns SendStream instance', function (t) { - t.plan(1) - t.ok(SendStream({}, '/', {}) instanceof SendStream) - }) -}) diff --git a/test/SendStream.test.js b/test/send.1.test.js similarity index 86% rename from test/SendStream.test.js rename to test/send.1.test.js index fc5c1c1..672face 100644 --- a/test/SendStream.test.js +++ b/test/send.1.test.js @@ -5,14 +5,14 @@ const fs = require('node:fs') const http = require('node:http') const path = require('node:path') const request = require('supertest') -const SendStream = require('..').SendStream +const send = require('..').send const { shouldNotHaveHeader, createServer } = require('./utils') // test server const fixtures = path.join(__dirname, 'fixtures') -test('SendStream(file, options)', function (t) { +test('send(file, options)', function (t) { t.plan(10) t.test('acceptRanges', function (t) { @@ -184,14 +184,14 @@ test('SendStream(file, options)', function (t) { t.test('when "allow"', function (t) { t.plan(3) - t.test('should SendStream dotfile', function (t) { + t.test('should send dotfile', function (t) { t.plan(1) request(createServer({ dotfiles: 'allow', root: fixtures })) .get('/.hidden.txt') .expect(200, 'secret', err => t.error(err)) }) - t.test('should SendStream within dotfile directory', function (t) { + t.test('should send within dotfile directory', function (t) { t.plan(1) request(createServer({ dotfiles: 'allow', root: fixtures })) .get('/.mine/name.txt') @@ -265,7 +265,7 @@ test('SendStream(file, options)', function (t) { .expect(403, err => t.error(err)) }) - t.test('should SendStream files in root dotfile directory', function (t) { + t.test('should send files in root dotfile directory', function (t) { t.plan(1) request(createServer({ dotfiles: 'deny', root: path.join(fixtures, '.mine') })) .get('/name.txt') @@ -274,8 +274,10 @@ test('SendStream(file, options)', function (t) { t.test('should 403 for dotfile without root', function (t) { t.plan(1) - const server = http.createServer(function onRequest (req, res) { - new SendStream(req, fixtures + '/.mine' + req.url, { dotfiles: 'deny' }).pipe(res) + 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) }) request(server) @@ -335,7 +337,7 @@ test('SendStream(file, options)', function (t) { .expect(404, err => t.error(err)) }) - t.test('should SendStream files in root dotfile directory', function (t) { + t.test('should send files in root dotfile directory', function (t) { t.plan(1) request(createServer({ dotfiles: 'ignore', root: path.join(fixtures, '.mine') })) @@ -346,8 +348,10 @@ test('SendStream(file, options)', function (t) { t.test('should 404 for dotfile without root', function (t) { t.plan(1) - const server = http.createServer(function onRequest (req, res) { - new SendStream(req, fixtures + '/.mine' + req.url, { dotfiles: 'ignore' }).pipe(res) + 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) }) request(server) @@ -487,10 +491,11 @@ test('SendStream(file, options)', function (t) { t.test('should work without root', function (t) { t.plan(1) - const server = http.createServer(function (req, res) { + const server = http.createServer(async function (req, res) { const p = path.join(fixtures, 'pets').replace(/\\/g, '/') + '/' - new SendStream(req, p, { index: ['index.html'] }) - .pipe(res) + const { statusCode, headers, stream } = await send(req, p, { index: ['index.html'] }) + res.writeHead(statusCode, headers) + stream.pipe(res) }) request(server) @@ -515,9 +520,10 @@ test('SendStream(file, options)', function (t) { t.test('should work with trailing slash', function (t) { t.plan(1) - const app = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures + '/' }) - .pipe(res) + 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) }) request(app) @@ -528,9 +534,10 @@ test('SendStream(file, options)', function (t) { t.test('should work with empty path', function (t) { t.plan(1) - const app = http.createServer(function (req, res) { - new SendStream(req, '', { root: fixtures }) - .pipe(res) + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, '', { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) }) request(app) @@ -547,9 +554,10 @@ test('SendStream(file, options)', function (t) { t.test('should try as file with empty path', function (t) { t.plan(1) - const app = http.createServer(function (req, res) { - new SendStream(req, '', { root: path.join(fixtures, 'name.txt') }) - .pipe(res) + 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) }) request(app) @@ -561,20 +569,21 @@ test('SendStream(file, options)', function (t) { t.plan(1) request(createServer({ root: fixtures })) - .get('/pets/../../SendStream.js') + .get('/pets/../../send.js') .expect(403, err => t.error(err)) }) t.test('should allow .. in root', function (t) { t.plan(1) - const app = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures + '/../fixtures' }) - .pipe(res) + 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) }) request(app) - .get('/pets/../../SendStream.js') + .get('/pets/../../send.js') .expect(403, err => t.error(err)) }) @@ -601,22 +610,24 @@ test('SendStream(file, options)', function (t) { t.test('should consider .. malicious', function (t) { t.plan(1) - const app = http.createServer(function (req, res) { - new SendStream(req, fixtures + req.url) - .pipe(res) + 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) }) request(app) - .get('/../SendStream.js') + .get('/../send.js') .expect(403, err => t.error(err)) }) t.test('should still serve files with dots in name', function (t) { t.plan(1) - const app = http.createServer(function (req, res) { - new SendStream(req, fixtures + req.url) - .pipe(res) + 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) }) request(app) diff --git a/test/send.2.test.js b/test/send.2.test.js new file mode 100644 index 0000000..a8e20cd --- /dev/null +++ b/test/send.2.test.js @@ -0,0 +1,1126 @@ +'use strict' + +const { test } = require('tap') +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)', function (t) { + t.plan(22) + + t.test('should stream the file contents', 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) + }) + + request(app) + .get('/name.txt') + .expect('Content-Length', '4') + .expect(200, 'tobi', err => t.error(err)) + }) + + t.test('should stream a zero-length file', 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) + }) + + request(app) + .get('/empty.txt') + .expect('Content-Length', '0') + .expect(200, '', err => t.error(err)) + }) + + t.test('should decode the given path as a URI', 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) + }) + + request(app) + .get('/some%20thing.txt') + .expect(200, 'hey', err => t.error(err)) + }) + + t.test('should serve files with dots in name', 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) + }) + + request(app) + .get('/do..ts.txt') + .expect(200, '...', err => t.error(err)) + }) + + t.test('should treat a malformed URI as a bad request', 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) + }) + + request(app) + .get('/some%99thing.txt') + .expect(400, /Bad Request/, err => t.error(err)) + }) + + t.test('should 400 on NULL bytes', 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) + }) + + request(app) + .get('/some%00thing.txt') + .expect(400, /Bad Request/, err => t.error(err)) + }) + + t.test('should treat an ENAMETOOLONG as a 404', 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) + }) + + const path = Array(100).join('foobar') + request(app) + .get('/' + path) + .expect(404, err => t.error(err)) + }) + + t.test('should support HEAD', function (t) { + t.plan(2) + + 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) + }) + + request(app) + .head('/name.txt') + .expect(200) + .expect('Content-Length', '4') + .expect(shouldNotHaveBody(t)) + .end(err => t.error(err)) + }) + + t.test('should add an ETag header field', 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) + }) + + request(app) + .get('/name.txt') + .expect('etag', /^W\/"[^"]+"$/) + .end(err => t.error(err)) + }) + + t.test('should add a Date header field', 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) + }) + + request(app) + .get('/name.txt') + .expect('date', dateRegExp, err => t.error(err)) + }) + + t.test('should add a Last-Modified header field', 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) + }) + + request(app) + .get('/name.txt') + .expect('last-modified', dateRegExp, err => t.error(err)) + }) + + t.test('should add a Accept-Ranges header field', 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) + }) + + request(app) + .get('/name.txt') + .expect('Accept-Ranges', 'bytes', err => t.error(err)) + }) + + t.test('should 404 if the file does not exist', 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) + }) + + request(app) + .get('/meow') + .expect(404, /Not Found/, err => t.error(err)) + }) + + t.test('should 404 if the filename is too long', 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) + }) + + const longFilename = new Array(512).fill('a').join('') + + request(app) + .get('/' + longFilename) + .expect(404, /Not Found/, err => t.error(err)) + }) + + t.test('should 404 if the requested resource is not a directory', 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) + }) + + request(app) + .get('/nums.txt/invalid') + .expect(404, /Not Found/, err => t.error(err)) + }) + + t.test('should not override content-type', 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, + 'Content-Type': 'application/x-custom' + }) + stream.pipe(res) + }) + request(app) + .get('/name.txt') + .expect('Content-Type', 'application/x-custom', err => t.error(err)) + }) + + t.test('should set Content-Type via mime map', function (t) { + t.plan(2) + + 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) + }) + + request(app) + .get('/name.txt') + .expect('Content-Type', 'text/plain; charset=UTF-8') + .expect(200, function (err) { + t.error(err) + request(app) + .get('/tobi.html') + .expect('Content-Type', 'text/html; charset=UTF-8') + .expect(200, err => t.error(err)) + }) + }) + + t.test('send directory', function (t) { + t.plan(5) + + t.test('should redirect directories to trailing slash', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/pets') + .expect('Location', '/pets/') + .expect(301, err => t.error(err)) + }) + + t.test('should respond with an HTML redirect', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/pets') + .expect('Location', '/pets/') + .expect('Content-Type', /html/) + .expect(301, />Redirecting to \/pets\/<\/a> t.error(err)) + }) + + t.test('should respond with default Content-Security-Policy', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/pets') + .expect('Location', '/pets/') + .expect('Content-Security-Policy', "default-src 'none'") + .expect(301, err => t.error(err)) + }) + + t.test('should not redirect to protocol-relative locations', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('//pets') + .expect('Location', '/pets/') + .expect(301, err => t.error(err)) + }) + + t.test('should respond with an HTML redirect', function (t) { + t.plan(1) + + 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) + }) + + request(app) + .get('/snow') + .expect('Location', '/snow%20%E2%98%83/') + .expect('Content-Type', /html/) + .expect(301, />Redirecting to \/snow%20%E2%98%83\/<\/a> t.error(err)) + }) + }) + + t.test('send error', function (t) { + t.plan(2) + + t.test('should respond to errors directly', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/foobar') + .expect(404, />Not Found t.error(err)) + }) + + t.test('should respond with default Content-Security-Policy', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/foobar') + .expect('Content-Security-Policy', "default-src 'none'") + .expect(404, err => t.error(err)) + }) + }) + + t.test('with conditional-GET', function (t) { + t.plan(6) + + t.test('should remove Content headers with 304', function (t) { + t.plan(2) + + 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') + }) + + request(server) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(server) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .expect('Content-Location', 'http://localhost/name.txt') + .expect('Contents', 'foo') + .expect(304, err => t.error(err)) + }) + }) + + t.test('should not remove all Content-* headers', function (t) { + t.plan(2) + + const 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) { + t.error(err) + 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, err => t.error(err)) + }) + }) + + t.test('where "If-Match" is set', function (t) { + t.plan(4) + + t.test('should respond with 200 when "*"', 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) + }) + + request(app) + .get('/name.txt') + .set('If-Match', '*') + .expect(200, err => t.error(err)) + }) + + t.test('should respond with 412 when ETag unmatched', 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) + }) + + request(app) + .get('/name.txt') + .set('If-Match', ' "foo",, "bar" ,') + .expect(412, err => t.error(err)) + }) + + t.test('should respond with 200 when ETag matched /1', function (t) { + t.plan(2) + + 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) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-Match', '"foo", "bar", ' + res.headers.etag) + .expect(200, err => t.error(err)) + }) + }) + + t.test('should respond with 200 when ETag matched /2', function (t) { + t.plan(2) + + 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) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-Match', '"foo", ' + res.headers.etag + ', "bar"') + .expect(200, err => t.error(err)) + }) + }) + }) + + t.test('where "If-Modified-Since" is set', function (t) { + t.plan(3) + + t.test('should respond with 304 when unmodified', function (t) { + t.plan(2) + + 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) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-Modified-Since', res.headers['last-modified']) + .expect(304, err => t.error(err)) + }) + }) + + t.test('should respond with 200 when modified', function (t) { + t.plan(2) + + 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) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + const lmod = new Date(res.headers['last-modified']) + const date = new Date(lmod - 60000) + request(app) + .get('/name.txt') + .set('If-Modified-Since', date.toUTCString()) + .expect(200, 'tobi', err => t.error(err)) + }) + }) + + t.test('should respond with 200 when modified', function (t) { + t.plan(2) + + 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) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-Modified-Since', res.headers['last-modified']) + .set('cache-control', 'no-cache') + .expect(200, 'tobi', err => t.error(err)) + }) + }) + }) + + t.test('where "If-None-Match" is set', function (t) { + t.plan(6) + + t.test('should respond with 304 when ETag matched', function (t) { + t.plan(2) + + 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) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .expect(304, err => t.error(err)) + }) + }) + + t.test('should respond with 200 when ETag unmatched', function (t) { + t.plan(2) + + 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) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-None-Match', '"123"') + .expect(200, 'tobi', err => t.error(err)) + }) + }) + + t.test('should respond with 200 when ETag is not generated', function (t) { + t.plan(2) + + 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) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-None-Match', '"123"') + .expect(200, 'tobi', err => t.error(err)) + }) + }) + + t.test('should respond with 306 Not Modified when using wildcard * on existing file', function (t) { + t.plan(2) + + 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) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-None-Match', '*') + .expect(304, '', err => t.error(err)) + }) + }) + + t.test('should respond with 404 Not Found when using wildcard * on non-existing file', function (t) { + t.plan(1) + + 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) + }) + + request(app) + .get('/asdf.txt') + .set('If-None-Match', '*') + .expect(404, /Not Found/, err => t.error(err)) + }) + + t.test('should respond with 200 cache-control is set to no-cache', function (t) { + t.plan(2) + + 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) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .set('cache-control', 'no-cache') + .expect(200, 'tobi', err => t.error(err)) + }) + }) + }) + + t.test('where "If-Unmodified-Since" is set', function (t) { + t.plan(3) + + t.test('should respond with 200 when unmodified', function (t) { + t.plan(2) + + 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) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-Unmodified-Since', res.headers['last-modified']) + .expect(200, err => t.error(err)) + }) + }) + + t.test('should respond with 412 when modified', function (t) { + t.plan(2) + + 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) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + const lmod = new Date(res.headers['last-modified']) + const date = new Date(lmod - 60000).toUTCString() + request(app) + .get('/name.txt') + .set('If-Unmodified-Since', date) + .expect(412, err => t.error(err)) + }) + }) + + t.test('should respond with 200 when invalid date', 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) + }) + + request(app) + .get('/name.txt') + .set('If-Unmodified-Since', 'foo') + .expect(200, err => t.error(err)) + }) + }) + }) + + t.test('with Range request', function (t) { + t.plan(13) + + t.test('should support byte ranges', 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=0-4') + .expect(206, '12345', err => t.error(err)) + }) + + t.test('should ignore non-byte ranges', 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'items=0-4') + .expect(200, '123456789', err => t.error(err)) + }) + + t.test('should be inclusive', 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=0-0') + .expect(206, '1', err => t.error(err)) + }) + + t.test('should set Content-Range', 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-5') + .expect('Content-Range', 'bytes 2-5/9') + .expect(206, err => t.error(err)) + }) + + t.test('should support -n', 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=-3') + .expect(206, '789', err => t.error(err)) + }) + + t.test('should support n-', 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=3-') + .expect(206, '456789', err => t.error(err)) + }) + + t.test('should respond with 206 "Partial Content"', 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=0-4') + .expect(206, err => t.error(err)) + }) + + t.test('should set Content-Length to the # of octets transferred', 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-3') + .expect('Content-Length', '2') + .expect(206, '34', err => t.error(err)) + }) + + t.test('when last-byte-pos of the range is greater the length', function (t) { + t.plan(2) + + t.test('is taken to be equal to one less than the length', 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-50') + .expect('Content-Range', 'bytes 2-8/9') + .expect(206, err => t.error(err)) + }) + + t.test('should adapt the Content-Length accordingly', 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-50') + .expect('Content-Length', '7') + .expect(206, err => t.error(err)) + }) + }) + + t.test('when the first- byte-pos of the range is greater length', function (t) { + t.plan(2) + + t.test('should respond with 416', 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=9-50') + .expect('Content-Range', 'bytes */9') + .expect(416, err => t.error(err)) + }) + + t.test('should emit error 416 with content-range header', function (t) { + t.plan(1) + + 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) + }) + + request(server) + .get('/nums.txt') + .set('Range', 'bytes=9-50') + .expect('X-Content-Range', 'bytes */9') + .expect(416, err => t.error(err)) + }) + }) + + t.test('when syntactically invalid', function (t) { + t.plan(1) + + t.test('should respond with 200 and the entire contents', 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'asdf') + .expect(200, '123456789', err => t.error(err)) + }) + }) + + t.test('when multiple ranges', function (t) { + t.plan(2) + + t.test('should respond with 200 and the entire contents', function (t) { + t.plan(2) + + 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=1-1,3-') + .expect(shouldNotHaveHeader('Content-Range', t)) + .expect(200, '123456789', err => t.error(err)) + }) + + t.test('should respond with 206 is all ranges can be combined', 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) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=1-2,3-5') + .expect('Content-Range', 'bytes 1-5/9') + .expect(206, '23456', err => t.error(err)) + }) + }) + + t.test('when if-range present', function (t) { + t.plan(5) + + t.test('should respond with parts when etag unchanged', function (t) { + t.plan(2) + + 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) + }) + + request(app) + .get('/nums.txt') + .expect(200, function (err, res) { + t.error(err) + const etag = res.headers.etag + + request(app) + .get('/nums.txt') + .set('If-Range', etag) + .set('Range', 'bytes=0-0') + .expect(206, '1', err => t.error(err)) + }) + }) + + t.test('should respond with 200 when etag changed', function (t) { + t.plan(2) + + 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) + }) + + request(app) + .get('/nums.txt') + .expect(200, function (err, res) { + t.error(err) + const etag = res.headers.etag.replace(/"(.)/, '"0$1') + + request(app) + .get('/nums.txt') + .set('If-Range', etag) + .set('Range', 'bytes=0-0') + .expect(200, '123456789', err => t.error(err)) + }) + }) + + t.test('should respond with parts when modified unchanged', function (t) { + t.plan(2) + + 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) + }) + + request(app) + .get('/nums.txt') + .expect(200, function (err, res) { + t.error(err) + const modified = res.headers['last-modified'] + + request(app) + .get('/nums.txt') + .set('If-Range', modified) + .set('Range', 'bytes=0-0') + .expect(206, '1', err => t.error(err)) + }) + }) + + t.test('should respond with 200 when modified changed', function (t) { + t.plan(2) + + 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) + }) + + request(app) + .get('/nums.txt') + .expect(200, function (err, res) { + t.error(err) + const 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', err => t.error(err)) + }) + }) + + t.test('should respond with 200 when invalid value', 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) + }) + + request(app) + .get('/nums.txt') + .set('If-Range', 'foo') + .set('Range', 'bytes=0-0') + .expect(200, '123456789', err => t.error(err)) + }) + }) + }) + + t.test('when "options" is specified', function (t) { + t.plan(4) + + t.test('should support start/end', function (t) { + t.plan(1) + + request(createServer({ root: fixtures, start: 3, end: 5 })) + .get('/nums.txt') + .expect(200, '456', err => t.error(err)) + }) + + t.test('should adjust too large end', function (t) { + t.plan(1) + + request(createServer({ root: fixtures, start: 3, end: 90 })) + .get('/nums.txt') + .expect(200, '456789', err => t.error(err)) + }) + + t.test('should support start/end with Range request', function (t) { + t.plan(1) + + request(createServer({ root: fixtures, start: 0, end: 2 })) + .get('/nums.txt') + .set('Range', 'bytes=-2') + .expect(206, '23', err => t.error(err)) + }) + + t.test('should support start/end with unsatisfiable Range request', function (t) { + t.plan(1) + + request(createServer({ root: fixtures, start: 0, end: 2 })) + .get('/nums.txt') + .set('Range', 'bytes=5-9') + .expect('Content-Range', 'bytes */3') + .expect(416, err => t.error(err)) + }) + }) +}) diff --git a/test/utils.js b/test/utils.js index 218d38f..864d5f3 100644 --- a/test/utils.js +++ b/test/utils.js @@ -10,10 +10,12 @@ module.exports.shouldNotHaveHeader = function shouldNotHaveHeader (header, t) { } module.exports.createServer = function createServer (opts, fn) { - return http.createServer(function onRequest (req, res) { + return http.createServer(async function onRequest (req, res) { try { fn && fn(req, res) - send(req, req.url, opts).pipe(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)) diff --git a/types/index.d.ts b/types/index.d.ts index 5f35c59..7f93448 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -5,13 +5,12 @@ /// import * as stream from "stream"; -import * as fs from "fs"; /** * 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). */ -declare function send(req: stream.Readable, path: string, options?: send.SendOptions): send.SendStream; +declare function send(req: stream.Readable, path: string, options?: send.SendOptions): Promise; type Send = typeof send; @@ -112,99 +111,10 @@ declare namespace send { start?: number | undefined; } - export class SendStream extends stream.Stream { - - constructor(req: stream.Readable, path: string, options?: SendOptions); - - /** - * Emit error with `status`. - */ - error(status: number, error?: Error): void; - - /** - * Check if the pathname ends with "/". - */ - hasTrailingSlash(): boolean; - - /** - * Check if this is a conditional GET request. - */ - isConditionalGET(): boolean; - - /** - * Strip content-* header fields. - */ - removeContentHeaderFields(): void; - - /** - * Respond with 304 not modified. - */ - notModified(): void; - - /** - * Raise error that headers already sent. - */ - headersAlreadySent(): void; - - /** - * Check if the request is cacheable, aka responded with 2xx or 304 (see RFC 2616 section 14.2{5,6}). - */ - isCachable(): boolean; - - /** - * Handle stat() error. - */ - onStatError(error: Error): void; - - /** - * Check if the cache is fresh. - */ - isFresh(): boolean; - - /** - * Check if the range is fresh. - */ - isRangeFresh(): boolean; - - /** - * Redirect to path. - */ - redirect(path: string): void; - - /** - * Pipe to `res`. - */ - pipe(res: T): T; - - /** - * Transfer `path`. - */ - send(path: string, stat?: fs.Stats): void; - - /** - * Transfer file for `path`. - */ - sendFile(path: string): void; - - /** - * Transfer index for `path`. - */ - sendIndex(path: string): void; - - /** - * Transfer index for `path`. - */ - stream(path: string, options?: {}): void; - - /** - * Set content-type based on `path` if it hasn't been explicitly set. - */ - type(path: string): void; - - /** - * Set response header fields, most fields may be pre-defined. - */ - setHeader(path: string, stat: fs.Stats): void; + export interface SendResult { + statusCode: number + headers: Record + stream: stream.Readable } export const send: Send diff --git a/types/index.test-d.ts b/types/index.test-d.ts index 28698c5..2fc1d4c 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -1,6 +1,6 @@ -import { expectType } from 'tsd' -import send from '..' -import { SendStream } from '..'; +import { Readable } from 'stream'; +import { expectType } from 'tsd'; +import send, { SendResult } from '..'; send.mime.define({ 'application/x-my-type': ['x-mt', 'x-mtt'] @@ -10,27 +10,26 @@ expectType<(value: string) => boolean>(send.isUtf8MimeType) expectType(send.isUtf8MimeType('application/json')) const req: any = {} -const res: any = {} - -send(req, '/test.html', { - immutable: true, - maxAge: 0, - root: __dirname + '/wwwroot' -}).pipe(res); - -send(req, '/test.html', { maxAge: 0, root: __dirname + '/wwwroot' }) - .on('error', (err: any) => { - res.statusCode = err.status || 500; - res.end(err.message); - }) - .on('directory', () => { - res.statusCode = 301; - res.setHeader('Location', req.url + '/'); - res.end(`Redirecting to ${req.url}/`); - }) - .on('headers', (res: any, path: string, stat: any) => { - res.setHeader('Content-Disposition', 'attachment'); - }) - .pipe(res); - -const test = new SendStream(req, '/test.html', { maxAge: 0, root: __dirname + '/wwwroot' }); + +{ + const result = await send(req, '/test.html', { + immutable: true, + maxAge: 0, + root: __dirname + '/wwwroot' + }); + + expectType(result) + expectType(result.statusCode) + expectType>(result.headers) + expectType(result.stream) +} + +{ + const result = await send(req, '/test.html', { maxAge: 0, root: __dirname + '/wwwroot' }) + + expectType(result) + expectType(result.statusCode) + expectType>(result.headers) + expectType(result.stream) +} + From ab277e4ee7f1cd7a8ce5504809e4a1b8f8a6c269 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 9 Jul 2024 11:45:22 +0200 Subject: [PATCH 064/109] Bumped v3.0.0 Signed-off-by: Matteo Collina --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 55146ba..ce9fb38 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@fastify/send", "description": "Better streaming static file server with Range and conditional-GET support", - "version": "3.0.0-pre.fv5.1", + "version": "3.0.0", "author": "TJ Holowaychuk ", "contributors": [ "Douglas Christopher Wilson ", From d28f7d3dfbc188112cbe4201a6708f875bdeddef Mon Sep 17 00:00:00 2001 From: KaKa <23028015+climba03003@users.noreply.github.com> Date: Fri, 12 Jul 2024 17:52:50 +0800 Subject: [PATCH 065/109] feat: extends send result to provide ability of custom handling (#80) * feat: extends send result to provide ability of custom handling * feat: ensure error exists * fixup --- README.md | 69 +++++++++++++++++++++ lib/createHttpError.js | 23 +++++++ lib/send.js | 46 ++++++++++---- package.json | 5 +- test/send.3.test.js | 137 +++++++++++++++++++++++++++++++++++++++++ types/index.d.ts | 28 ++++++++- types/index.test-d.ts | 23 ++++++- 7 files changed, 316 insertions(+), 15 deletions(-) create mode 100644 lib/createHttpError.js create mode 100644 test/send.3.test.js diff --git a/README.md b/README.md index acf1d51..f3d090d 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,75 @@ var server = http.createServer(function onRequest (req, res) { server.listen(3000) ``` +### Custom directory index view + +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('node:http') +var fs = require('node:fs') +var parseUrl = require('parseurl') +var send = require('@fastify/send') + +// Transfer arbitrary files from within /www/example.com/public/* +// with a custom handler for directory listing +var 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) +``` + +### Serving from a root directory with custom error-handling + +```js +var http = require('node:http') +var parseUrl = require('parseurl') +var send = require('@fastify/send') + +var server = http.createServer(async function onRequest (req, res) { + // transfer arbitrary files from within + // /www/example.com/public/* + 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) +``` + ## License [MIT](LICENSE) diff --git a/lib/createHttpError.js b/lib/createHttpError.js new file mode 100644 index 0000000..ba7bcca --- /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/send.js b/lib/send.js index f98d375..226dd6f 100644 --- a/lib/send.js +++ b/lib/send.js @@ -18,6 +18,7 @@ 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. @@ -403,7 +404,10 @@ function sendError (statusCode, err) { return { statusCode, headers, - stream: Readable.from(doc[0]) + stream: Readable.from(doc[0]), + // metadata + type: 'error', + metadata: { error: createHttpError(statusCode, err) } } } @@ -427,7 +431,7 @@ function sendStatError (err) { * @api private */ -function sendNotModified (headers) { +function sendNotModified (headers, path, stat) { debug('not modified') delete headers['Content-Encoding'] @@ -439,7 +443,10 @@ function sendNotModified (headers) { return { statusCode: 304, headers, - stream: Readable.from('') + stream: Readable.from(''), + // metadata + type: 'file', + metadata: { path, stat } } } @@ -498,7 +505,7 @@ function sendFileDirectly (request, path, stat, options) { } if (isNotModifiedFailure(request, headers)) { - return sendNotModified(headers) + return sendNotModified(headers, path, stat) } } @@ -556,7 +563,14 @@ function sendFileDirectly (request, path, stat, options) { // HEAD support if (request.method === 'HEAD') { - return { statusCode, headers, stream: Readable.from('') } + return { + statusCode, + headers, + stream: Readable.from(''), + // metadata + type: 'file', + metadata: { path, stat } + } } const stream = fs.createReadStream(path, { @@ -564,15 +578,22 @@ function sendFileDirectly (request, path, stat, options) { end: Math.max(offset, offset + len - 1) }) - return { statusCode, headers, stream } + return { + statusCode, + headers, + stream, + // metadata + type: 'file', + metadata: { path, stat } + } } -function sendRedirect (path) { - if (hasTrailingSlash(path)) { +function sendRedirect (path, options) { + if (hasTrailingSlash(options.path)) { return sendError(403) } - const loc = encodeURI(collapseLeadingSlashes(path + '/')) + const loc = encodeURI(collapseLeadingSlashes(options.path + '/')) const doc = createHtmlDocument('Redirecting', 'Redirecting to ' + escapeHtml(loc) + '') @@ -586,7 +607,10 @@ function sendRedirect (path) { return { statusCode: 301, headers, - stream: Readable.from(doc[0]) + stream: Readable.from(doc[0]), + // metadata + type: 'directory', + metadata: { requestPath: options.path, path } } } @@ -636,7 +660,7 @@ async function sendFile (request, path, options) { return sendError(404) } if (error) return sendStatError(error) - if (stat.isDirectory()) return sendRedirect(options.path) + if (stat.isDirectory()) return sendRedirect(path, options) return sendFileDirectly(request, path, stat, options) } diff --git a/package.json b/package.json index ce9fb38..4e7e356 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,11 @@ "server" ], "dependencies": { + "@lukeed/ms": "^2.0.2", "escape-html": "~1.0.3", "fast-decode-uri-component": "^1.0.1", - "mime": "^3", - "@lukeed/ms": "^2.0.2" + "http-errors": "^2.0.0", + "mime": "^3" }, "devDependencies": { "@fastify/pre-commit": "^2.1.0", diff --git a/test/send.3.test.js b/test/send.3.test.js new file mode 100644 index 0000000..8fa0332 --- /dev/null +++ b/test/send.3.test.js @@ -0,0 +1,137 @@ +'use strict' + +const { test } = require('tap') +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)', function (t) { + t.plan(5) + + t.test('file type', function (t) { + t.plan(6) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url, { root: fixtures }) + t.equal(type, 'file') + t.ok(metadata.path) + t.ok(metadata.stat) + t.notOk(metadata.error) + t.notOk(metadata.requestPath) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .expect('Content-Length', '4') + .expect(200, 'tobi', err => t.error(err)) + }) + + t.test('directory type', function (t) { + t.plan(6) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url, { root: fixtures }) + t.equal(type, 'directory') + t.ok(metadata.path) + t.notOk(metadata.stat) + t.notOk(metadata.error) + t.ok(metadata.requestPath) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/pets') + .expect('Location', '/pets/') + .expect(301, err => t.error(err)) + }) + + t.test('error type', function (t) { + t.plan(6) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url, { root: fixtures }) + t.equal(type, 'error') + t.notOk(metadata.path) + t.notOk(metadata.stat) + t.ok(metadata.error) + t.notOk(metadata.requestPath) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const path = Array(100).join('foobar') + request(app) + .get('/' + path) + .expect(404, err => t.error(err)) + }) + + t.test('custom directory index view', function (t) { + t.plan(1) + + 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) + } + }) + + request(app) + .get('/pets') + .expect('Content-Type', 'text/plain; charset=UTF-8') + .expect(200, '.hidden\nindex.html\n', err => t.error(err)) + }) + + t.test('serving from a root directory with custom error-handling', function (t) { + t.plan(3) + + 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) + } + } + }) + + request(app) + .get('/pets') + .expect('Location', '/pets/') + .expect(301, err => t.error(err)) + + request(app) + .get('/not-exists') + .expect(404, err => t.error(err)) + + request(app) + .get('/pets/index.html') + .expect('Content-Disposition', 'attachment') + .expect(200, err => t.error(err)) + }) +}) diff --git a/types/index.d.ts b/types/index.d.ts index 7f93448..31cd91d 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -4,6 +4,7 @@ /// +import { Dirent } from "fs"; import * as stream from "stream"; /** @@ -111,12 +112,37 @@ declare namespace send { start?: number | undefined; } - export interface SendResult { + export interface BaseSendResult { statusCode: number headers: Record stream: stream.Readable } + export interface FileSendResult extends BaseSendResult { + type: 'file' + metadata: { + path: string + stat: Dirent + } + } + + export interface DirectorySendResult extends BaseSendResult { + type: 'directory' + metadata: { + path: string + requestPath: string + } + } + + export interface ErrorSendResult extends BaseSendResult { + type: 'error' + metadata: { + error: Error + } + } + + export type SendResult = FileSendResult | DirectorySendResult | ErrorSendResult + export const send: Send export { send as default } diff --git a/types/index.test-d.ts b/types/index.test-d.ts index 2fc1d4c..783ceba 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -1,6 +1,7 @@ +import { Dirent } from 'fs'; import { Readable } from 'stream'; import { expectType } from 'tsd'; -import send, { SendResult } from '..'; +import send, { DirectorySendResult, ErrorSendResult, FileSendResult, SendResult } from '..'; send.mime.define({ 'application/x-my-type': ['x-mt', 'x-mtt'] @@ -33,3 +34,23 @@ const req: any = {} expectType(result.stream) } + +const result = await send(req, '/test.html') +switch (result.type) { + case 'file': { + expectType(result) + expectType(result.metadata.path) + expectType(result.metadata.stat) + break + } + case 'directory': { + expectType(result) + expectType(result.metadata.path) + expectType(result.metadata.requestPath) + break + } + case 'error': { + expectType(result) + expectType(result.metadata.error) + } +} \ No newline at end of file From c0310bad0e8aadcd6daef56723b701dd502d173c Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 12 Jul 2024 11:53:37 +0200 Subject: [PATCH 066/109] Bumepd v3.1.0 Signed-off-by: Matteo Collina --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4e7e356..b1230f7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@fastify/send", "description": "Better streaming static file server with Range and conditional-GET support", - "version": "3.0.0", + "version": "3.1.0", "author": "TJ Holowaychuk ", "contributors": [ "Douglas Christopher Wilson ", From 39b3b4e78aef3572c70a48ce4497f646cb8d945b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jul 2024 05:18:12 +0000 Subject: [PATCH 067/109] build(deps-dev): bump tap from 20.0.3 to 21.0.0 (#81) Bumps [tap](https://github.com/tapjs/tapjs) from 20.0.3 to 21.0.0. - [Release notes](https://github.com/tapjs/tapjs/releases) - [Commits](https://github.com/tapjs/tapjs/compare/tap@20.0.3...tap@21.0.0) --- updated-dependencies: - dependency-name: tap dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b1230f7..2d0bb63 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "snazzy": "^9.0.0", "standard": "^17.1.0", "supertest": "6.3.4", - "tap": "^20.0.3", + "tap": "^21.0.0", "tsd": "^0.31.0" }, "scripts": { From 0e065c46aa5402bc302b28323439bdc9461b2544 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jul 2024 05:19:21 +0000 Subject: [PATCH 068/109] build(deps-dev): bump @types/node from 20.14.13 to 22.0.0 (#82) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.14.13 to 22.0.0. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2d0bb63..1ca158f 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ }, "devDependencies": { "@fastify/pre-commit": "^2.1.0", - "@types/node": "^20.12.2", + "@types/node": "^22.0.0", "after": "0.8.2", "benchmark": "^2.1.4", "snazzy": "^9.0.0", From 66d30063ce3e1b01c3fe22b33d8bad9586961c1e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Aug 2024 05:44:35 +0000 Subject: [PATCH 069/109] build(deps): bump fastify/workflows from 4.2.0 to 5.0.0 (#83) Bumps [fastify/workflows](https://github.com/fastify/workflows) from 4.2.0 to 5.0.0. - [Release notes](https://github.com/fastify/workflows/releases) - [Commits](https://github.com/fastify/workflows/compare/v4.2.0...v5.0.0) --- updated-dependencies: - dependency-name: fastify/workflows dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff54ddc..31223b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ on: jobs: test: - uses: fastify/workflows/.github/workflows/plugins-ci.yml@v4.2.0 + uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5.0.0 with: license-check: true lint: true From c5226067d3e1b06cf37825d4a0379c6dde760906 Mon Sep 17 00:00:00 2001 From: KaKa <23028015+climba03003@users.noreply.github.com> Date: Fri, 13 Sep 2024 19:25:49 +0800 Subject: [PATCH 070/109] fix: template injection (#84) --- lib/send.js | 3 +-- test/send.2.test.js | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/send.js b/lib/send.js index 226dd6f..82bb5d8 100644 --- a/lib/send.js +++ b/lib/send.js @@ -594,8 +594,7 @@ function sendRedirect (path, options) { } const loc = encodeURI(collapseLeadingSlashes(options.path + '/')) - const doc = createHtmlDocument('Redirecting', 'Redirecting to ' + - escapeHtml(loc) + '') + const doc = createHtmlDocument('Redirecting', 'Redirecting to ' + escapeHtml(loc)) const headers = {} headers['Content-Type'] = 'text/html; charset=UTF-8' diff --git a/test/send.2.test.js b/test/send.2.test.js index a8e20cd..8b6bdb3 100644 --- a/test/send.2.test.js +++ b/test/send.2.test.js @@ -288,7 +288,7 @@ test('send(file)', function (t) { .get('/pets') .expect('Location', '/pets/') .expect('Content-Type', /html/) - .expect(301, />Redirecting to \/pets\/<\/a> t.error(err)) + .expect(301, />Redirecting to \/pets\/ t.error(err)) }) t.test('should respond with default Content-Security-Policy', function (t) { @@ -323,7 +323,7 @@ test('send(file)', function (t) { .get('/snow') .expect('Location', '/snow%20%E2%98%83/') .expect('Content-Type', /html/) - .expect(301, />Redirecting to \/snow%20%E2%98%83\/<\/a> t.error(err)) + .expect(301, />Redirecting to \/snow%20%E2%98%83\/ t.error(err)) }) }) From 7dcacdbdfc636a44be03c35e6c22692e5dd0418c Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 13 Sep 2024 13:27:13 +0200 Subject: [PATCH 071/109] Bumped v3.1.1 Signed-off-by: Matteo Collina --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1ca158f..8d25741 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@fastify/send", "description": "Better streaming static file server with Range and conditional-GET support", - "version": "3.1.0", + "version": "3.1.1", "author": "TJ Holowaychuk ", "contributors": [ "Douglas Christopher Wilson ", From 6639f477c83a4c6d79375e25d2992a66ffad1388 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Thu, 24 Oct 2024 17:47:49 +0100 Subject: [PATCH 072/109] docs(readme): replace `var` statements (#86) * docs(readme): replace `var` statements Signed-off-by: Frazer Smith * Update README.md Signed-off-by: Frazer Smith --------- Signed-off-by: Frazer Smith --- README.md | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index f3d090d..1616bb1 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ $ npm install -D @types/mime@3 ## API ```js -var send = require('@fastify/send') +const send = require('@fastify/send') ``` ### send(req, path, [options]) @@ -160,10 +160,10 @@ $ npm test This simple example will send a specific file to all requests. ```js -var http = require('node:http') -var send = require('send') +const http = require('node:http') +const send = require('send') -var server = http.createServer(async function onRequest (req, 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) @@ -179,11 +179,11 @@ given directory as the top-level. For example, a request `GET /foo.txt` will send back `/www/public/foo.txt`. ```js -var http = require('node:http') -var parseUrl = require('parseurl') -var send = require('@fastify/send') +const http = require('node:http') +const parseUrl = require('parseurl') +const send = require('@fastify/send') -var server = http.createServer(async function onRequest (req, res) { +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) @@ -195,9 +195,9 @@ server.listen(3000) ### Custom file types ```js -var http = require('node:http') -var parseUrl = require('parseurl') -var send = require('@fastify/send') +const http = require('node:http') +const parseUrl = require('parseurl') +const send = require('@fastify/send') // Default unknown types to text/plain send.mime.default_type = 'text/plain' @@ -207,7 +207,7 @@ send.mime.define({ 'application/x-my-type': ['x-mt', 'x-mtt'] }) -var server = http.createServer(function onRequest (req, res) { +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) @@ -222,14 +222,14 @@ 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('node:http') -var fs = require('node:fs') -var parseUrl = require('parseurl') -var send = require('@fastify/send') +const http = require('node:http') +const fs = require('node:fs') +const parseUrl = require('parseurl') +const send = require('@fastify/send') // Transfer arbitrary files from within /www/example.com/public/* // with a custom handler for directory listing -var server = http.createServer(async function onRequest (req, 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 @@ -249,11 +249,11 @@ server.listen(3000) ### Serving from a root directory with custom error-handling ```js -var http = require('node:http') -var parseUrl = require('parseurl') -var send = require('@fastify/send') +const http = require('node:http') +const parseUrl = require('parseurl') +const send = require('@fastify/send') -var server = http.createServer(async function onRequest (req, res) { +const server = http.createServer(async function onRequest (req, res) { // transfer arbitrary files from within // /www/example.com/public/* const { statusCode, headers, stream, type, metadata } = await send(req, parseUrl(req).pathname, { root: '/www/public' }) From a4b8f4e07faa90d0b72f44b7257d016c04daa045 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Nov 2024 05:13:25 +0000 Subject: [PATCH 073/109] build(deps): bump fastify/workflows from 5.0.0 to 5.0.1 (#88) Bumps [fastify/workflows](https://github.com/fastify/workflows) from 5.0.0 to 5.0.1. - [Release notes](https://github.com/fastify/workflows/releases) - [Commits](https://github.com/fastify/workflows/compare/v5.0.0...v5.0.1) --- updated-dependencies: - dependency-name: fastify/workflows dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31223b1..5e11029 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ on: jobs: test: - uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5.0.0 + uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5.0.1 with: license-check: true lint: true From 84d6f7258c7ab67934a5849edb8d5016709b2019 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Fri, 1 Nov 2024 10:39:29 +0000 Subject: [PATCH 074/109] ci: use major version of workflows --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e11029..e6de39c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ on: jobs: test: - uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5.0.1 + uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5 with: license-check: true lint: true From 60c37c23b46fdbdf8d3d3494b3879423e73bff4b Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Sun, 3 Nov 2024 12:30:50 +0000 Subject: [PATCH 075/109] style: remove trailing whitespace (#89) --- HISTORY.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index a739774..bebfd46 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -480,37 +480,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) From 396f0aa2142e9a2594af62e8cf5f5885a809a3fb Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Mon, 18 Nov 2024 14:10:27 +0000 Subject: [PATCH 076/109] chore(package): add `homepage` and `bugs` urls (#91) Signed-off-by: Frazer Smith --- package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/package.json b/package.json index 8d25741..ff8dd9e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,10 @@ "type": "git", "url": "git+https://github.com/fastify/send.git" }, + "bugs": { + "url": "https://github.com/fastify/send/issues" + }, + "homepage": "https://github.com/fastify/send#readme", "keywords": [ "static", "file", From 6f078bb410d2502de0f7817667e9568e48603b2e Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Mon, 18 Nov 2024 16:40:31 +0000 Subject: [PATCH 077/109] refactor: use lowercase for charset (#92) --- README.md | 2 +- lib/send.js | 6 +++--- test/mime.test.js | 2 +- test/send.2.test.js | 4 ++-- test/send.3.test.js | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1616bb1..a6a87df 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,7 @@ const server = http.createServer(async function onRequest (req, res) { // 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.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }) res.end(list.join('\n') + '\n') } else { res.writeHead(statusCode, headers) diff --git a/lib/send.js b/lib/send.js index 82bb5d8..d9b05e0 100644 --- a/lib/send.js +++ b/lib/send.js @@ -396,7 +396,7 @@ function sendError (statusCode, err) { const doc = ERROR_RESPONSES[statusCode] // basic response - headers['Content-Type'] = 'text/html; charset=UTF-8' + 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' @@ -492,7 +492,7 @@ function sendFileDirectly (request, path, stat, options) { let type = mime.getType(path) || mime.default_type debug('content-type %s', type) if (type && isUtf8MimeType(type)) { - type += '; charset=UTF-8' + type += '; charset=utf-8' } if (type) { headers['Content-Type'] = type @@ -597,7 +597,7 @@ function sendRedirect (path, options) { const doc = createHtmlDocument('Redirecting', 'Redirecting to ' + escapeHtml(loc)) const headers = {} - headers['Content-Type'] = 'text/html; charset=UTF-8' + 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' diff --git a/test/mime.test.js b/test/mime.test.js index 6031d32..6e0443d 100644 --- a/test/mime.test.js +++ b/test/mime.test.js @@ -33,7 +33,7 @@ test('send.mime', function (t) { request(createServer({ root: fixtures })) .get('/no_ext') - .expect('Content-Type', 'text/plain; charset=UTF-8') + .expect('Content-Type', 'text/plain; charset=utf-8') .expect(200, err => t.error(err)) }) diff --git a/test/send.2.test.js b/test/send.2.test.js index 8b6bdb3..badf7b5 100644 --- a/test/send.2.test.js +++ b/test/send.2.test.js @@ -259,12 +259,12 @@ test('send(file)', function (t) { request(app) .get('/name.txt') - .expect('Content-Type', 'text/plain; charset=UTF-8') + .expect('Content-Type', 'text/plain; charset=utf-8') .expect(200, function (err) { t.error(err) request(app) .get('/tobi.html') - .expect('Content-Type', 'text/html; charset=UTF-8') + .expect('Content-Type', 'text/html; charset=utf-8') .expect(200, err => t.error(err)) }) }) diff --git a/test/send.3.test.js b/test/send.3.test.js index 8fa0332..0600a53 100644 --- a/test/send.3.test.js +++ b/test/send.3.test.js @@ -79,7 +79,7 @@ test('send(file)', function (t) { 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.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }) res.end(list.join('\n') + '\n') } else { res.writeHead(statusCode, headers) @@ -89,7 +89,7 @@ test('send(file)', function (t) { request(app) .get('/pets') - .expect('Content-Type', 'text/plain; charset=UTF-8') + .expect('Content-Type', 'text/plain; charset=utf-8') .expect(200, '.hidden\nindex.html\n', err => t.error(err)) }) From 277a32f74b5757e3a7e5a1fe57d592206a1817fa Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Fri, 22 Nov 2024 10:19:46 +0000 Subject: [PATCH 078/109] feat: add `contentType` option (#93) * feat: add `contentType` option * test: add type tests * Update README.md Signed-off-by: Frazer Smith --------- Signed-off-by: Frazer Smith --- README.md | 8 ++++++++ lib/send.js | 21 ++++++++++++++------- test/send.1.test.js | 15 ++++++++++++++- types/index.d.ts | 5 +++++ types/index.test-d.ts | 10 +++++++++- 5 files changed, 50 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a6a87df..f5b11e5 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,14 @@ 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 diff --git a/lib/send.js b/lib/send.js index d9b05e0..f8df6ec 100644 --- a/lib/send.js +++ b/lib/send.js @@ -101,6 +101,10 @@ function normalizeOptions (options) { ? Boolean(options.cacheControl) : true + const contentType = options.contentType !== undefined + ? Boolean(options.contentType) + : true + const etag = options.etag !== undefined ? Boolean(options.etag) : true @@ -137,6 +141,7 @@ function normalizeOptions (options) { return { acceptRanges, cacheControl, + contentType, etag, dotfiles, extensions, @@ -489,13 +494,15 @@ function sendFileDirectly (request, path, stat, options) { } // set content-type - 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 + 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 diff --git a/test/send.1.test.js b/test/send.1.test.js index 672face..a744346 100644 --- a/test/send.1.test.js +++ b/test/send.1.test.js @@ -13,7 +13,7 @@ const { shouldNotHaveHeader, createServer } = require('./utils') const fixtures = path.join(__dirname, 'fixtures') test('send(file, options)', function (t) { - t.plan(10) + t.plan(11) t.test('acceptRanges', function (t) { t.plan(2) @@ -60,6 +60,19 @@ test('send(file, options)', function (t) { }) }) + t.test('contentType', function (t) { + t.plan(1) + + t.test('should support disabling content-type', function (t) { + t.plan(2) + + request(createServer({ contentType: false, root: fixtures })) + .get('/name.txt') + .expect(shouldNotHaveHeader('Content-Type', t)) + .expect(200, err => t.error(err)) + }) + }) + t.test('etag', function (t) { t.plan(1) diff --git a/types/index.d.ts b/types/index.d.ts index 31cd91d..b2a0f11 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -44,6 +44,11 @@ declare namespace send { */ cacheControl?: boolean | undefined; + /** + * Enable or disable setting Content-Type response header, defaults to true. + */ + contentType?: boolean | undefined; + /** * Set how "dotfiles" are treated when encountered. * A dotfile is a file or directory that begins with a dot ("."). diff --git a/types/index.test-d.ts b/types/index.test-d.ts index 783ceba..7a50808 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -26,7 +26,7 @@ const req: any = {} } { - const result = await send(req, '/test.html', { maxAge: 0, root: __dirname + '/wwwroot' }) + const result = await send(req, '/test.html', { contentType: true, maxAge: 0, root: __dirname + '/wwwroot' }) expectType(result) expectType(result.statusCode) @@ -34,6 +34,14 @@ const req: any = {} expectType(result.stream) } +{ + const result = await send(req, '/test.html', { contentType: false, root: __dirname + '/wwwroot' }) + + expectType(result) + expectType(result.statusCode) + expectType>(result.headers) + expectType(result.stream) +} const result = await send(req, '/test.html') switch (result.type) { From 52f810ecc5c57d9c57ccddab7b6a8692755aed68 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Fri, 22 Nov 2024 10:36:43 +0000 Subject: [PATCH 079/109] 3.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ff8dd9e..3f316d0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@fastify/send", "description": "Better streaming static file server with Range and conditional-GET support", - "version": "3.1.1", + "version": "3.2.0", "author": "TJ Holowaychuk ", "contributors": [ "Douglas Christopher Wilson ", From 35c75b4a74991208c6e56218a4461667ab9f8245 Mon Sep 17 00:00:00 2001 From: Kaede Fujisaki Date: Mon, 2 Dec 2024 17:46:51 +0900 Subject: [PATCH 080/109] feat: add `maxContentRangeChunkSize` option (#90) * Add maxChunkSize to SendOptions * Revert unused change * Fix for the case when maxChunkSize is null * Add maxChunkSize related tests * Fix README.md * typo * Add tsd tests * Apply suggestions from code review Co-authored-by: Frazer Smith Signed-off-by: Kaede Fujisaki * All options must be sorted alphabetically * add test cases * add more test cases * add more test cases * Renamed to highWatarmark * nits: Fix doc * Renamed to `maxReturnSize` * Update README.md Signed-off-by: Frazer Smith * Renamed to `maxContentRangeChunkSize` * Empty commit to kick CI --------- Signed-off-by: Kaede Fujisaki Signed-off-by: Frazer Smith Co-authored-by: Frazer Smith Co-authored-by: Aras Abbasi --- README.md | 5 +++++ lib/send.js | 8 +++++++ test/send.1.test.js | 50 ++++++++++++++++++++++++++++++++++++++++++- test/utils.js | 6 ++++++ types/index.d.ts | 5 +++++ types/index.test-d.ts | 4 +++- 6 files changed, 76 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f5b11e5..36bcbef 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,11 @@ 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`. diff --git a/lib/send.js b/lib/send.js index f8df6ec..096f64e 100644 --- a/lib/send.js +++ b/lib/send.js @@ -134,6 +134,10 @@ function normalizeOptions (options) { const maxage = normalizeMaxAge(options.maxAge ?? options.maxage) + const maxContentRangeChunkSize = options.maxContentRangeChunkSize !== undefined + ? Number(options.maxContentRangeChunkSize) + : null + const root = options.root ? resolve(options.root) : null @@ -149,6 +153,7 @@ function normalizeOptions (options) { index, lastModified, maxage, + maxContentRangeChunkSize, root, start: options.start, end: options.end @@ -553,6 +558,9 @@ function sendFileDirectly (request, path, stat, options) { // 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 diff --git a/test/send.1.test.js b/test/send.1.test.js index a744346..d62c6ff 100644 --- a/test/send.1.test.js +++ b/test/send.1.test.js @@ -16,7 +16,7 @@ test('send(file, options)', function (t) { t.plan(11) t.test('acceptRanges', function (t) { - t.plan(2) + t.plan(6) t.test('should support disabling accept-ranges', function (t) { t.plan(2) @@ -37,6 +37,54 @@ test('send(file, options)', function (t) { .expect(shouldNotHaveHeader('Content-Range', t)) .expect(200, '123456789', err => t.error(err)) }) + + t.test('should limit high return size /1', function (t) { + t.plan(4) + + request(createServer({ acceptRanges: true, maxContentRangeChunkSize: 1, root: fixtures })) + .get('/nums.txt') + .set('Range', 'bytes=0-2') + .expect((res) => t.equal(res.headers['accept-ranges'], 'bytes')) + .expect((res) => t.equal(res.headers['content-range'], 'bytes 0-0/9')) + .expect((res) => t.equal(res.headers['content-length'], '1', 'should content-length must be as same as maxContentRangeChunkSize')) + .expect(206, '1', (err) => t.error(err)) + }) + + t.test('should limit high return size /2', function (t) { + t.plan(4) + + request(createServer({ acceptRanges: true, maxContentRangeChunkSize: 1, root: fixtures })) + .get('/nums.txt') + .set('Range', 'bytes=1-2') + .expect((res) => t.equal(res.headers['accept-ranges'], 'bytes')) + .expect((res) => t.equal(res.headers['content-range'], 'bytes 1-1/9')) + .expect((res) => t.equal(res.headers['content-length'], '1', 'should content-length must be as same as maxContentRangeChunkSize')) + .expect(206, '2', (err) => t.error(err)) + }) + + t.test('should limit high return size /3', function (t) { + t.plan(4) + + request(createServer({ acceptRanges: true, maxContentRangeChunkSize: 1, root: fixtures })) + .get('/nums.txt') + .set('Range', 'bytes=1-3') + .expect((res) => t.equal(res.headers['accept-ranges'], 'bytes')) + .expect((res) => t.equal(res.headers['content-range'], 'bytes 1-1/9')) + .expect((res) => t.equal(res.headers['content-length'], '1', 'should content-length must be as same as maxContentRangeChunkSize')) + .expect(206, '2', (err) => t.error(err)) + }) + + t.test('should limit high return size /4', function (t) { + t.plan(4) + + request(createServer({ acceptRanges: true, maxContentRangeChunkSize: 4, root: fixtures })) + .get('/nums.txt') + .set('Range', 'bytes=1-2,3-6') + .expect((res) => t.equal(res.headers['accept-ranges'], 'bytes')) + .expect((res) => t.equal(res.headers['content-range'], 'bytes 1-4/9')) + .expect((res) => t.equal(res.headers['content-length'], '4', 'should content-length must be as same as maxContentRangeChunkSize')) + .expect(206, '2345', (err) => t.error(err)) + }) }) t.test('cacheControl', function (t) { diff --git a/test/utils.js b/test/utils.js index 864d5f3..b68ea51 100644 --- a/test/utils.js +++ b/test/utils.js @@ -9,6 +9,12 @@ module.exports.shouldNotHaveHeader = function shouldNotHaveHeader (header, t) { } } +module.exports.shouldHaveHeader = function shouldHaveHeader (header, t) { + return function (res) { + t.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 { diff --git a/types/index.d.ts b/types/index.d.ts index b2a0f11..76ff4e0 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -105,6 +105,11 @@ declare namespace send { */ maxAge?: string | number | undefined; + /** + * Limit max response content size when acceptRanges is true, defaults to the entire file size. + */ + maxContentRangeChunkSize?: number | undefined; + /** * Serve files relative to path. */ diff --git a/types/index.test-d.ts b/types/index.test-d.ts index 7a50808..edb1329 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -14,6 +14,8 @@ const req: any = {} { const result = await send(req, '/test.html', { + acceptRanges: true, + maxContentRangeChunkSize: 10, immutable: true, maxAge: 0, root: __dirname + '/wwwroot' @@ -61,4 +63,4 @@ switch (result.type) { expectType(result) expectType(result.metadata.error) } -} \ No newline at end of file +} From 14de39b72db56506e53e636d5fdad2a5e98c0ae0 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Tue, 3 Dec 2024 08:41:33 +0000 Subject: [PATCH 081/109] 3.3.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3f316d0..4a2e76c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@fastify/send", "description": "Better streaming static file server with Range and conditional-GET support", - "version": "3.2.0", + "version": "3.3.0", "author": "TJ Holowaychuk ", "contributors": [ "Douglas Christopher Wilson ", From 86d28a803aea1f65d144588e52743faceea16602 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Thu, 5 Dec 2024 14:23:27 +0000 Subject: [PATCH 082/109] docs(readme): update ci badge syntax (#94) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 36bcbef..0492b72 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @fastify/send -![CI](https://github.com/fastify/send/workflows/CI/badge.svg) +[![CI](https://github.com/fastify/send/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/fastify/send/actions/workflows/ci.yml) [![NPM version](https://img.shields.io/npm/v/@fastify/send.svg?style=flat)](https://www.npmjs.com/package/@fastify/send) [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](https://standardjs.com/) From 5eb7088511594a7dfd9cf5997d1497dc8ce80eb0 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Sun, 8 Dec 2024 16:16:39 +0000 Subject: [PATCH 083/109] build(deps-dev): replace standard with neostandard (#95) * build(deps-dev): replace standard with neostandard * chore: add eslint.config.js * fix lint --------- Co-authored-by: Aras Abbasi --- README.md | 2 +- eslint.config.js | 6 ++++++ package.json | 7 +++---- types/index.d.ts | 24 ++++++++++++------------ types/index.test-d.ts | 19 ++++++++++--------- 5 files changed, 32 insertions(+), 26 deletions(-) create mode 100644 eslint.config.js diff --git a/README.md b/README.md index 0492b72..236bcc0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/fastify/send/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/fastify/send/actions/workflows/ci.yml) [![NPM version](https://img.shields.io/npm/v/@fastify/send.svg?style=flat)](https://www.npmjs.com/package/@fastify/send) -[![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](https://standardjs.com/) +[![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard) Send is a library for streaming files from the file system as an HTTP response supporting partial responses (Ranges), conditional-GET negotiation (If-Match, diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..89fd678 --- /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/package.json b/package.json index 4a2e76c..6f16434 100644 --- a/package.json +++ b/package.json @@ -37,15 +37,14 @@ "@types/node": "^22.0.0", "after": "0.8.2", "benchmark": "^2.1.4", - "snazzy": "^9.0.0", - "standard": "^17.1.0", + "neostandard": "^0.11.9", "supertest": "6.3.4", "tap": "^21.0.0", "tsd": "^0.31.0" }, "scripts": { - "lint": "standard | snazzy", - "lint:fix": "standard --fix | snazzy", + "lint": "eslint", + "lint:fix": "eslint --fix", "test": "npm run test:unit && npm run test:typescript", "test:coverage": "tap --coverage-report=html", "test:typescript": "tsd", diff --git a/types/index.d.ts b/types/index.d.ts index 76ff4e0..8231c1e 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -4,23 +4,23 @@ /// -import { Dirent } from "fs"; -import * as stream from "stream"; +import { Dirent } from 'fs' +import * as stream from 'stream' /** * 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). */ -declare function send(req: stream.Readable, path: string, options?: send.SendOptions): Promise; +declare function send (req: stream.Readable, path: string, options?: send.SendOptions): Promise -type Send = typeof send; +type Send = typeof send declare class Mime { - constructor(typeMap: TypeMap, ...mimes: TypeMap[]); + constructor (typeMap: TypeMap, ...mimes: TypeMap[]) - getType(path: string): string | null; - getExtension(mime: string): string | null; - define(typeMap: TypeMap, force?: boolean): void; + getType (path: string): string | null + getExtension (mime: string): string | null + define (typeMap: TypeMap, force?: boolean): void } interface TypeMap { @@ -28,8 +28,8 @@ interface TypeMap { } declare namespace send { - export const mime: Mime; - export const isUtf8MimeType: (value: string) => boolean; + export const mime: Mime + export const isUtf8MimeType: (value: string) => boolean export interface SendOptions { /** @@ -59,7 +59,7 @@ declare namespace send { * 'ignore' Pretend like the dotfile does not exist and 404. * The default value is similar to 'ignore', with the exception that this default will not ignore the files within a directory that begins with a dot, for backward-compatibility. */ - dotfiles?: "allow" | "deny" | "ignore" | undefined; + dotfiles?: 'allow' | 'deny' | 'ignore' | undefined; /** * Byte offset at which the stream ends, defaults to the length of the file minus 1. @@ -158,4 +158,4 @@ declare namespace send { export { send as default } } -export = send; +export = send diff --git a/types/index.test-d.ts b/types/index.test-d.ts index edb1329..1711484 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -1,11 +1,12 @@ -import { Dirent } from 'fs'; -import { Readable } from 'stream'; -import { expectType } from 'tsd'; -import send, { DirectorySendResult, ErrorSendResult, FileSendResult, SendResult } from '..'; +import { Dirent } from 'node:fs' +import { resolve } from 'node:path' +import { Readable } from 'node:stream' +import { expectType } from 'tsd' +import send, { DirectorySendResult, ErrorSendResult, FileSendResult, SendResult } from '..' send.mime.define({ 'application/x-my-type': ['x-mt', 'x-mtt'] -}); +}) expectType<(value: string) => boolean>(send.isUtf8MimeType) expectType(send.isUtf8MimeType('application/json')) @@ -18,8 +19,8 @@ const req: any = {} maxContentRangeChunkSize: 10, immutable: true, maxAge: 0, - root: __dirname + '/wwwroot' - }); + root: resolve(__dirname, '/wwwroot') + }) expectType(result) expectType(result.statusCode) @@ -28,7 +29,7 @@ const req: any = {} } { - const result = await send(req, '/test.html', { contentType: true, maxAge: 0, root: __dirname + '/wwwroot' }) + const result = await send(req, '/test.html', { contentType: true, maxAge: 0, root: resolve(__dirname, '/wwwroot') }) expectType(result) expectType(result.statusCode) @@ -37,7 +38,7 @@ const req: any = {} } { - const result = await send(req, '/test.html', { contentType: false, root: __dirname + '/wwwroot' }) + const result = await send(req, '/test.html', { contentType: false, root: resolve(__dirname, '/wwwroot') }) expectType(result) expectType(result.statusCode) From 7ab12a490632df1909a1bcf340a3023f1af79e05 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Sun, 15 Dec 2024 16:50:55 +0000 Subject: [PATCH 084/109] types: use `node:` prefix for builtins (#96) --- types/index.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/types/index.d.ts b/types/index.d.ts index 8231c1e..1ff729c 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -4,8 +4,8 @@ /// -import { Dirent } from 'fs' -import * as stream from 'stream' +import { Dirent } from 'node:fs' +import * as stream from 'node:stream' /** * Create a new SendStream for the given path to send to a res. From 86d08933ff52d67d3224c787c816125b48bcdc8f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Dec 2024 06:01:26 +0000 Subject: [PATCH 085/109] build(deps-dev): bump neostandard from 0.11.9 to 0.12.0 (#97) Bumps [neostandard](https://github.com/neostandard/neostandard) from 0.11.9 to 0.12.0. - [Release notes](https://github.com/neostandard/neostandard/releases) - [Changelog](https://github.com/neostandard/neostandard/blob/main/CHANGELOG.md) - [Commits](https://github.com/neostandard/neostandard/compare/v0.11.9...v0.12.0) --- updated-dependencies: - dependency-name: neostandard dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6f16434..a9ad7c8 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "@types/node": "^22.0.0", "after": "0.8.2", "benchmark": "^2.1.4", - "neostandard": "^0.11.9", + "neostandard": "^0.12.0", "supertest": "6.3.4", "tap": "^21.0.0", "tsd": "^0.31.0" From 20146a117c52502b602637225353a6610fd2b21e Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Mon, 16 Dec 2024 20:04:33 +0000 Subject: [PATCH 086/109] build(deps-dev): add eslint, peer dep of neostandard (#98) --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index a9ad7c8..dd360a8 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@types/node": "^22.0.0", "after": "0.8.2", "benchmark": "^2.1.4", + "eslint": "^9.17.0", "neostandard": "^0.12.0", "supertest": "6.3.4", "tap": "^21.0.0", From 63b54f3506c977c6bf012753122fe6f1c166a0c1 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Sun, 22 Dec 2024 17:22:52 +0000 Subject: [PATCH 087/109] chore(package): add contribs and funding (#99) * chore(package): add contribs and funding Signed-off-by: Frazer Smith * Update package.json Signed-off-by: Frazer Smith --------- Signed-off-by: Frazer Smith --- package.json | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index dd360a8..be20754 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,20 @@ "contributors": [ "Douglas Christopher Wilson ", "James Wyatt Cready ", - "Jesús Leganés Combarro " + "Jesús Leganés Combarro ", + { + "name": "Matteo Collina", + "email": "hello@matteocollina.com" + }, + { + "name": "Frazer Smith", + "email": "frazer.dev@icloud.com", + "url": "https://github.com/fdawgs" + }, + { + "name": "Aras Abbasi", + "email": "aras.abbasi@gmail.com" + } ], "main": "index.js", "type": "commonjs", @@ -20,6 +33,16 @@ "url": "https://github.com/fastify/send/issues" }, "homepage": "https://github.com/fastify/send#readme", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "keywords": [ "static", "file", From ebb1c5e9a954b8a31ee76ef34b6a259336786b2a Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Fri, 3 Jan 2025 17:29:35 +0000 Subject: [PATCH 088/109] test(send2): prefix unused params with underscores (#100) Signed-off-by: Frazer Smith --- test/send.2.test.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/send.2.test.js b/test/send.2.test.js index badf7b5..ba9205b 100644 --- a/test/send.2.test.js +++ b/test/send.2.test.js @@ -354,7 +354,7 @@ test('send(file)', function (t) { t.test('should remove Content headers with 304', function (t) { t.plan(2) - const server = createServer({ root: fixtures }, function (req, res) { + 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') @@ -376,7 +376,7 @@ test('send(file)', function (t) { t.test('should not remove all Content-* headers', function (t) { t.plan(2) - const server = createServer({ root: fixtures }, function (req, res) { + const server = createServer({ root: fixtures }, function (_req, res) { res.setHeader('Content-Location', 'http://localhost/name.txt') res.setHeader('Content-Security-Policy', 'default-src \'self\'') }) @@ -569,7 +569,7 @@ test('send(file)', function (t) { request(app) .get('/name.txt') - .expect(200, function (err, res) { + .expect(200, function (err) { t.error(err) request(app) .get('/name.txt') @@ -589,7 +589,7 @@ test('send(file)', function (t) { request(app) .get('/name.txt') - .expect(200, function (err, res) { + .expect(200, function (err) { t.error(err) request(app) .get('/name.txt') @@ -609,7 +609,7 @@ test('send(file)', function (t) { request(app) .get('/name.txt') - .expect(200, function (err, res) { + .expect(200, function (err) { t.error(err) request(app) .get('/name.txt') From caa7d3b0f5d2c8b39ee993a1ca6a2f2b21035b96 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Sat, 4 Jan 2025 19:07:47 +0000 Subject: [PATCH 089/109] docs(readme): grammar fix (#101) Signed-off-by: Frazer Smith --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 236bcc0..7201d7c 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ const send = require('@fastify/send') ### send(req, path, [options]) -Provide `statusCode`, `headers` and `stream` for the given path to send to a +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). @@ -300,4 +300,4 @@ server.listen(3000) ## License -[MIT](LICENSE) +Licensed under [MIT](./LICENSE). From 4826d1bdd2bdc5339b7059b79347441db21cddf7 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Sat, 4 Jan 2025 19:08:18 +0000 Subject: [PATCH 090/109] 3.3.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index be20754..70a7c79 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@fastify/send", "description": "Better streaming static file server with Range and conditional-GET support", - "version": "3.3.0", + "version": "3.3.1", "author": "TJ Holowaychuk ", "contributors": [ "Douglas Christopher Wilson ", From c1c723b8f2fe5fa4cfb33c306db41254c7c959f3 Mon Sep 17 00:00:00 2001 From: Russell Chappell Date: Tue, 7 Jan 2025 15:36:18 +0000 Subject: [PATCH 091/109] fix: rename fixtures to avoid triggering malware (#103) --- test/fixtures/.mine/{.hidden => .hidden.txt} | 0 test/fixtures/pets/{.hidden => .hidden.txt} | 0 test/send.1.test.js | 4 ++-- test/send.3.test.js | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename test/fixtures/.mine/{.hidden => .hidden.txt} (100%) rename test/fixtures/pets/{.hidden => .hidden.txt} (100%) diff --git a/test/fixtures/.mine/.hidden b/test/fixtures/.mine/.hidden.txt similarity index 100% rename from test/fixtures/.mine/.hidden rename to test/fixtures/.mine/.hidden.txt diff --git a/test/fixtures/pets/.hidden b/test/fixtures/pets/.hidden.txt similarity index 100% rename from test/fixtures/pets/.hidden rename to test/fixtures/pets/.hidden.txt diff --git a/test/send.1.test.js b/test/send.1.test.js index d62c6ff..68122a4 100644 --- a/test/send.1.test.js +++ b/test/send.1.test.js @@ -315,14 +315,14 @@ test('send(file, options)', function (t) { t.test('should 403 for dotfile in directory', function (t) { t.plan(1) request(createServer({ dotfiles: 'deny', root: fixtures })) - .get('/pets/.hidden') + .get('/pets/.hidden.txt') .expect(403, err => t.error(err)) }) t.test('should 403 for dotfile in dotfile directory', function (t) { t.plan(1) request(createServer({ dotfiles: 'deny', root: fixtures })) - .get('/.mine/.hidden') + .get('/.mine/.hidden.txt') .expect(403, err => t.error(err)) }) diff --git a/test/send.3.test.js b/test/send.3.test.js index 0600a53..5b02e43 100644 --- a/test/send.3.test.js +++ b/test/send.3.test.js @@ -90,7 +90,7 @@ test('send(file)', function (t) { request(app) .get('/pets') .expect('Content-Type', 'text/plain; charset=utf-8') - .expect(200, '.hidden\nindex.html\n', err => t.error(err)) + .expect(200, '.hidden.txt\nindex.html\n', err => t.error(err)) }) t.test('serving from a root directory with custom error-handling', function (t) { From 4e783b1f7f545556e5685dc9a88143ec02c309af Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Wed, 15 Jan 2025 15:25:47 +0000 Subject: [PATCH 092/109] build(dependabot): reduce npm updates to monthly (#104) --- .github/dependabot.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 618a234..be225b0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,5 +9,5 @@ updates: - package-ecosystem: "npm" directory: "/" schedule: - interval: "weekly" + interval: "monthly" open-pull-requests-limit: 10 \ No newline at end of file From c9627d310a5c24eae9283deddb7b5fc84387bada Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Mon, 3 Feb 2025 09:29:01 +0000 Subject: [PATCH 093/109] chore: rename master to main (#105) --- .github/workflows/ci.yml | 1 - README.md | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6de39c..d8bd1be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: - main - - master - next - 'v*' paths-ignore: diff --git a/README.md b/README.md index 7201d7c..75dfc39 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @fastify/send -[![CI](https://github.com/fastify/send/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/fastify/send/actions/workflows/ci.yml) +[![CI](https://github.com/fastify/send/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/send/actions/workflows/ci.yml) [![NPM version](https://img.shields.io/npm/v/@fastify/send.svg?style=flat)](https://www.npmjs.com/package/@fastify/send) [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard) From d8528293df2fa49bfcfc48f2ccc5385dc3d3b56f Mon Sep 17 00:00:00 2001 From: Matteo Pietro Dazzi Date: Sun, 9 Feb 2025 15:22:33 +0100 Subject: [PATCH 094/109] test: move to node test runner (#106) * feat: migrate collapseLeadingSlashes * feat: migrate containsDotFile * feat: migrate isUtf8MimeType * feat: migrate normalize list * feat: migrate parseBytesRange * feat: migrated mime * feat: migrate send.1 * feat: migrate send 2 * feat: migrate send.3 and removed tap * feat: migrated commands * chore: import * fix: await * chore: removed empty plans * Delete .taprc Signed-off-by: Matteo Pietro Dazzi * fix: typo * BREAKING CHANGE: remove support for node18 * fix: use c8 --------- Signed-off-by: Matteo Pietro Dazzi --- .github/workflows/ci.yml | 1 - .taprc | 2 - package.json | 6 +- test/collapseLeadingSlashes.test.js | 6 +- test/containsDotFile.test.js | 6 +- test/isUtf8MimeType.test.js | 6 +- test/mime.test.js | 33 +- test/normalizeList.test.js | 18 +- test/parseBytesRange.test.js | 92 ++-- test/send.1.test.js | 596 +++++++++----------- test/send.2.test.js | 811 ++++++++++++---------------- test/send.3.test.js | 82 ++- test/utils.js | 8 +- 13 files changed, 705 insertions(+), 962 deletions(-) delete mode 100644 .taprc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8bd1be..496e8b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,4 +20,3 @@ jobs: with: license-check: true lint: true - node-versions: '["18", "20", "22"]' diff --git a/.taprc b/.taprc deleted file mode 100644 index eb6eb3e..0000000 --- a/.taprc +++ /dev/null @@ -1,2 +0,0 @@ -files: - - test/**/*.test.js diff --git a/package.json b/package.json index 70a7c79..35b56d5 100644 --- a/package.json +++ b/package.json @@ -60,19 +60,19 @@ "@types/node": "^22.0.0", "after": "0.8.2", "benchmark": "^2.1.4", + "c8": "^10.1.3", "eslint": "^9.17.0", "neostandard": "^0.12.0", "supertest": "6.3.4", - "tap": "^21.0.0", "tsd": "^0.31.0" }, "scripts": { "lint": "eslint", "lint:fix": "eslint --fix", "test": "npm run test:unit && npm run test:typescript", - "test:coverage": "tap --coverage-report=html", + "test:coverage": "c8 --reporter html node --test", "test:typescript": "tsd", - "test:unit": "tap" + "test:unit": "c8 --100 node --test" }, "pre-commit": [ "lint", diff --git a/test/collapseLeadingSlashes.test.js b/test/collapseLeadingSlashes.test.js index 6f8dd93..1410d7a 100644 --- a/test/collapseLeadingSlashes.test.js +++ b/test/collapseLeadingSlashes.test.js @@ -1,6 +1,6 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const { collapseLeadingSlashes } = require('../lib/collapseLeadingSlashes') test('collapseLeadingSlashes', function (t) { @@ -16,7 +16,7 @@ test('collapseLeadingSlashes', function (t) { ] t.plan(testCases.length) - for (let i = 0; i < testCases.length; ++i) { - t.strictSame(collapseLeadingSlashes(testCases[i][0]), testCases[i][1]) + for (const testCase of testCases) { + t.assert.deepStrictEqual(collapseLeadingSlashes(testCase[0]), testCase[1]) } }) diff --git a/test/containsDotFile.test.js b/test/containsDotFile.test.js index 03e5f50..7e9d664 100644 --- a/test/containsDotFile.test.js +++ b/test/containsDotFile.test.js @@ -1,6 +1,6 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const { containsDotFile } = require('../lib/containsDotFile') test('containsDotFile', function (t) { @@ -12,7 +12,7 @@ test('containsDotFile', function (t) { ] t.plan(testCases.length) - for (let i = 0; i < testCases.length; ++i) { - t.strictSame(containsDotFile(testCases[i][0].split('/')), testCases[i][1], testCases[i][0]) + for (const testCase of testCases) { + t.assert.deepStrictEqual(containsDotFile(testCase[0].split('/')), testCase[1], testCase[0]) } }) diff --git a/test/isUtf8MimeType.test.js b/test/isUtf8MimeType.test.js index f718c2a..daad693 100644 --- a/test/isUtf8MimeType.test.js +++ b/test/isUtf8MimeType.test.js @@ -1,6 +1,6 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const { isUtf8MimeType } = require('../lib/isUtf8MimeType') test('isUtf8MimeType', function (t) { @@ -16,7 +16,7 @@ test('isUtf8MimeType', function (t) { ] t.plan(testCases.length) - for (let i = 0; i < testCases.length; ++i) { - t.strictSame(isUtf8MimeType(testCases[i][0], 'test'), testCases[i][1]) + for (const testCase of testCases) { + t.assert.deepStrictEqual(isUtf8MimeType(testCase[0], 'test'), testCase[1]) } }) diff --git a/test/mime.test.js b/test/mime.test.js index 6e0443d..433a003 100644 --- a/test/mime.test.js +++ b/test/mime.test.js @@ -1,6 +1,6 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const path = require('node:path') const request = require('supertest') const send = require('..') @@ -8,15 +8,15 @@ const { shouldNotHaveHeader, createServer } = require('./utils') const fixtures = path.join(__dirname, 'fixtures') -test('send.mime', function (t) { +test('send.mime', async function (t) { t.plan(2) - t.test('should be exposed', function (t) { + await t.test('should be exposed', function (t) { t.plan(1) - t.ok(send.mime) + t.assert.ok(send.mime) }) - t.test('.default_type', function (t) { + await t.test('.default_type', async function (t) { t.plan(3) t.before(() => { @@ -27,33 +27,30 @@ test('send.mime', function (t) { send.mime.default_type = this.default_type }) - t.test('should change the default type', function (t) { - t.plan(1) + await t.test('should change the default type', async function (t) { send.mime.default_type = 'text/plain' - request(createServer({ root: fixtures })) + await request(createServer({ root: fixtures })) .get('/no_ext') .expect('Content-Type', 'text/plain; charset=utf-8') - .expect(200, err => t.error(err)) + .expect(200) }) - t.test('should not add Content-Type for undefined default', function (t) { - t.plan(2) + await t.test('should not add Content-Type for undefined default', async function (t) { + t.plan(1) send.mime.default_type = undefined - request(createServer({ root: fixtures })) + await request(createServer({ root: fixtures })) .get('/no_ext') .expect(shouldNotHaveHeader('Content-Type', t)) - .expect(200, err => t.error(err)) + .expect(200) }) - t.test('should return Content-Type without charset', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) + await t.test('should return Content-Type without charset', async function (t) { + await request(createServer({ root: fixtures })) .get('/images/node-js.png') .expect('Content-Type', 'image/png') - .expect(200, err => t.error(err)) + .expect(200) }) }) }) diff --git a/test/normalizeList.test.js b/test/normalizeList.test.js index 7473e9c..84b8216 100644 --- a/test/normalizeList.test.js +++ b/test/normalizeList.test.js @@ -1,28 +1,28 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const { normalizeList } = require('../lib/normalizeList') test('normalizeList', function (t) { const testCases = [ - [undefined, new Error('test must be array of strings or false')], + [undefined, new TypeError('test must be array of strings or false')], [false, []], [[], []], ['', ['']], [[''], ['']], [['a'], ['a']], ['a', ['a']], - [true, new Error('test must be array of strings or false')], - [1, new Error('test must be array of strings or false')], - [[1], new Error('test must be array of strings or false')] + [true, new TypeError('test must be array of strings or false')], + [1, new TypeError('test must be array of strings or false')], + [[1], new TypeError('test must be array of strings or false')] ] t.plan(testCases.length) - for (let i = 0; i < testCases.length; ++i) { - if (testCases[i][1] instanceof Error) { - t.throws(() => normalizeList(testCases[i][0], 'test'), testCases[i][1]) + for (const testCase of testCases) { + if (testCase[1] instanceof Error) { + t.assert.throws(() => normalizeList(testCase[0], 'test'), testCase[1]) } else { - t.strictSame(normalizeList(testCases[i][0], 'test'), testCases[i][1]) + t.assert.deepStrictEqual(normalizeList(testCase[0], 'test'), testCase[1]) } } }) diff --git a/test/parseBytesRange.test.js b/test/parseBytesRange.test.js index 0d075b7..dcbf325 100644 --- a/test/parseBytesRange.test.js +++ b/test/parseBytesRange.test.js @@ -1,103 +1,103 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const { parseBytesRange } = require('../lib/parseBytesRange') -test('parseBytesRange', function (t) { +test('parseBytesRange', async function (t) { t.plan(13) - t.test('should return empty array if all specified ranges are invalid', function (t) { + await t.test('should return empty array if all specified ranges are invalid', function (t) { t.plan(3) - t.strictSame(parseBytesRange(200, 'bytes=500-20'), []) - t.strictSame(parseBytesRange(200, 'bytes=500-999'), []) - t.strictSame(parseBytesRange(200, 'bytes=500-999,1000-1499'), []) + t.assert.deepStrictEqual(parseBytesRange(200, 'bytes=500-20'), []) + t.assert.deepStrictEqual(parseBytesRange(200, 'bytes=500-999'), []) + t.assert.deepStrictEqual(parseBytesRange(200, 'bytes=500-999,1000-1499'), []) }) - t.test('should parse str', function (t) { + await t.test('should parse str', function (t) { t.plan(2) const range = parseBytesRange(1000, 'bytes=0-499') - t.equal(range.length, 1) - t.strictSame(range[0], { start: 0, end: 499, index: 0 }) + t.assert.deepStrictEqual(range.length, 1) + t.assert.deepStrictEqual(range[0], { start: 0, end: 499, index: 0 }) }) - t.test('should cap end at size', function (t) { + await t.test('should cap end at size', function (t) { t.plan(2) const range = parseBytesRange(200, 'bytes=0-499') - t.equal(range.length, 1) - t.strictSame(range[0], { start: 0, end: 199, index: 0 }) + t.assert.deepStrictEqual(range.length, 1) + t.assert.deepStrictEqual(range[0], { start: 0, end: 199, index: 0 }) }) - t.test('should parse str', function (t) { + await t.test('should parse str', function (t) { t.plan(2) const range = parseBytesRange(1000, 'bytes=40-80') - t.equal(range.length, 1) - t.strictSame(range[0], { start: 40, end: 80, index: 0 }) + t.assert.deepStrictEqual(range.length, 1) + t.assert.deepStrictEqual(range[0], { start: 40, end: 80, index: 0 }) }) - t.test('should parse str asking for last n bytes', function (t) { + await t.test('should parse str asking for last n bytes', function (t) { t.plan(2) const range = parseBytesRange(1000, 'bytes=-400') - t.equal(range.length, 1) - t.strictSame(range[0], { start: 600, end: 999, index: 0 }) + t.assert.deepStrictEqual(range.length, 1) + t.assert.deepStrictEqual(range[0], { start: 600, end: 999, index: 0 }) }) - t.test('should parse str with only start', function (t) { + await t.test('should parse str with only start', function (t) { t.plan(2) const range = parseBytesRange(1000, 'bytes=400-') - t.equal(range.length, 1) - t.strictSame(range[0], { start: 400, end: 999, index: 0 }) + t.assert.deepStrictEqual(range.length, 1) + t.assert.deepStrictEqual(range[0], { start: 400, end: 999, index: 0 }) }) - t.test('should parse "bytes=0-"', function (t) { + await t.test('should parse "bytes=0-"', function (t) { t.plan(2) const range = parseBytesRange(1000, 'bytes=0-') - t.equal(range.length, 1) - t.strictSame(range[0], { start: 0, end: 999, index: 0 }) + t.assert.deepStrictEqual(range.length, 1) + t.assert.deepStrictEqual(range[0], { start: 0, end: 999, index: 0 }) }) - t.test('should parse str with no bytes', function (t) { + await t.test('should parse str with no bytes', function (t) { t.plan(2) const range = parseBytesRange(1000, 'bytes=0-0') - t.equal(range.length, 1) - t.strictSame(range[0], { start: 0, end: 0, index: 0 }) + t.assert.deepStrictEqual(range.length, 1) + t.assert.deepStrictEqual(range[0], { start: 0, end: 0, index: 0 }) }) - t.test('should parse str asking for last byte', function (t) { + await t.test('should parse str asking for last byte', function (t) { t.plan(2) const range = parseBytesRange(1000, 'bytes=-1') - t.equal(range.length, 1) - t.strictSame(range[0], { start: 999, end: 999, index: 0 }) + t.assert.deepStrictEqual(range.length, 1) + t.assert.deepStrictEqual(range[0], { start: 999, end: 999, index: 0 }) }) - t.test('should parse str with some invalid ranges', function (t) { + await t.test('should parse str with some invalid ranges', function (t) { t.plan(2) const range = parseBytesRange(200, 'bytes=0-499,1000-,500-999') - t.equal(range.length, 1) - t.strictSame(range[0], { start: 0, end: 199, index: 0 }) + t.assert.deepStrictEqual(range.length, 1) + t.assert.deepStrictEqual(range[0], { start: 0, end: 199, index: 0 }) }) - t.test('should combine overlapping ranges', function (t) { + await t.test('should combine overlapping ranges', function (t) { t.plan(3) const range = parseBytesRange(150, 'bytes=0-4,90-99,5-75,100-199,101-102') - t.equal(range.length, 2) - t.strictSame(range[0], { start: 0, end: 75, index: 0 }) - t.strictSame(range[1], { start: 90, end: 149, index: 1 }) + t.assert.deepStrictEqual(range.length, 2) + t.assert.deepStrictEqual(range[0], { start: 0, end: 75, index: 0 }) + t.assert.deepStrictEqual(range[1], { start: 90, end: 149, index: 1 }) }) - t.test('should retain original order /1', function (t) { + await t.test('should retain original order /1', function (t) { t.plan(3) const range = parseBytesRange(150, 'bytes=90-99,5-75,100-199,101-102,0-4') - t.equal(range.length, 2) - t.strictSame(range[0], { start: 90, end: 149, index: 0 }) - t.strictSame(range[1], { start: 0, end: 75, index: 1 }) + t.assert.deepStrictEqual(range.length, 2) + t.assert.deepStrictEqual(range[0], { start: 90, end: 149, index: 0 }) + t.assert.deepStrictEqual(range[1], { start: 0, end: 75, index: 1 }) }) - t.test('should retain original order /2', function (t) { + await t.test('should retain original order /2', function (t) { t.plan(4) const range = parseBytesRange(150, 'bytes=-1,20-100,0-1,101-120') - t.equal(range.length, 3) - t.strictSame(range[0], { start: 149, end: 149, index: 0 }) - t.strictSame(range[1], { start: 20, end: 120, index: 1 }) - t.strictSame(range[2], { start: 0, end: 1, index: 2 }) + t.assert.deepStrictEqual(range.length, 3) + t.assert.deepStrictEqual(range[0], { start: 149, end: 149, index: 0 }) + t.assert.deepStrictEqual(range[1], { start: 20, end: 120, index: 1 }) + t.assert.deepStrictEqual(range[2], { start: 0, end: 1, index: 2 }) }) }) diff --git a/test/send.1.test.js b/test/send.1.test.js index 68122a4..59243d9 100644 --- a/test/send.1.test.js +++ b/test/send.1.test.js @@ -1,557 +1,478 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const fs = require('node:fs') const http = require('node:http') const path = require('node:path') const request = require('supertest') -const send = require('..').send +const { send } = require('..') const { shouldNotHaveHeader, createServer } = require('./utils') // test server const fixtures = path.join(__dirname, 'fixtures') -test('send(file, options)', function (t) { +test('send(file, options)', async function (t) { t.plan(11) - t.test('acceptRanges', function (t) { + await t.test('acceptRanges', async function (t) { t.plan(6) - t.test('should support disabling accept-ranges', function (t) { - t.plan(2) + await t.test('should support disabling accept-ranges', async function (t) { + t.plan(1) - request(createServer({ acceptRanges: false, root: fixtures })) + await request(createServer({ acceptRanges: false, root: fixtures })) .get('/nums.txt') .expect(shouldNotHaveHeader('Accept-Ranges', t)) - .expect(200, err => t.error(err)) + .expect(200) }) - t.test('should ignore requested range', function (t) { - t.plan(3) + await t.test('should ignore requested range', async function (t) { + t.plan(2) - request(createServer({ acceptRanges: false, root: fixtures })) + await request(createServer({ acceptRanges: false, root: fixtures })) .get('/nums.txt') .set('Range', 'bytes=0-2') .expect(shouldNotHaveHeader('Accept-Ranges', t)) .expect(shouldNotHaveHeader('Content-Range', t)) - .expect(200, '123456789', err => t.error(err)) + .expect(200, '123456789') }) - t.test('should limit high return size /1', function (t) { - t.plan(4) + await t.test('should limit high return size /1', async function (t) { + t.plan(3) - request(createServer({ acceptRanges: true, maxContentRangeChunkSize: 1, root: fixtures })) + await request(createServer({ acceptRanges: true, maxContentRangeChunkSize: 1, root: fixtures })) .get('/nums.txt') .set('Range', 'bytes=0-2') - .expect((res) => t.equal(res.headers['accept-ranges'], 'bytes')) - .expect((res) => t.equal(res.headers['content-range'], 'bytes 0-0/9')) - .expect((res) => t.equal(res.headers['content-length'], '1', 'should content-length must be as same as maxContentRangeChunkSize')) - .expect(206, '1', (err) => t.error(err)) + .expect((res) => t.assert.deepStrictEqual(res.headers['accept-ranges'], 'bytes')) + .expect((res) => t.assert.deepStrictEqual(res.headers['content-range'], 'bytes 0-0/9')) + .expect((res) => t.assert.deepStrictEqual(res.headers['content-length'], '1', 'should content-length must be as same as maxContentRangeChunkSize')) + .expect(206, '1') }) - t.test('should limit high return size /2', function (t) { - t.plan(4) + await t.test('should limit high return size /2', async function (t) { + t.plan(3) - request(createServer({ acceptRanges: true, maxContentRangeChunkSize: 1, root: fixtures })) + await request(createServer({ acceptRanges: true, maxContentRangeChunkSize: 1, root: fixtures })) .get('/nums.txt') .set('Range', 'bytes=1-2') - .expect((res) => t.equal(res.headers['accept-ranges'], 'bytes')) - .expect((res) => t.equal(res.headers['content-range'], 'bytes 1-1/9')) - .expect((res) => t.equal(res.headers['content-length'], '1', 'should content-length must be as same as maxContentRangeChunkSize')) - .expect(206, '2', (err) => t.error(err)) + .expect((res) => t.assert.deepStrictEqual(res.headers['accept-ranges'], 'bytes')) + .expect((res) => t.assert.deepStrictEqual(res.headers['content-range'], 'bytes 1-1/9')) + .expect((res) => t.assert.deepStrictEqual(res.headers['content-length'], '1', 'should content-length must be as same as maxContentRangeChunkSize')) + .expect(206, '2') }) - t.test('should limit high return size /3', function (t) { - t.plan(4) + await t.test('should limit high return size /3', async function (t) { + t.plan(3) - request(createServer({ acceptRanges: true, maxContentRangeChunkSize: 1, root: fixtures })) + await request(createServer({ acceptRanges: true, maxContentRangeChunkSize: 1, root: fixtures })) .get('/nums.txt') .set('Range', 'bytes=1-3') - .expect((res) => t.equal(res.headers['accept-ranges'], 'bytes')) - .expect((res) => t.equal(res.headers['content-range'], 'bytes 1-1/9')) - .expect((res) => t.equal(res.headers['content-length'], '1', 'should content-length must be as same as maxContentRangeChunkSize')) - .expect(206, '2', (err) => t.error(err)) + .expect((res) => t.assert.deepStrictEqual(res.headers['accept-ranges'], 'bytes')) + .expect((res) => t.assert.deepStrictEqual(res.headers['content-range'], 'bytes 1-1/9')) + .expect((res) => t.assert.deepStrictEqual(res.headers['content-length'], '1', 'should content-length must be as same as maxContentRangeChunkSize')) + .expect(206, '2') }) - t.test('should limit high return size /4', function (t) { - t.plan(4) + await t.test('should limit high return size /4', async function (t) { + t.plan(3) - request(createServer({ acceptRanges: true, maxContentRangeChunkSize: 4, root: fixtures })) + await request(createServer({ acceptRanges: true, maxContentRangeChunkSize: 4, root: fixtures })) .get('/nums.txt') .set('Range', 'bytes=1-2,3-6') - .expect((res) => t.equal(res.headers['accept-ranges'], 'bytes')) - .expect((res) => t.equal(res.headers['content-range'], 'bytes 1-4/9')) - .expect((res) => t.equal(res.headers['content-length'], '4', 'should content-length must be as same as maxContentRangeChunkSize')) - .expect(206, '2345', (err) => t.error(err)) + .expect((res) => t.assert.deepStrictEqual(res.headers['accept-ranges'], 'bytes')) + .expect((res) => t.assert.deepStrictEqual(res.headers['content-range'], 'bytes 1-4/9')) + .expect((res) => t.assert.deepStrictEqual(res.headers['content-length'], '4', 'should content-length must be as same as maxContentRangeChunkSize')) + .expect(206, '2345') }) }) - t.test('cacheControl', function (t) { + await t.test('cacheControl', async function (t) { t.plan(2) - t.test('should support disabling cache-control', function (t) { - t.plan(2) - request(createServer({ cacheControl: false, root: fixtures })) + await t.test('should support disabling cache-control', async function (t) { + t.plan(1) + await request(createServer({ cacheControl: false, root: fixtures })) .get('/name.txt') .expect(shouldNotHaveHeader('Cache-Control', t)) - .expect(200, err => t.error(err)) + .expect(200) }) - t.test('should ignore maxAge option', function (t) { - t.plan(2) + await t.test('should ignore maxAge option', async function (t) { + t.plan(1) - request(createServer({ cacheControl: false, maxAge: 1000, root: fixtures })) + await request(createServer({ cacheControl: false, maxAge: 1000, root: fixtures })) .get('/name.txt') .expect(shouldNotHaveHeader('Cache-Control', t)) - .expect(200, err => t.error(err)) + .expect(200) }) }) - t.test('contentType', function (t) { + await t.test('contentType', async function (t) { t.plan(1) - t.test('should support disabling content-type', function (t) { - t.plan(2) + await t.test('should support disabling content-type', async function (t) { + t.plan(1) - request(createServer({ contentType: false, root: fixtures })) + await request(createServer({ contentType: false, root: fixtures })) .get('/name.txt') .expect(shouldNotHaveHeader('Content-Type', t)) - .expect(200, err => t.error(err)) + .expect(200) }) }) - t.test('etag', function (t) { + await t.test('etag', async function (t) { t.plan(1) - t.test('should support disabling etags', function (t) { - t.plan(2) + await t.test('should support disabling etags', async function (t) { + t.plan(1) - request(createServer({ etag: false, root: fixtures })) + await request(createServer({ etag: false, root: fixtures })) .get('/name.txt') .expect(shouldNotHaveHeader('ETag', t)) - .expect(200, err => t.error(err)) + .expect(200) }) }) - t.test('extensions', function (t) { + await t.test('extensions', async function (t) { t.plan(9) - t.test('should reject numbers', function (t) { - t.plan(1) - - request(createServer({ extensions: 42, root: fixtures })) + await t.test('should reject numbers', async function (t) { + await request(createServer({ extensions: 42, root: fixtures })) .get('/pets/') - .expect(500, /TypeError: extensions option/, err => t.error(err)) + .expect(500, /TypeError: extensions option/) }) - t.test('should reject true', function (t) { - t.plan(1) - - request(createServer({ extensions: true, root: fixtures })) + await t.test('should reject true', async function (t) { + await request(createServer({ extensions: true, root: fixtures })) .get('/pets/') - .expect(500, /TypeError: extensions option/, err => t.error(err)) + .expect(500, /TypeError: extensions option/) }) - t.test('should be not be enabled by default', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) + await t.test('should be not be enabled by default', async function (t) { + await request(createServer({ root: fixtures })) .get('/tobi') - .expect(404, err => t.error(err)) + .expect(404) }) - t.test('should be configurable', function (t) { - t.plan(1) - - request(createServer({ extensions: 'txt', root: fixtures })) + await t.test('should be configurable', async function (t) { + await request(createServer({ extensions: 'txt', root: fixtures })) .get('/name') - .expect(200, 'tobi', err => t.error(err)) + .expect(200, 'tobi') }) - t.test('should support disabling extensions', function (t) { - t.plan(1) - - request(createServer({ extensions: false, root: fixtures })) + await t.test('should support disabling extensions', async function (t) { + await request(createServer({ extensions: false, root: fixtures })) .get('/name') - .expect(404, err => t.error(err)) + .expect(404) }) - t.test('should support fallbacks', function (t) { - t.plan(1) - - request(createServer({ extensions: ['htm', 'html', 'txt'], root: fixtures })) + await t.test('should support fallbacks', async function (t) { + await request(createServer({ extensions: ['htm', 'html', 'txt'], root: fixtures })) .get('/name') - .expect(200, '

tobi

', err => t.error(err)) + .expect(200, '

tobi

') }) - t.test('should 404 if nothing found', function (t) { - t.plan(1) - - request(createServer({ extensions: ['htm', 'html', 'txt'], root: fixtures })) + await t.test('should 404 if nothing found', async function (t) { + await request(createServer({ extensions: ['htm', 'html', 'txt'], root: fixtures })) .get('/bob') - .expect(404, err => t.error(err)) + .expect(404) }) - t.test('should skip directories', function (t) { - t.plan(1) - - request(createServer({ extensions: ['file', 'dir'], root: fixtures })) + await t.test('should skip directories', async function (t) { + await request(createServer({ extensions: ['file', 'dir'], root: fixtures })) .get('/name') - .expect(404, err => t.error(err)) + .expect(404) }) - t.test('should not search if file has extension', function (t) { - t.plan(1) - - request(createServer({ extensions: 'html', root: fixtures })) + 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, err => t.error(err)) + .expect(404) }) }) - t.test('lastModified', function (t) { + await t.test('lastModified', async function (t) { t.plan(1) - t.test('should support disabling last-modified', function (t) { - t.plan(2) + await t.test('should support disabling last-modified', async function (t) { + t.plan(1) - request(createServer({ lastModified: false, root: fixtures })) + await request(createServer({ lastModified: false, root: fixtures })) .get('/name.txt') .expect(shouldNotHaveHeader('Last-Modified', t)) - .expect(200, err => t.error(err)) + .expect(200) }) }) - t.test('dotfiles', function (t) { + await t.test('dotfiles', async function (t) { t.plan(5) - t.test('should default to "ignore"', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) + await t.test('should default to "ignore"', async function (t) { + await request(createServer({ root: fixtures })) .get('/.hidden.txt') - .expect(404, err => t.error(err)) + .expect(404) }) - t.test('should reject bad value', function (t) { - t.plan(1) - - request(createServer({ dotfiles: 'bogus' })) + await t.test('should reject bad value', async function (t) { + await request(createServer({ dotfiles: 'bogus' })) .get('/name.txt') - .expect(500, /dotfiles/, err => t.error(err)) + .expect(500, /dotfiles/) }) - t.test('when "allow"', function (t) { + await t.test('when "allow"', async function (t) { t.plan(3) - t.test('should send dotfile', function (t) { - t.plan(1) - request(createServer({ dotfiles: 'allow', root: fixtures })) + await t.test('should send dotfile', async function (t) { + await request(createServer({ dotfiles: 'allow', root: fixtures })) .get('/.hidden.txt') - .expect(200, 'secret', err => t.error(err)) + .expect(200, 'secret') }) - t.test('should send within dotfile directory', function (t) { - t.plan(1) - request(createServer({ dotfiles: 'allow', root: fixtures })) + 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/, err => t.error(err)) + .expect(200, /tobi/) }) - t.test('should 404 for non-existent dotfile', function (t) { - t.plan(1) - request(createServer({ dotfiles: 'allow', root: fixtures })) + await t.test('should 404 for non-existent dotfile', async function (t) { + await request(createServer({ dotfiles: 'allow', root: fixtures })) .get('/.nothere') - .expect(404, err => t.error(err)) + .expect(404) }) }) - t.test('when "deny"', function (t) { + await t.test('when "deny"', async function (t) { t.plan(10) - t.test('should 403 for dotfile', function (t) { - t.plan(1) - request(createServer({ dotfiles: 'deny', root: fixtures })) + await t.test('should 403 for dotfile', async function (t) { + await request(createServer({ dotfiles: 'deny', root: fixtures })) .get('/.hidden.txt') - .expect(403, err => t.error(err)) + .expect(403) }) - t.test('should 403 for dotfile directory', function (t) { - t.plan(1) - request(createServer({ dotfiles: 'deny', root: fixtures })) + await t.test('should 403 for dotfile directory', async function (t) { + await request(createServer({ dotfiles: 'deny', root: fixtures })) .get('/.mine') - .expect(403, err => t.error(err)) + .expect(403) }) - t.test('should 403 for dotfile directory with trailing slash', function (t) { - t.plan(1) - request(createServer({ dotfiles: 'deny', root: fixtures })) + 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, err => t.error(err)) + .expect(403) }) - t.test('should 403 for file within dotfile directory', function (t) { - t.plan(1) - request(createServer({ dotfiles: 'deny', root: fixtures })) + 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, err => t.error(err)) + .expect(403) }) - t.test('should 403 for non-existent dotfile', function (t) { - t.plan(1) - request(createServer({ dotfiles: 'deny', root: fixtures })) + await t.test('should 403 for non-existent dotfile', async function (t) { + await request(createServer({ dotfiles: 'deny', root: fixtures })) .get('/.nothere') - .expect(403, err => t.error(err)) + .expect(403) }) - t.test('should 403 for non-existent dotfile directory', function (t) { - t.plan(1) - request(createServer({ dotfiles: 'deny', root: fixtures })) + 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, err => t.error(err)) + .expect(403) }) - t.test('should 403 for dotfile in directory', function (t) { - t.plan(1) - request(createServer({ dotfiles: 'deny', root: fixtures })) + 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, err => t.error(err)) + .expect(403) }) - t.test('should 403 for dotfile in dotfile directory', function (t) { - t.plan(1) - request(createServer({ dotfiles: 'deny', root: fixtures })) + 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, err => t.error(err)) + .expect(403) }) - t.test('should send files in root dotfile directory', function (t) { - t.plan(1) - request(createServer({ dotfiles: 'deny', root: path.join(fixtures, '.mine') })) + 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/, err => t.error(err)) + .expect(200, /tobi/) }) - t.test('should 403 for dotfile without root', function (t) { - t.plan(1) + 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) }) - request(server) + await request(server) .get('/name.txt') - .expect(403, err => t.error(err)) + .expect(403) }) }) - t.test('when "ignore"', function (t) { + await t.test('when "ignore"', async function (t) { t.plan(8) - t.test('should 404 for dotfile', function (t) { - t.plan(1) - - request(createServer({ dotfiles: 'ignore', root: fixtures })) + await t.test('should 404 for dotfile', async function (t) { + await request(createServer({ dotfiles: 'ignore', root: fixtures })) .get('/.hidden.txt') - .expect(404, err => t.error(err)) + .expect(404) }) - t.test('should 404 for dotfile directory', function (t) { - t.plan(1) - - request(createServer({ dotfiles: 'ignore', root: fixtures })) + await t.test('should 404 for dotfile directory', async function (t) { + await request(createServer({ dotfiles: 'ignore', root: fixtures })) .get('/.mine') - .expect(404, err => t.error(err)) + .expect(404) }) - t.test('should 404 for dotfile directory with trailing slash', function (t) { - t.plan(1) - - request(createServer({ dotfiles: 'ignore', root: fixtures })) + 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, err => t.error(err)) + .expect(404) }) - t.test('should 404 for file within dotfile directory', function (t) { - t.plan(1) - - request(createServer({ dotfiles: 'ignore', root: fixtures })) + 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, err => t.error(err)) + .expect(404) }) - t.test('should 404 for non-existent dotfile', function (t) { - t.plan(1) - - request(createServer({ dotfiles: 'ignore', root: fixtures })) + await t.test('should 404 for non-existent dotfile', async function (t) { + await request(createServer({ dotfiles: 'ignore', root: fixtures })) .get('/.nothere') - .expect(404, err => t.error(err)) + .expect(404) }) - t.test('should 404 for non-existent dotfile directory', function (t) { - t.plan(1) - - request(createServer({ dotfiles: 'ignore', root: fixtures })) + 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, err => t.error(err)) + .expect(404) }) - t.test('should send files in root dotfile directory', function (t) { - t.plan(1) - - request(createServer({ dotfiles: 'ignore', root: path.join(fixtures, '.mine') })) + 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/, err => t.error(err)) + .expect(200, /tobi/) }) - t.test('should 404 for dotfile without root', function (t) { - t.plan(1) - + 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) }) - request(server) + await request(server) .get('/name.txt') - .expect(404, err => t.error(err)) + .expect(404) }) }) }) - t.test('immutable', function (t) { + await t.test('immutable', async function (t) { t.plan(2) - t.test('should default to false', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) + 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', err => t.error(err)) + .expect('Cache-Control', 'public, max-age=0') }) - t.test('should set immutable directive in Cache-Control', function (t) { - t.plan(1) - - request(createServer({ immutable: true, maxAge: '1h', root: fixtures })) + 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', err => t.error(err)) + .expect('Cache-Control', 'public, max-age=3600, immutable') }) }) - t.test('maxAge', function (t) { + await t.test('maxAge', async function (t) { t.plan(4) - t.test('should default to 0', function (t) { - t.plan(1) - request(createServer({ root: fixtures })) + 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', err => t.error(err)) + .expect('Cache-Control', 'public, max-age=0') }) - t.test('should floor to integer', function (t) { - t.plan(1) - request(createServer({ maxAge: 123956, root: fixtures })) + 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', err => t.error(err)) + .expect('Cache-Control', 'public, max-age=123') }) - t.test('should accept string', function (t) { - t.plan(1) - request(createServer({ maxAge: '30d', root: fixtures })) + 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', err => t.error(err)) + .expect('Cache-Control', 'public, max-age=2592000') }) - t.test('should max at 1 year', function (t) { - t.plan(1) - request(createServer({ maxAge: '2y', root: fixtures })) + 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', err => t.error(err)) + .expect('Cache-Control', 'public, max-age=31536000') }) }) - t.test('index', function (t) { + await t.test('index', async function (t) { t.plan(10) - t.test('should reject numbers', function (t) { - t.plan(1) - - request(createServer({ root: fixtures, index: 42 })) + await t.test('should reject numbers', async function (t) { + await request(createServer({ root: fixtures, index: 42 })) .get('/pets/') - .expect(500, /TypeError: index option/, err => t.error(err)) + .expect(500, /TypeError: index option/) }) - t.test('should reject true', function (t) { - t.plan(1) - - request(createServer({ root: fixtures, index: true })) + await t.test('should reject true', async function (t) { + await request(createServer({ root: fixtures, index: true })) .get('/pets/') - .expect(500, /TypeError: index option/, err => t.error(err)) + .expect(500, /TypeError: index option/) }) - t.test('should default to index.html', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) + 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'), err => t.error(err)) + .expect(fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8')) }) - t.test('should be configurable', function (t) { - t.plan(1) - - request(createServer({ root: fixtures, index: 'tobi.html' })) + await t.test('should be configurable', async function (t) { + await request(createServer({ root: fixtures, index: 'tobi.html' })) .get('/') - .expect(200, '

tobi

', err => t.error(err)) + .expect(200, '

tobi

') }) - t.test('should support disabling', function (t) { - t.plan(1) - - request(createServer({ root: fixtures, index: false })) + await t.test('should support disabling', async function (t) { + await request(createServer({ root: fixtures, index: false })) .get('/pets/') - .expect(403, err => t.error(err)) + .expect(403) }) - t.test('should support fallbacks', function (t) { - t.plan(1) - - request(createServer({ root: fixtures, index: ['default.htm', 'index.html'] })) + 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'), err => t.error(err)) + .expect(200, fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8')) }) - t.test('should 404 if no index file found (file)', function (t) { - t.plan(1) - - request(createServer({ root: fixtures, index: 'default.htm' })) + 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, err => t.error(err)) + .expect(404) }) - t.test('should 404 if no index file found (dir)', function (t) { - t.plan(1) - - request(createServer({ root: fixtures, index: 'pets' })) + await t.test('should 404 if no index file found (dir)', async function (t) { + await request(createServer({ root: fixtures, index: 'pets' })) .get('/') - .expect(404, err => t.error(err)) + .expect(404) }) - t.test('should not follow directories', function (t) { - t.plan(1) - - request(createServer({ root: fixtures, index: ['pets', 'name.txt'] })) + await t.test('should not follow directories', async function (t) { + await request(createServer({ root: fixtures, index: ['pets', 'name.txt'] })) .get('/') - .expect(200, 'tobi', err => t.error(err)) + .expect(200, 'tobi') }) - t.test('should work without root', function (t) { - t.plan(1) - + 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'] }) @@ -559,51 +480,46 @@ test('send(file, options)', function (t) { stream.pipe(res) }) - request(server) + await request(server) .get('/') - .expect(200, /tobi/, err => t.error(err)) + .expect(200, /tobi/) }) }) - t.test('root', function (t) { + await t.test('root', async function (t) { t.plan(2) - t.test('when given', function (t) { + await t.test('when given', async function (t) { t.plan(8) - t.test('should join root', function (t) { - t.plan(1) - request(createServer({ root: fixtures })) + await t.test('should join root', async function (t) { + await request(createServer({ root: fixtures })) .get('/pets/../name.txt') - .expect(200, 'tobi', err => t.error(err)) + .expect(200, 'tobi') }) - t.test('should work with trailing slash', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/name.txt') - .expect(200, 'tobi', err => t.error(err)) + .expect(200, 'tobi') }) - t.test('should work with empty path', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/name.txt') - .expect(301, /Redirecting to/, err => t.error(err)) + .expect(301, /Redirecting to/) }) // @@ -612,88 +528,74 @@ test('send(file, options)', function (t) { // are doing, so this will prevent unseen // regressions around this use-case. // - t.test('should try as file with empty path', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/') - .expect(200, 'tobi', err => t.error(err)) + .expect(200, 'tobi') }) - t.test('should restrict paths to within root', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) + await t.test('should restrict paths to within root', async function (t) { + await request(createServer({ root: fixtures })) .get('/pets/../../send.js') - .expect(403, err => t.error(err)) + .expect(403) }) - t.test('should allow .. in root', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/pets/../../send.js') - .expect(403, err => t.error(err)) + .expect(403) }) - t.test('should not allow root transversal', function (t) { - t.plan(1) - - request(createServer({ root: path.join(fixtures, 'name.d') })) + 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, err => t.error(err)) + .expect(403) }) - t.test('should not allow root path disclosure', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) + await t.test('should not allow root path disclosure', async function (t) { + await request(createServer({ root: fixtures })) .get('/pets/../../fixtures/name.txt') - .expect(403, err => t.error(err)) + .expect(403) }) }) - t.test('when missing', function (t) { + await t.test('when missing', async function (t) { t.plan(2) - t.test('should consider .. malicious', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/../send.js') - .expect(403, err => t.error(err)) + .expect(403) }) - t.test('should still serve files with dots in name', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/do..ts.txt') - .expect(200, '...', err => t.error(err)) + .expect(200, '...') }) }) }) diff --git a/test/send.2.test.js b/test/send.2.test.js index ba9205b..957d6f5 100644 --- a/test/send.2.test.js +++ b/test/send.2.test.js @@ -1,6 +1,6 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const http = require('node:http') const path = require('node:path') const request = require('supertest') @@ -10,98 +10,84 @@ const { shouldNotHaveBody, createServer, shouldNotHaveHeader } = require('./util const dateRegExp = /^\w{3}, \d+ \w+ \d+ \d+:\d+:\d+ \w+$/ const fixtures = path.join(__dirname, 'fixtures') -test('send(file)', function (t) { +test('send(file)', async function (t) { t.plan(22) - t.test('should stream the file contents', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/name.txt') .expect('Content-Length', '4') - .expect(200, 'tobi', err => t.error(err)) + .expect(200, 'tobi') }) - t.test('should stream a zero-length file', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/empty.txt') .expect('Content-Length', '0') - .expect(200, '', err => t.error(err)) + .expect(200, '') }) - t.test('should decode the given path as a URI', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/some%20thing.txt') - .expect(200, 'hey', err => t.error(err)) + .expect(200, 'hey') }) - t.test('should serve files with dots in name', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/do..ts.txt') - .expect(200, '...', err => t.error(err)) + .expect(200, '...') }) - t.test('should treat a malformed URI as a bad request', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/some%99thing.txt') - .expect(400, /Bad Request/, err => t.error(err)) + .expect(400, /Bad Request/) }) - t.test('should 400 on NULL bytes', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/some%00thing.txt') - .expect(400, /Bad Request/, err => t.error(err)) + .expect(400, /Bad Request/) }) - t.test('should treat an ENAMETOOLONG as a 404', function (t) { - t.plan(1) - + 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) @@ -109,13 +95,13 @@ test('send(file)', function (t) { }) const path = Array(100).join('foobar') - request(app) + await request(app) .get('/' + path) - .expect(404, err => t.error(err)) + .expect(404) }) - t.test('should support HEAD', function (t) { - t.plan(2) + 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 }) @@ -123,88 +109,74 @@ test('send(file)', function (t) { stream.pipe(res) }) - request(app) + await request(app) .head('/name.txt') .expect(200) .expect('Content-Length', '4') .expect(shouldNotHaveBody(t)) - .end(err => t.error(err)) }) - t.test('should add an ETag header field', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/name.txt') .expect('etag', /^W\/"[^"]+"$/) - .end(err => t.error(err)) }) - t.test('should add a Date header field', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/name.txt') - .expect('date', dateRegExp, err => t.error(err)) + .expect('date', dateRegExp) }) - t.test('should add a Last-Modified header field', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/name.txt') - .expect('last-modified', dateRegExp, err => t.error(err)) + .expect('last-modified', dateRegExp) }) - t.test('should add a Accept-Ranges header field', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/name.txt') - .expect('Accept-Ranges', 'bytes', err => t.error(err)) + .expect('Accept-Ranges', 'bytes') }) - t.test('should 404 if the file does not exist', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/meow') - .expect(404, /Not Found/, err => t.error(err)) + .expect(404, /Not Found/) }) - t.test('should 404 if the filename is too long', function (t) { - t.plan(1) - + 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) @@ -213,28 +185,24 @@ test('send(file)', function (t) { const longFilename = new Array(512).fill('a').join('') - request(app) + await request(app) .get('/' + longFilename) - .expect(404, /Not Found/, err => t.error(err)) + .expect(404, /Not Found/) }) - t.test('should 404 if the requested resource is not a directory', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/nums.txt/invalid') - .expect(404, /Not Found/, err => t.error(err)) + .expect(404, /Not Found/) }) - t.test('should not override content-type', function (t) { - t.plan(1) - + 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, { @@ -243,663 +211,572 @@ test('send(file)', function (t) { }) stream.pipe(res) }) - request(app) + await request(app) .get('/name.txt') - .expect('Content-Type', 'application/x-custom', err => t.error(err)) + .expect('Content-Type', 'application/x-custom') }) - t.test('should set Content-Type via mime map', function (t) { - t.plan(2) - + 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) }) - request(app) + await request(app) .get('/name.txt') .expect('Content-Type', 'text/plain; charset=utf-8') - .expect(200, function (err) { - t.error(err) - request(app) - .get('/tobi.html') - .expect('Content-Type', 'text/html; charset=utf-8') - .expect(200, err => t.error(err)) - }) + .expect(200) + + await request(app) + .get('/tobi.html') + .expect('Content-Type', 'text/html; charset=utf-8') + .expect(200) }) - t.test('send directory', function (t) { + await t.test('send directory', async function (t) { t.plan(5) - t.test('should redirect directories to trailing slash', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) + await t.test('should redirect directories to trailing slash', async function (t) { + await request(createServer({ root: fixtures })) .get('/pets') .expect('Location', '/pets/') - .expect(301, err => t.error(err)) + .expect(301) }) - t.test('should respond with an HTML redirect', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) + 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\/ t.error(err)) + .expect(301, />Redirecting to \/pets\/ t.error(err)) + .expect(301) }) - t.test('should not redirect to protocol-relative locations', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) + 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, err => t.error(err)) + .expect(301) }) - t.test('should respond with an HTML redirect', function (t) { - t.plan(1) - + 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) }) - request(app) + 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\/ t.error(err)) + .expect(301, />Redirecting to \/snow%20%E2%98%83\/Not Found t.error(err)) + .expect(404, />Not Found t.error(err)) + .expect(404) }) }) - t.test('with conditional-GET', function (t) { + await t.test('with conditional-GET', async function (t) { t.plan(6) - t.test('should remove Content headers with 304', function (t) { - t.plan(2) - + 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') }) - request(server) + const res = await request(server) .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(server) - .get('/name.txt') - .set('If-None-Match', res.headers.etag) - .expect('Content-Location', 'http://localhost/name.txt') - .expect('Contents', 'foo') - .expect(304, err => t.error(err)) - }) - }) + .expect(200) - t.test('should not remove all Content-* headers', function (t) { - t.plan(2) + 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\'') }) - request(server) + const res = await request(server) .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - 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, err => t.error(err)) - }) + .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) }) - t.test('where "If-Match" is set', function (t) { + await t.test('where "If-Match" is set', async function (t) { t.plan(4) - t.test('should respond with 200 when "*"', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/name.txt') .set('If-Match', '*') - .expect(200, err => t.error(err)) + .expect(200) }) - t.test('should respond with 412 when ETag unmatched', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/name.txt') .set('If-Match', ' "foo",, "bar" ,') - .expect(412, err => t.error(err)) + .expect(412) }) - t.test('should respond with 200 when ETag matched /1', function (t) { - t.plan(2) - + 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) }) - request(app) + const res = await request(app) .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-Match', '"foo", "bar", ' + res.headers.etag) - .expect(200, err => t.error(err)) - }) - }) + .expect(200) - t.test('should respond with 200 when ETag matched /2', function (t) { - t.plan(2) + 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) }) - request(app) + const res = await request(app) .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-Match', '"foo", ' + res.headers.etag + ', "bar"') - .expect(200, err => t.error(err)) - }) + .expect(200) + + await request(app) + .get('/name.txt') + .set('If-Match', '"foo", ' + res.headers.etag + ', "bar"') + .expect(200) }) }) - t.test('where "If-Modified-Since" is set', function (t) { + await t.test('where "If-Modified-Since" is set', async function (t) { t.plan(3) - t.test('should respond with 304 when unmodified', function (t) { - t.plan(2) - + 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) }) - request(app) + const res = await request(app) .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-Modified-Since', res.headers['last-modified']) - .expect(304, err => t.error(err)) - }) - }) + .expect(200) - t.test('should respond with 200 when modified', function (t) { - t.plan(2) + 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) }) - request(app) + const res = await request(app) .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - const lmod = new Date(res.headers['last-modified']) - const date = new Date(lmod - 60000) - request(app) - .get('/name.txt') - .set('If-Modified-Since', date.toUTCString()) - .expect(200, 'tobi', err => t.error(err)) - }) - }) + .expect(200) - t.test('should respond with 200 when modified', function (t) { - t.plan(2) + 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) }) - request(app) + const res = await request(app) .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-Modified-Since', res.headers['last-modified']) - .set('cache-control', 'no-cache') - .expect(200, 'tobi', err => t.error(err)) - }) + .expect(200) + + await request(app) + .get('/name.txt') + .set('If-Modified-Since', res.headers['last-modified']) + .set('cache-control', 'no-cache') + .expect(200, 'tobi') }) }) - t.test('where "If-None-Match" is set', function (t) { + await t.test('where "If-None-Match" is set', async function (t) { t.plan(6) - t.test('should respond with 304 when ETag matched', function (t) { - t.plan(2) - + 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) }) - request(app) + const res = await request(app) .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-None-Match', res.headers.etag) - .expect(304, err => t.error(err)) - }) - }) + .expect(200) - t.test('should respond with 200 when ETag unmatched', function (t) { - t.plan(2) + 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) }) - request(app) + await request(app) .get('/name.txt') - .expect(200, function (err) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-None-Match', '"123"') - .expect(200, 'tobi', err => t.error(err)) - }) - }) + .expect(200) - t.test('should respond with 200 when ETag is not generated', function (t) { - t.plan(2) + 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) }) - request(app) + await request(app) .get('/name.txt') - .expect(200, function (err) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-None-Match', '"123"') - .expect(200, 'tobi', err => t.error(err)) - }) - }) + .expect(200) - t.test('should respond with 306 Not Modified when using wildcard * on existing file', function (t) { - t.plan(2) + 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) }) - request(app) + await request(app) .get('/name.txt') - .expect(200, function (err) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-None-Match', '*') - .expect(304, '', err => t.error(err)) - }) - }) + .expect(200) - t.test('should respond with 404 Not Found when using wildcard * on non-existing file', function (t) { - t.plan(1) + 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) }) - request(app) + await request(app) .get('/asdf.txt') .set('If-None-Match', '*') - .expect(404, /Not Found/, err => t.error(err)) + .expect(404, /Not Found/) }) - t.test('should respond with 200 cache-control is set to no-cache', function (t) { - t.plan(2) - + 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) }) - request(app) + const res = await request(app) .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-None-Match', res.headers.etag) - .set('cache-control', 'no-cache') - .expect(200, 'tobi', err => t.error(err)) - }) + .expect(200) + + await request(app) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .set('cache-control', 'no-cache') + .expect(200, 'tobi') }) }) - t.test('where "If-Unmodified-Since" is set', function (t) { + await t.test('where "If-Unmodified-Since" is set', async function (t) { t.plan(3) - t.test('should respond with 200 when unmodified', function (t) { - t.plan(2) - + 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) }) - request(app) + const res = await request(app) .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-Unmodified-Since', res.headers['last-modified']) - .expect(200, err => t.error(err)) - }) - }) + .expect(200) - t.test('should respond with 412 when modified', function (t) { - t.plan(2) + 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) }) - request(app) + const res = await request(app) .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - const lmod = new Date(res.headers['last-modified']) - const date = new Date(lmod - 60000).toUTCString() - request(app) - .get('/name.txt') - .set('If-Unmodified-Since', date) - .expect(412, err => t.error(err)) - }) - }) + .expect(200) - t.test('should respond with 200 when invalid date', function (t) { - t.plan(1) + 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) }) - request(app) + await request(app) .get('/name.txt') .set('If-Unmodified-Since', 'foo') - .expect(200, err => t.error(err)) + .expect(200) }) }) }) - t.test('with Range request', function (t) { + await t.test('with Range request', async function (t) { t.plan(13) - t.test('should support byte ranges', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/nums.txt') .set('Range', 'bytes=0-4') - .expect(206, '12345', err => t.error(err)) + .expect(206, '12345') }) - t.test('should ignore non-byte ranges', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/nums.txt') .set('Range', 'items=0-4') - .expect(200, '123456789', err => t.error(err)) + .expect(200, '123456789') }) - t.test('should be inclusive', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/nums.txt') .set('Range', 'bytes=0-0') - .expect(206, '1', err => t.error(err)) + .expect(206, '1') }) - t.test('should set Content-Range', function (t) { - t.plan(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) }) - request(app) + await request(app) .get('/nums.txt') .set('Range', 'bytes=2-5') .expect('Content-Range', 'bytes 2-5/9') - .expect(206, err => t.error(err)) + .expect(206) }) - t.test('should support -n', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/nums.txt') .set('Range', 'bytes=-3') - .expect(206, '789', err => t.error(err)) + .expect(206, '789') }) - t.test('should support n-', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/nums.txt') .set('Range', 'bytes=3-') - .expect(206, '456789', err => t.error(err)) + .expect(206, '456789') }) - t.test('should respond with 206 "Partial Content"', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/nums.txt') .set('Range', 'bytes=0-4') - .expect(206, err => t.error(err)) + .expect(206) }) - t.test('should set Content-Length to the # of octets transferred', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/nums.txt') .set('Range', 'bytes=2-3') .expect('Content-Length', '2') - .expect(206, '34', err => t.error(err)) + .expect(206, '34') }) - t.test('when last-byte-pos of the range is greater the length', function (t) { + await t.test('when last-byte-pos of the range is greater the length', async function (t) { t.plan(2) - t.test('is taken to be equal to one less than the length', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/nums.txt') .set('Range', 'bytes=2-50') .expect('Content-Range', 'bytes 2-8/9') - .expect(206, err => t.error(err)) + .expect(206) }) - t.test('should adapt the Content-Length accordingly', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/nums.txt') .set('Range', 'bytes=2-50') .expect('Content-Length', '7') - .expect(206, err => t.error(err)) + .expect(206) }) }) - t.test('when the first- byte-pos of the range is greater length', function (t) { + await t.test('when the first- byte-pos of the range is greater length', async function (t) { t.plan(2) - t.test('should respond with 416', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/nums.txt') .set('Range', 'bytes=9-50') .expect('Content-Range', 'bytes */9') - .expect(416, err => t.error(err)) + .expect(416) }) - t.test('should emit error 416 with content-range header', function (t) { - t.plan(1) - + 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, { @@ -909,38 +786,36 @@ test('send(file)', function (t) { stream.pipe(res) }) - request(server) + await request(server) .get('/nums.txt') .set('Range', 'bytes=9-50') .expect('X-Content-Range', 'bytes */9') - .expect(416, err => t.error(err)) + .expect(416) }) }) - t.test('when syntactically invalid', function (t) { + await t.test('when syntactically invalid', async function (t) { t.plan(1) - t.test('should respond with 200 and the entire contents', 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) }) - request(app) + await request(app) .get('/nums.txt') .set('Range', 'asdf') - .expect(200, '123456789', err => t.error(err)) + .expect(200, '123456789') }) }) - t.test('when multiple ranges', function (t) { + await t.test('when multiple ranges', async function (t) { t.plan(2) - t.test('should respond with 200 and the entire contents', 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 }) @@ -948,179 +823,155 @@ test('send(file)', function (t) { stream.pipe(res) }) - request(app) + await request(app) .get('/nums.txt') .set('Range', 'bytes=1-1,3-') .expect(shouldNotHaveHeader('Content-Range', t)) - .expect(200, '123456789', err => t.error(err)) + .expect(200, '123456789') }) - t.test('should respond with 206 is all ranges can be combined', function (t) { - t.plan(1) - + 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) }) - request(app) + await request(app) .get('/nums.txt') .set('Range', 'bytes=1-2,3-5') .expect('Content-Range', 'bytes 1-5/9') - .expect(206, '23456', err => t.error(err)) + .expect(206, '23456') }) }) - t.test('when if-range present', function (t) { + await t.test('when if-range present', async function (t) { t.plan(5) - t.test('should respond with parts when etag unchanged', function (t) { - t.plan(2) - + 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) }) - request(app) + const res = await request(app) .get('/nums.txt') - .expect(200, function (err, res) { - t.error(err) - const etag = res.headers.etag - - request(app) - .get('/nums.txt') - .set('If-Range', etag) - .set('Range', 'bytes=0-0') - .expect(206, '1', err => t.error(err)) - }) - }) + .expect(200) + + const etag = res.headers.etag - t.test('should respond with 200 when etag changed', function (t) { - t.plan(2) + 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) }) - request(app) + const res = await request(app) .get('/nums.txt') - .expect(200, function (err, res) { - t.error(err) - const etag = res.headers.etag.replace(/"(.)/, '"0$1') - - request(app) - .get('/nums.txt') - .set('If-Range', etag) - .set('Range', 'bytes=0-0') - .expect(200, '123456789', err => t.error(err)) - }) - }) + .expect(200) - t.test('should respond with parts when modified unchanged', function (t) { - t.plan(2) + 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) }) - request(app) + const res = await request(app) .get('/nums.txt') - .expect(200, function (err, res) { - t.error(err) - const modified = res.headers['last-modified'] - - request(app) - .get('/nums.txt') - .set('If-Range', modified) - .set('Range', 'bytes=0-0') - .expect(206, '1', err => t.error(err)) - }) - }) + .expect(200) - t.test('should respond with 200 when modified changed', function (t) { - t.plan(2) + 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) }) - request(app) + const res = await request(app) .get('/nums.txt') - .expect(200, function (err, res) { - t.error(err) - const 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', err => t.error(err)) - }) - }) + .expect(200) - t.test('should respond with 200 when invalid value', function (t) { - t.plan(1) + 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) }) - request(app) + await request(app) .get('/nums.txt') .set('If-Range', 'foo') .set('Range', 'bytes=0-0') - .expect(200, '123456789', err => t.error(err)) + .expect(200, '123456789') }) }) }) - t.test('when "options" is specified', function (t) { + await t.test('when "options" is specified', async function (t) { t.plan(4) - t.test('should support start/end', function (t) { - t.plan(1) - - request(createServer({ root: fixtures, start: 3, end: 5 })) + 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', err => t.error(err)) + .expect(200, '456') }) - t.test('should adjust too large end', function (t) { - t.plan(1) - - request(createServer({ root: fixtures, start: 3, end: 90 })) + 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', err => t.error(err)) + .expect(200, '456789') }) - t.test('should support start/end with Range request', function (t) { - t.plan(1) - - request(createServer({ root: fixtures, start: 0, end: 2 })) + 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', err => t.error(err)) + .expect(206, '23') }) - t.test('should support start/end with unsatisfiable Range request', function (t) { - t.plan(1) - - request(createServer({ root: fixtures, start: 0, end: 2 })) + 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, err => t.error(err)) + .expect(416) }) }) }) diff --git a/test/send.3.test.js b/test/send.3.test.js index 5b02e43..f6f0911 100644 --- a/test/send.3.test.js +++ b/test/send.3.test.js @@ -1,6 +1,6 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const http = require('node:http') const path = require('node:path') const request = require('supertest') @@ -9,72 +9,70 @@ const send = require('../lib/send').send const fixtures = path.join(__dirname, 'fixtures') -test('send(file)', function (t) { +test('send(file)', async function (t) { t.plan(5) - t.test('file type', function (t) { - t.plan(6) + 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.equal(type, 'file') - t.ok(metadata.path) - t.ok(metadata.stat) - t.notOk(metadata.error) - t.notOk(metadata.requestPath) + 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) }) - request(app) + await request(app) .get('/name.txt') .expect('Content-Length', '4') - .expect(200, 'tobi', err => t.error(err)) + .expect(200, 'tobi') }) - t.test('directory type', function (t) { - t.plan(6) + 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.equal(type, 'directory') - t.ok(metadata.path) - t.notOk(metadata.stat) - t.notOk(metadata.error) - t.ok(metadata.requestPath) + 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) }) - request(app) + await request(app) .get('/pets') .expect('Location', '/pets/') - .expect(301, err => t.error(err)) + .expect(301) }) - t.test('error type', function (t) { - t.plan(6) + 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.equal(type, 'error') - t.notOk(metadata.path) - t.notOk(metadata.stat) - t.ok(metadata.error) - t.notOk(metadata.requestPath) + 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') - request(app) + await request(app) .get('/' + path) - .expect(404, err => t.error(err)) + .expect(404) }) - t.test('custom directory index view', function (t) { - t.plan(1) - + 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') { @@ -87,15 +85,13 @@ test('send(file)', function (t) { } }) - request(app) + await request(app) .get('/pets') .expect('Content-Type', 'text/plain; charset=utf-8') - .expect(200, '.hidden.txt\nindex.html\n', err => t.error(err)) + .expect(200, '.hidden.txt\nindex.html\n') }) - t.test('serving from a root directory with custom error-handling', function (t) { - t.plan(3) - + 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) { @@ -120,18 +116,18 @@ test('send(file)', function (t) { } }) - request(app) + await request(app) .get('/pets') .expect('Location', '/pets/') - .expect(301, err => t.error(err)) + .expect(301) - request(app) + await request(app) .get('/not-exists') - .expect(404, err => t.error(err)) + .expect(404) - request(app) + await request(app) .get('/pets/index.html') .expect('Content-Disposition', 'attachment') - .expect(200, err => t.error(err)) + .expect(200) }) }) diff --git a/test/utils.js b/test/utils.js index b68ea51..218eba7 100644 --- a/test/utils.js +++ b/test/utils.js @@ -5,20 +5,20 @@ const send = require('..') module.exports.shouldNotHaveHeader = function shouldNotHaveHeader (header, t) { return function (res) { - t.ok(!(header.toLowerCase() in res.headers), 'should not have header ' + header) + t.assert.ok(!(header.toLowerCase() in res.headers), 'should not have header ' + header) } } module.exports.shouldHaveHeader = function shouldHaveHeader (header, t) { return function (res) { - t.ok((header.toLowerCase() in res.headers), 'should have header ' + header) + 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 && fn(req, res) + fn?.(req, res) const { statusCode, headers, stream } = await send(req, req.url, opts) res.writeHead(statusCode, headers) stream.pipe(res) @@ -31,6 +31,6 @@ module.exports.createServer = function createServer (opts, fn) { module.exports.shouldNotHaveBody = function shouldNotHaveBody (t) { return function (res) { - t.ok(res.text === '' || res.text === undefined) + t.assert.ok(res.text === '' || res.text === undefined) } } From 682f0f84af7ae8cb6a9c64534a61a2bc602af5d4 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Sun, 9 Feb 2025 17:16:37 +0000 Subject: [PATCH 095/109] 4.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 35b56d5..aefa950 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@fastify/send", "description": "Better streaming static file server with Range and conditional-GET support", - "version": "3.3.1", + "version": "4.0.0", "author": "TJ Holowaychuk ", "contributors": [ "Douglas Christopher Wilson ", From 7bdf4897617b4a35ed3e5062bfe00235c82442f6 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Fri, 7 Mar 2025 19:05:32 +0000 Subject: [PATCH 096/109] ci(ci): set job permissions (#107) --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 496e8b9..f9fae55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,9 @@ on: jobs: test: + permissions: + contents: write + pull-requests: write uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5 with: license-check: true From f76c3245d0144da51a7efc9d982cf782b3a13661 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Sun, 30 Mar 2025 21:52:54 +0100 Subject: [PATCH 097/109] ci: set permissions at workflow level (#108) --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9fae55..d2a8b62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,11 +14,11 @@ on: - 'docs/**' - '*.md' +permissions: + contents: read + jobs: test: - permissions: - contents: write - pull-requests: write uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5 with: license-check: true From ef995556af28bad0743cae9c8b9fd8b7fa160fe2 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Sun, 30 Mar 2025 22:38:57 +0100 Subject: [PATCH 098/109] ci: restore job level permissions (#109) --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2a8b62..fd45202 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,9 @@ permissions: jobs: test: + permissions: + contents: write + pull-requests: write uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5 with: license-check: true From bb6ccc1f3eaf43ffabf41a0e50e19fc87bd80d52 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 May 2025 05:25:02 +0000 Subject: [PATCH 099/109] build(deps-dev): bump tsd from 0.31.2 to 0.32.0 (#111) Bumps [tsd](https://github.com/tsdjs/tsd) from 0.31.2 to 0.32.0. - [Release notes](https://github.com/tsdjs/tsd/releases) - [Commits](https://github.com/tsdjs/tsd/compare/v0.31.2...v0.32.0) --- updated-dependencies: - dependency-name: tsd dependency-version: 0.32.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index aefa950..a866c1d 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "eslint": "^9.17.0", "neostandard": "^0.12.0", "supertest": "6.3.4", - "tsd": "^0.31.0" + "tsd": "^0.32.0" }, "scripts": { "lint": "eslint", From c2760b14ae65cf52d9a90173a31a50cca07099bb Mon Sep 17 00:00:00 2001 From: Johan Legrand Date: Tue, 3 Jun 2025 14:22:22 +0200 Subject: [PATCH 100/109] feat: add highWaterMark option (#113) --- README.md | 7 +++++++ lib/send.js | 6 ++++++ test/send.1.test.js | 46 ++++++++++++++++++++++++++++++++++++++++++++- types/index.d.ts | 6 ++++++ 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 75dfc39..cf351e4 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,13 @@ 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. +##### highWaterMark + +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. + ### .mime The `mime` export is the global instance of the diff --git a/lib/send.js b/lib/send.js index 096f64e..a0f4a50 100644 --- a/lib/send.js +++ b/lib/send.js @@ -142,6 +142,10 @@ function normalizeOptions (options) { ? resolve(options.root) : null + const highWaterMark = Number.isSafeInteger(options.highWaterMark) && options.highWaterMark > 0 + ? options.highWaterMark + : null + return { acceptRanges, cacheControl, @@ -155,6 +159,7 @@ function normalizeOptions (options) { maxage, maxContentRangeChunkSize, root, + highWaterMark, start: options.start, end: options.end } @@ -589,6 +594,7 @@ function sendFileDirectly (request, path, stat, options) { } const stream = fs.createReadStream(path, { + highWaterMark: options.highWaterMark, start: offset, end: Math.max(offset, offset + len - 1) }) diff --git a/test/send.1.test.js b/test/send.1.test.js index 59243d9..aa0c581 100644 --- a/test/send.1.test.js +++ b/test/send.1.test.js @@ -7,13 +7,14 @@ const path = require('node:path') const request = require('supertest') const { send } = require('..') const { shouldNotHaveHeader, createServer } = require('./utils') +const { getDefaultHighWaterMark } = require('node:stream') // test server const fixtures = path.join(__dirname, 'fixtures') test('send(file, options)', async function (t) { - t.plan(11) + t.plan(12) await t.test('acceptRanges', async function (t) { t.plan(6) @@ -599,4 +600,47 @@ test('send(file, options)', async function (t) { }) }) }) + + 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/types/index.d.ts b/types/index.d.ts index 1ff729c..36184e1 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -120,6 +120,12 @@ declare namespace send { * The start is inclusive, meaning start: 2 will include the 3rd byte in the stream. */ start?: number | undefined; + + /** + * Maximum number of bytes that the internal buffer will hold. + * If omitted, Node.js falls back to its built-in default. + */ + highWaterMark?: number | undefined; } export interface BaseSendResult { From 1b54ebfce9a47325787663f4484b54dd855fa07a Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Thu, 5 Jun 2025 16:58:33 +0100 Subject: [PATCH 101/109] 4.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a866c1d..32e3240 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@fastify/send", "description": "Better streaming static file server with Range and conditional-GET support", - "version": "4.0.0", + "version": "4.1.0", "author": "TJ Holowaychuk ", "contributors": [ "Douglas Christopher Wilson ", From 107b7e06aede1e6e5ac7f0c44fa06d6dac7a67d8 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Mon, 23 Jun 2025 12:25:56 +0100 Subject: [PATCH 102/109] chore(license): update date ranges; standardise style (#115) --- LICENSE | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 74e9601..a996842 100644 --- a/LICENSE +++ b/LICENSE @@ -2,7 +2,9 @@ MIT License Copyright (c) 2012 TJ Holowaychuk Copyright (c) 2014-2022 Douglas Christopher Wilson -Copyright (c) 2023 The Fastify Team +Copyright (c) 2023-present The Fastify team + +The Fastify team members are listed at https://github.com/fastify/fastify#team. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From c253fabecfdf5f44a15d6e7755d053d8ee4e1056 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Jul 2025 07:27:33 +0000 Subject: [PATCH 103/109] build(deps-dev): bump @types/node from 22.15.34 to 24.0.8 (#116) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.15.34 to 24.0.8. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 24.0.8 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 32e3240..7afb992 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ }, "devDependencies": { "@fastify/pre-commit": "^2.1.0", - "@types/node": "^22.0.0", + "@types/node": "^24.0.8", "after": "0.8.2", "benchmark": "^2.1.4", "c8": "^10.1.3", From 27b6d6781ee08fd9951c18e9f9513c3094976387 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Wed, 17 Sep 2025 20:46:07 +0100 Subject: [PATCH 104/109] chore(.npmrc): ignore scripts (#118) --- .npmrc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.npmrc b/.npmrc index 9cf9495..5b4b8cc 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,3 @@ -package-lock=false \ No newline at end of file +ignore-scripts=true +package-lock=false + From 6b70f2cd8d7af379f82ea32efbfdea09471ab0e8 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Sun, 21 Sep 2025 10:18:07 +0100 Subject: [PATCH 105/109] build(deps-dev): remove @fastify/pre-commit (#119) --- package.json | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/package.json b/package.json index 7afb992..2fd3649 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,6 @@ "mime": "^3" }, "devDependencies": { - "@fastify/pre-commit": "^2.1.0", "@types/node": "^24.0.8", "after": "0.8.2", "benchmark": "^2.1.4", @@ -73,9 +72,5 @@ "test:coverage": "c8 --reporter html node --test", "test:typescript": "tsd", "test:unit": "c8 --100 node --test" - }, - "pre-commit": [ - "lint", - "test" - ] + } } From b043307b562bb0b6894910de4c068214b5fc10bc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 05:06:08 +0000 Subject: [PATCH 106/109] build(deps-dev): bump tsd from 0.32.0 to 0.33.0 (#120) Bumps [tsd](https://github.com/tsdjs/tsd) from 0.32.0 to 0.33.0. - [Release notes](https://github.com/tsdjs/tsd/releases) - [Commits](https://github.com/tsdjs/tsd/compare/v0.32.0...v0.33.0) --- updated-dependencies: - dependency-name: tsd dependency-version: 0.33.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2fd3649..103fe94 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "eslint": "^9.17.0", "neostandard": "^0.12.0", "supertest": "6.3.4", - "tsd": "^0.32.0" + "tsd": "^0.33.0" }, "scripts": { "lint": "eslint", From 3637eafe571a9e63096e178b59358084e8b8d430 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Fri, 17 Oct 2025 10:22:05 +0100 Subject: [PATCH 107/109] ci(ci): add concurrency config (#121) --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd45202..5508c50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,11 @@ on: - 'docs/**' - '*.md' +# This allows a subsequently queued workflow run to interrupt previous runs +concurrency: + group: "${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}" + cancel-in-progress: true + permissions: contents: read From 030392f6f8e667574e6f2cc6c1b4dba78be87529 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Jan 2026 05:04:59 +0000 Subject: [PATCH 108/109] build(deps-dev): bump @types/node from 24.10.4 to 25.0.3 (#122) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.10.4 to 25.0.3. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 25.0.3 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 103fe94..8c4d39e 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "mime": "^3" }, "devDependencies": { - "@types/node": "^24.0.8", + "@types/node": "^25.0.3", "after": "0.8.2", "benchmark": "^2.1.4", "c8": "^10.1.3", From e1469ad2eadb36f3174dd498a072f1e945ea9624 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Wed, 21 Jan 2026 20:02:45 -0500 Subject: [PATCH 109/109] refactor: update package details and README for migration to pillarjs/send - I also run the npm run lint:fix command --- .github/workflows/ci.yml | 4 +- LICENSE | 5 +- README.md | 33 +- package.json | 34 +- scripts/version-history.js | 24 +- test/send.js | 1330 ------------------------------------ 6 files changed, 57 insertions(+), 1373 deletions(-) delete mode 100644 test/send.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10b88a6..5bd665d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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/LICENSE b/LICENSE index a996842..9b43b7f 100644 --- a/LICENSE +++ b/LICENSE @@ -2,9 +2,8 @@ MIT License Copyright (c) 2012 TJ Holowaychuk Copyright (c) 2014-2022 Douglas Christopher Wilson -Copyright (c) 2023-present The Fastify team - -The Fastify team members are listed at https://github.com/fastify/fastify#team. +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 diff --git a/README.md b/README.md index d24d414..7d72845 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ -# @fastify/send +# send -[![CI](https://github.com/fastify/send/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/send/actions/workflows/ci.yml) -[![NPM version](https://img.shields.io/npm/v/@fastify/send.svg?style=flat)](https://www.npmjs.com/package/@fastify/send) -[![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard) +[![NPM Version][npm-version-image]][npm-url] +[![NPM Downloads][npm-downloads-image]][npm-url] +[![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 an HTTP response supporting partial responses (Ranges), conditional-GET negotiation (If-Match, @@ -17,7 +18,7 @@ This is a [Node.js](https://nodejs.org/en/) module available through the [`npm install` command](https://docs.npmjs.com/getting-started/installing-npm-packages-locally): ```bash -$ npm install @fastify/send +$ npm install send ``` ### TypeScript @@ -32,7 +33,7 @@ $ npm install -D @types/mime@3 ## API ```js -const send = require('@fastify/send') +const send = require('send') ``` ### send(req, path, [options]) @@ -208,7 +209,7 @@ given directory as the top-level. For example, a request ```js const http = require('node:http') const parseUrl = require('parseurl') -const send = require('@fastify/send') +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' }) @@ -224,7 +225,7 @@ server.listen(3000) ```js const http = require('node:http') const parseUrl = require('parseurl') -const send = require('@fastify/send') +const send = require('send') // Default unknown types to text/plain send.mime.default_type = 'text/plain' @@ -252,7 +253,7 @@ custom function to render a listing of a directory. const http = require('node:http') const fs = require('node:fs') const parseUrl = require('parseurl') -const send = require('@fastify/send') +const send = require('send') // Transfer arbitrary files from within /www/example.com/public/* // with a custom handler for directory listing @@ -278,7 +279,7 @@ server.listen(3000) ```js const http = require('node:http') const parseUrl = require('parseurl') -const send = require('@fastify/send') +const send = require('send') const server = http.createServer(async function onRequest (req, res) { // transfer arbitrary files from within @@ -314,4 +315,14 @@ server.listen(3000) ## License -Licensed under [MIT](./LICENSE). +[MIT](LICENSE) + +[coveralls-image]: https://badgen.net/coveralls/c/github/pillarjs/send/master +[coveralls-url]: https://coveralls.io/r/pillarjs/send?branch=master +[github-actions-ci-image]: https://badgen.net/github/checks/pillarjs/send/master?label=linux +[github-actions-ci-url]: https://github.com/pillarjs/send/actions/workflows/ci.yml +[node-image]: https://badgen.net/npm/node/send +[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 \ No newline at end of file diff --git a/package.json b/package.json index 8c4d39e..2da6c9d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "@fastify/send", + "name": "send", "description": "Better streaming static file server with Range and conditional-GET support", - "version": "4.1.0", + "version": "1.2.1", "author": "TJ Holowaychuk ", "contributors": [ "Douglas Christopher Wilson ", @@ -24,25 +24,25 @@ "main": "index.js", "type": "commonjs", "types": "types/index.d.ts", + "files": [ + "LICENSE", + "index.js", + "lib/", + "types" + ], "license": "MIT", "repository": { "type": "git", - "url": "git+https://github.com/fastify/send.git" + "url": "git+https://github.com/pillarjs/send.git" }, "bugs": { - "url": "https://github.com/fastify/send/issues" + "url": "https://github.com/pillarjs/send/issues" + }, + "homepage": "https://github.com/pillarjs/send#readme", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" }, - "homepage": "https://github.com/fastify/send#readme", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], "keywords": [ "static", "file", @@ -69,8 +69,12 @@ "lint": "eslint", "lint:fix": "eslint --fix", "test": "npm run test:unit && npm run test:typescript", + "test:ci": "c8 --reporter=lcovonly --reporter=text npm test", "test:coverage": "c8 --reporter html node --test", "test:typescript": "tsd", "test:unit": "c8 --100 node --test" + }, + "engines": { + "node": ">= 18" } } diff --git a/scripts/version-history.js b/scripts/version-history.js index c58268a..b62d4e1 100644 --- a/scripts/version-history.js +++ b/scripts/version-history.js @@ -1,14 +1,14 @@ 'use strict' -var fs = require('fs') -var path = require('path') +const fs = require('fs') +const path = require('path') -var HISTORY_FILE_PATH = path.join(__dirname, '..', 'HISTORY.md') -var MD_HEADER_REGEXP = /^====*$/ -var VERSION = process.env.npm_package_version -var VERSION_PLACEHOLDER_REGEXP = /^(?:unreleased|(\d+\.)+x)$/ +const HISTORY_FILE_PATH = path.join(__dirname, '..', 'HISTORY.md') +const MD_HEADER_REGEXP = /^====*$/ +const VERSION = process.env.npm_package_version +const VERSION_PLACEHOLDER_REGEXP = /^(?:unreleased|(\d+\.)+x)$/ -var historyFileLines = fs.readFileSync(HISTORY_FILE_PATH, 'utf-8').split('\n') +const historyFileLines = fs.readFileSync(HISTORY_FILE_PATH, 'utf-8').split('\n') if (!MD_HEADER_REGEXP.test(historyFileLines[1])) { console.error('Missing header in HISTORY.md') @@ -21,7 +21,7 @@ if (!VERSION_PLACEHOLDER_REGEXP.test(historyFileLines[0])) { } if (historyFileLines[0].indexOf('x') !== -1) { - var versionCheckRegExp = new RegExp('^' + historyFileLines[0].replace('x', '.+') + '$') + const versionCheckRegExp = new RegExp('^' + historyFileLines[0].replace('x', '.+') + '$') if (!versionCheckRegExp.test(VERSION)) { console.error('Version %s does not match placeholder %s', VERSION, historyFileLines[0]) @@ -35,7 +35,7 @@ historyFileLines[1] = repeat('=', historyFileLines[0].length) fs.writeFileSync(HISTORY_FILE_PATH, historyFileLines.join('\n')) function getLocaleDate () { - var now = new Date() + const now = new Date() return zeroPad(now.getFullYear(), 4) + '-' + zeroPad(now.getMonth() + 1, 2) + '-' + @@ -43,9 +43,9 @@ function getLocaleDate () { } function repeat (str, length) { - var out = '' + let out = '' - for (var i = 0; i < length; i++) { + for (let i = 0; i < length; i++) { out += str } @@ -53,7 +53,7 @@ function repeat (str, length) { } function zeroPad (number, length) { - var num = number.toString() + let num = number.toString() while (num.length < length) { num = '0' + num diff --git a/test/send.js b/test/send.js deleted file mode 100644 index b824282..0000000 --- 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\/Redirecting to \/snow%20%E2%98%83\/Not Foundtobi

', 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) - } -}