diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..62562b74 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +coverage +node_modules diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 00000000..e4f03fba --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1,11 @@ +root: true +extends: + - standard + - plugin:markdown/recommended +plugins: + - markdown +overrides: + - files: '**/*.md' + processor: 'markdown/markdown' +rules: + no-param-reassign: error diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..6c5b941a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,189 @@ +name: ci + +on: +- pull_request +- push + +jobs: + test: + runs-on: ubuntu-latest + 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" + + - 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 diff --git a/.gitignore b/.gitignore index 3cd27afd..f15b98e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.nyc_output/ coverage/ node_modules/ npm-debug.log +package-lock.json diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 1ff243c5..00000000 --- a/.travis.yml +++ /dev/null @@ -1,11 +0,0 @@ -language: node_js -node_js: - - "0.8" - - "0.10" - - "0.11" -matrix: - allow_failures: - - node_js: "0.11" - fast_finish: true -script: "npm run-script test-travis" -after_script: "npm install coveralls@2.10.0 && cat ./coverage/lcov.info | coveralls" diff --git a/HISTORY.md b/HISTORY.md new file mode 100644 index 00000000..a7397749 --- /dev/null +++ b/HISTORY.md @@ -0,0 +1,521 @@ +0.18.0 / 2022-03-23 +=================== + + * Fix emitted 416 error missing headers property + * Limit the headers removed for 304 response + * deps: depd@2.0.0 + - Replace internal `eval` usage with `Function` constructor + - Use instance methods on `process` to check for listeners + * deps: destroy@1.2.0 + * deps: http-errors@2.0.0 + - deps: depd@2.0.0 + - deps: statuses@2.0.1 + * deps: on-finished@2.4.1 + * deps: statuses@2.0.1 + +0.17.2 / 2021-12-11 +=================== + + * pref: ignore empty http tokens + * deps: http-errors@1.8.1 + - deps: inherits@2.0.4 + - deps: toidentifier@1.0.1 + - deps: setprototypeof@1.2.0 + * deps: ms@2.1.3 + +0.17.1 / 2019-05-10 +=================== + + * Set stricter CSP header in redirect & error responses + * deps: range-parser@~1.2.1 + +0.17.0 / 2019-05-03 +=================== + + * deps: http-errors@~1.7.2 + - Set constructor name when possible + - Use `toidentifier` module to make class names + - deps: depd@~1.1.2 + - deps: setprototypeof@1.1.1 + - deps: statuses@'>= 1.5.0 < 2' + * deps: mime@1.6.0 + - Add extensions for JPEG-2000 images + - Add new `font/*` types from IANA + - Add WASM mapping + - Update `.bdoc` to `application/bdoc` + - Update `.bmp` to `image/bmp` + - Update `.m4a` to `audio/mp4` + - Update `.rtf` to `application/rtf` + - Update `.wav` to `audio/wav` + - Update `.xml` to `application/xml` + - Update generic extensions to `application/octet-stream`: + `.deb`, `.dll`, `.dmg`, `.exe`, `.iso`, `.msi` + - Use mime-score module to resolve extension conflicts + * deps: ms@2.1.1 + - Add `week`/`w` support + - Fix negative number handling + * deps: statuses@~1.5.0 + * perf: remove redundant `path.normalize` call + +0.16.2 / 2018-02-07 +=================== + + * Fix incorrect end tag in default error & redirects + * deps: depd@~1.1.2 + - perf: remove argument reassignment + * deps: encodeurl@~1.0.2 + - Fix encoding `%` as last character + * deps: statuses@~1.4.0 + +0.16.1 / 2017-09-29 +=================== + + * Fix regression in edge-case behavior for empty `path` + +0.16.0 / 2017-09-27 +=================== + + * Add `immutable` option + * Fix missing `` in default error & redirects + * Use instance methods on steam to check for listeners + * deps: mime@1.4.1 + - Add 70 new types for file extensions + - Set charset as "UTF-8" for .js and .json + * perf: improve path validation speed + +0.15.6 / 2017-09-22 +=================== + + * deps: debug@2.6.9 + * perf: improve `If-Match` token parsing + +0.15.5 / 2017-09-20 +=================== + + * deps: etag@~1.8.1 + - perf: replace regular expression with substring + * deps: fresh@0.5.2 + - Fix handling of modified headers with invalid dates + - perf: improve ETag match loop + - perf: improve `If-None-Match` token parsing + +0.15.4 / 2017-08-05 +=================== + + * deps: debug@2.6.8 + * deps: depd@~1.1.1 + - Remove unnecessary `Buffer` loading + * deps: http-errors@~1.6.2 + - deps: depd@1.1.1 + +0.15.3 / 2017-05-16 +=================== + + * deps: debug@2.6.7 + - deps: ms@2.0.0 + * deps: ms@2.0.0 + +0.15.2 / 2017-04-26 +=================== + + * deps: debug@2.6.4 + - Fix `DEBUG_MAX_ARRAY_LENGTH` + - deps: ms@0.7.3 + * deps: ms@1.0.0 + +0.15.1 / 2017-03-04 +=================== + + * Fix issue when `Date.parse` does not return `NaN` on invalid date + * Fix strict violation in broken environments + +0.15.0 / 2017-02-25 +=================== + + * Support `If-Match` and `If-Unmodified-Since` headers + * Add `res` and `path` arguments to `directory` event + * Remove usage of `res._headers` private field + - Improves compatibility with Node.js 8 nightly + * Send complete HTML document in redirect & error responses + * Set default CSP header in redirect & error responses + * Use `res.getHeaderNames()` when available + * Use `res.headersSent` when available + * deps: debug@2.6.1 + - Allow colors in workers + - Deprecated `DEBUG_FD` environment variable set to `3` or higher + - Fix error when running under React Native + - Use same color for same namespace + - deps: ms@0.7.2 + * deps: etag@~1.8.0 + * deps: fresh@0.5.0 + - Fix false detection of `no-cache` request directive + - Fix incorrect result when `If-None-Match` has both `*` and ETags + - Fix weak `ETag` matching to match spec + - perf: delay reading header values until needed + - perf: enable strict mode + - perf: hoist regular expressions + - perf: remove duplicate conditional + - perf: remove unnecessary boolean coercions + - perf: skip checking modified time if ETag check failed + - perf: skip parsing `If-None-Match` when no `ETag` header + - perf: use `Date.parse` instead of `new Date` + * deps: http-errors@~1.6.1 + - Make `message` property enumerable for `HttpError`s + - deps: setprototypeof@1.0.3 + +0.14.2 / 2017-01-23 +=================== + + * deps: http-errors@~1.5.1 + - deps: inherits@2.0.3 + - deps: setprototypeof@1.0.2 + - deps: statuses@'>= 1.3.1 < 2' + * deps: ms@0.7.2 + * deps: statuses@~1.3.1 + +0.14.1 / 2016-06-09 +=================== + + * Fix redirect error when `path` contains raw non-URL characters + * Fix redirect when `path` starts with multiple forward slashes + +0.14.0 / 2016-06-06 +=================== + + * Add `acceptRanges` option + * Add `cacheControl` option + * Attempt to combine multiple ranges into single range + * Correctly inherit from `Stream` class + * Fix `Content-Range` header in 416 responses when using `start`/`end` options + * Fix `Content-Range` header missing from default 416 responses + * Ignore non-byte `Range` headers + * deps: http-errors@~1.5.0 + - Add `HttpError` export, for `err instanceof createError.HttpError` + - Support new code `421 Misdirected Request` + - Use `setprototypeof` module to replace `__proto__` setting + - deps: inherits@2.0.1 + - deps: statuses@'>= 1.3.0 < 2' + - perf: enable strict mode + * deps: range-parser@~1.2.0 + - Fix incorrectly returning -1 when there is at least one valid range + - perf: remove internal function + * deps: statuses@~1.3.0 + - Add `421 Misdirected Request` + - perf: enable strict mode + * perf: remove argument reassignment + +0.13.2 / 2016-03-05 +=================== + + * Fix invalid `Content-Type` header when `send.mime.default_type` unset + +0.13.1 / 2016-01-16 +=================== + + * deps: depd@~1.1.0 + - Support web browser loading + - perf: enable strict mode + * deps: destroy@~1.0.4 + - perf: enable strict mode + * deps: escape-html@~1.0.3 + - perf: enable strict mode + - perf: optimize string replacement + - perf: use faster string coercion + * deps: range-parser@~1.0.3 + - perf: enable strict mode + +0.13.0 / 2015-06-16 +=================== + + * Allow Node.js HTTP server to set `Date` response header + * Fix incorrectly removing `Content-Location` on 304 response + * Improve the default redirect response headers + * Send appropriate headers on default error response + * Use `http-errors` for standard emitted errors + * Use `statuses` instead of `http` module for status messages + * deps: escape-html@1.0.2 + * deps: etag@~1.7.0 + - Improve stat performance by removing hashing + * deps: fresh@0.3.0 + - Add weak `ETag` matching support + * deps: on-finished@~2.3.0 + - Add defined behavior for HTTP `CONNECT` requests + - Add defined behavior for HTTP `Upgrade` requests + - deps: ee-first@1.1.1 + * perf: enable strict mode + * perf: remove unnecessary array allocations + +0.12.3 / 2015-05-13 +=================== + + * deps: debug@~2.2.0 + - deps: ms@0.7.1 + * deps: depd@~1.0.1 + * deps: etag@~1.6.0 + - Improve support for JXcore + - Support "fake" stats objects in environments without `fs` + * deps: ms@0.7.1 + - Prevent extraordinarily long inputs + * deps: on-finished@~2.2.1 + +0.12.2 / 2015-03-13 +=================== + + * Throw errors early for invalid `extensions` or `index` options + * deps: debug@~2.1.3 + - Fix high intensity foreground color for bold + - deps: ms@0.7.0 + +0.12.1 / 2015-02-17 +=================== + + * Fix regression sending zero-length files + +0.12.0 / 2015-02-16 +=================== + + * Always read the stat size from the file + * Fix mutating passed-in `options` + * deps: mime@1.3.4 + +0.11.1 / 2015-01-20 +=================== + + * Fix `root` path disclosure + +0.11.0 / 2015-01-05 +=================== + + * deps: debug@~2.1.1 + * deps: etag@~1.5.1 + - deps: crc@3.2.1 + * deps: ms@0.7.0 + - Add `milliseconds` + - Add `msecs` + - Add `secs` + - Add `mins` + - Add `hrs` + - Add `yrs` + * deps: on-finished@~2.2.0 + +0.10.1 / 2014-10-22 +=================== + + * deps: on-finished@~2.1.1 + - Fix handling of pipelined requests + +0.10.0 / 2014-10-15 +=================== + + * deps: debug@~2.1.0 + - Implement `DEBUG_FD` env variable support + * deps: depd@~1.0.0 + * deps: etag@~1.5.0 + - Improve string performance + - Slightly improve speed for weak ETags over 1KB + +0.9.3 / 2014-09-24 +================== + + * deps: etag@~1.4.0 + - Support "fake" stats objects + +0.9.2 / 2014-09-15 +================== + + * deps: depd@0.4.5 + * deps: etag@~1.3.1 + * deps: range-parser@~1.0.2 + +0.9.1 / 2014-09-07 +================== + + * deps: fresh@0.2.4 + +0.9.0 / 2014-09-07 +================== + + * Add `lastModified` option + * Use `etag` to generate `ETag` header + * deps: debug@~2.0.0 + +0.8.5 / 2014-09-04 +================== + + * Fix malicious path detection for empty string path + +0.8.4 / 2014-09-04 +================== + + * Fix a path traversal issue when using `root` + +0.8.3 / 2014-08-16 +================== + + * deps: destroy@1.0.3 + - renamed from dethroy + * deps: on-finished@2.1.0 + +0.8.2 / 2014-08-14 +================== + + * Work around `fd` leak in Node.js 0.10 for `fs.ReadStream` + * deps: dethroy@1.0.2 + +0.8.1 / 2014-08-05 +================== + + * Fix `extensions` behavior when file already has extension + +0.8.0 / 2014-08-05 +================== + + * Add `extensions` option + +0.7.4 / 2014-08-04 +================== + + * Fix serving index files without root dir + +0.7.3 / 2014-07-29 +================== + + * Fix incorrect 403 on Windows and Node.js 0.11 + +0.7.2 / 2014-07-27 +================== + + * deps: depd@0.4.4 + - Work-around v8 generating empty stack traces + +0.7.1 / 2014-07-26 +================== + + * deps: depd@0.4.3 + - Fix exception when global `Error.stackTraceLimit` is too low + +0.7.0 / 2014-07-20 +================== + + * Deprecate `hidden` option; use `dotfiles` option + * Add `dotfiles` option + * deps: debug@1.0.4 + * deps: depd@0.4.2 + - Add `TRACE_DEPRECATION` environment variable + - Remove non-standard grey color from color output + - Support `--no-deprecation` argument + - Support `--trace-deprecation` argument + +0.6.0 / 2014-07-11 +================== + + * Deprecate `from` option; use `root` option + * Deprecate `send.etag()` -- use `etag` in `options` + * Deprecate `send.hidden()` -- use `hidden` in `options` + * Deprecate `send.index()` -- use `index` in `options` + * Deprecate `send.maxage()` -- use `maxAge` in `options` + * Deprecate `send.root()` -- use `root` in `options` + * Cap `maxAge` value to 1 year + * deps: debug@1.0.3 + - Add support for multiple wildcards in namespaces + +0.5.0 / 2014-06-28 +================== + + * Accept string for `maxAge` (converted by `ms`) + * Add `headers` event + * Include link in default redirect response + * Use `EventEmitter.listenerCount` to count listeners + +0.4.3 / 2014-06-11 +================== + + * Do not throw un-catchable error on file open race condition + * Use `escape-html` for HTML escaping + * deps: debug@1.0.2 + - fix some debugging output colors on node.js 0.8 + * deps: finished@1.2.2 + * deps: fresh@0.2.2 + +0.4.2 / 2014-06-09 +================== + + * fix "event emitter leak" warnings + * deps: debug@1.0.1 + * deps: finished@1.2.1 + +0.4.1 / 2014-06-02 +================== + + * Send `max-age` in `Cache-Control` in correct format + +0.4.0 / 2014-05-27 +================== + + * Calculate ETag with md5 for reduced collisions + * Fix wrong behavior when index file matches directory + * Ignore stream errors after request ends + - Goodbye `EBADF, read` + * Skip directories in index file search + * deps: debug@0.8.1 + +0.3.0 / 2014-04-24 +================== + + * Fix sending files with dots without root set + * Coerce option types + * Accept API options in options object + * Set etags to "weak" + * Include file path in etag + * Make "Can't set headers after they are sent." catchable + * Send full entity-body for multi range requests + * Default directory access to 403 when index disabled + * Support multiple index paths + * Support "If-Range" header + * Control whether to generate etags + * deps: mime@1.2.11 + +0.2.0 / 2014-01-29 +================== + + * update range-parser and fresh + +0.1.4 / 2013-08-11 +================== + + * update fresh + +0.1.3 / 2013-07-08 +================== + + * Revert "Fix fd leak" + +0.1.2 / 2013-07-03 +================== + + * Fix fd leak + +0.1.0 / 2012-08-25 +================== + + * add options parameter to send() that is passed to fs.createReadStream() [kanongil] + +0.0.4 / 2012-08-16 +================== + + * allow custom "Accept-Ranges" definition + +0.0.3 / 2012-07-16 +================== + + * fix normalization of the root directory. Closes #3 + +0.0.2 / 2012-07-09 +================== + + * add passing of req explicitly for now (YUCK) + +0.0.1 / 2010-01-03 +================== + + * Initial release diff --git a/History.md b/History.md deleted file mode 100644 index 697a0280..00000000 --- a/History.md +++ /dev/null @@ -1,241 +0,0 @@ -0.11.1 / 2015-01-20 -=================== - - * Fix `root` path disclosure - -0.11.0 / 2015-01-05 -=================== - - * deps: debug@~2.1.1 - * deps: etag@~1.5.1 - - deps: crc@3.2.1 - * deps: ms@0.7.0 - - Add `milliseconds` - - Add `msecs` - - Add `secs` - - Add `mins` - - Add `hrs` - - Add `yrs` - * deps: on-finished@~2.2.0 - -0.10.1 / 2014-10-22 -=================== - - * deps: on-finished@~2.1.1 - - Fix handling of pipelined requests - -0.10.0 / 2014-10-15 -=================== - - * deps: debug@~2.1.0 - - Implement `DEBUG_FD` env variable support - * deps: depd@~1.0.0 - * deps: etag@~1.5.0 - - Improve string performance - - Slightly improve speed for weak ETags over 1KB - -0.9.3 / 2014-09-24 -================== - - * deps: etag@~1.4.0 - - Support "fake" stats objects - -0.9.2 / 2014-09-15 -================== - - * deps: depd@0.4.5 - * deps: etag@~1.3.1 - * deps: range-parser@~1.0.2 - -0.9.1 / 2014-09-07 -================== - - * deps: fresh@0.2.4 - -0.9.0 / 2014-09-07 -================== - - * Add `lastModified` option - * Use `etag` to generate `ETag` header - * deps: debug@~2.0.0 - -0.8.5 / 2014-09-04 -================== - - * Fix malicious path detection for empty string path - -0.8.4 / 2014-09-04 -================== - - * Fix a path traversal issue when using `root` - -0.8.3 / 2014-08-16 -================== - - * deps: destroy@1.0.3 - - renamed from dethroy - * deps: on-finished@2.1.0 - -0.8.2 / 2014-08-14 -================== - - * Work around `fd` leak in Node.js 0.10 for `fs.ReadStream` - * deps: dethroy@1.0.2 - -0.8.1 / 2014-08-05 -================== - - * Fix `extensions` behavior when file already has extension - -0.8.0 / 2014-08-05 -================== - - * Add `extensions` option - -0.7.4 / 2014-08-04 -================== - - * Fix serving index files without root dir - -0.7.3 / 2014-07-29 -================== - - * Fix incorrect 403 on Windows and Node.js 0.11 - -0.7.2 / 2014-07-27 -================== - - * deps: depd@0.4.4 - - Work-around v8 generating empty stack traces - -0.7.1 / 2014-07-26 -================== - - * deps: depd@0.4.3 - - Fix exception when global `Error.stackTraceLimit` is too low - -0.7.0 / 2014-07-20 -================== - - * Deprecate `hidden` option; use `dotfiles` option - * Add `dotfiles` option - * deps: debug@1.0.4 - * deps: depd@0.4.2 - - Add `TRACE_DEPRECATION` environment variable - - Remove non-standard grey color from color output - - Support `--no-deprecation` argument - - Support `--trace-deprecation` argument - -0.6.0 / 2014-07-11 -================== - - * Deprecate `from` option; use `root` option - * Deprecate `send.etag()` -- use `etag` in `options` - * Deprecate `send.hidden()` -- use `hidden` in `options` - * Deprecate `send.index()` -- use `index` in `options` - * Deprecate `send.maxage()` -- use `maxAge` in `options` - * Deprecate `send.root()` -- use `root` in `options` - * Cap `maxAge` value to 1 year - * deps: debug@1.0.3 - - Add support for multiple wildcards in namespaces - -0.5.0 / 2014-06-28 -================== - - * Accept string for `maxAge` (converted by `ms`) - * Add `headers` event - * Include link in default redirect response - * Use `EventEmitter.listenerCount` to count listeners - -0.4.3 / 2014-06-11 -================== - - * Do not throw un-catchable error on file open race condition - * Use `escape-html` for HTML escaping - * deps: debug@1.0.2 - - fix some debugging output colors on node.js 0.8 - * deps: finished@1.2.2 - * deps: fresh@0.2.2 - -0.4.2 / 2014-06-09 -================== - - * fix "event emitter leak" warnings - * deps: debug@1.0.1 - * deps: finished@1.2.1 - -0.4.1 / 2014-06-02 -================== - - * Send `max-age` in `Cache-Control` in correct format - -0.4.0 / 2014-05-27 -================== - - * Calculate ETag with md5 for reduced collisions - * Fix wrong behavior when index file matches directory - * Ignore stream errors after request ends - - Goodbye `EBADF, read` - * Skip directories in index file search - * deps: debug@0.8.1 - -0.3.0 / 2014-04-24 -================== - - * Fix sending files with dots without root set - * Coerce option types - * Accept API options in options object - * Set etags to "weak" - * Include file path in etag - * Make "Can't set headers after they are sent." catchable - * Send full entity-body for multi range requests - * Default directory access to 403 when index disabled - * Support multiple index paths - * Support "If-Range" header - * Control whether to generate etags - * deps: mime@1.2.11 - -0.2.0 / 2014-01-29 -================== - - * update range-parser and fresh - -0.1.4 / 2013-08-11 -================== - - * update fresh - -0.1.3 / 2013-07-08 -================== - - * Revert "Fix fd leak" - -0.1.2 / 2013-07-03 -================== - - * Fix fd leak - -0.1.0 / 2012-08-25 -================== - - * add options parameter to send() that is passed to fs.createReadStream() [kanongil] - -0.0.4 / 2012-08-16 -================== - - * allow custom "Accept-Ranges" definition - -0.0.3 / 2012-07-16 -================== - - * fix normalization of the root directory. Closes #3 - -0.0.2 / 2012-07-09 -================== - - * add passing of req explicitly for now (YUCK) - -0.0.1 / 2010-01-03 -================== - - * Initial release diff --git a/LICENSE b/LICENSE index 3b87e2db..b6ea1c1f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ (The MIT License) Copyright (c) 2012 TJ Holowaychuk -Copyright (c) 2014 Douglas Christopher Wilson +Copyright (c) 2014-2022 Douglas Christopher Wilson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/README.md b/README.md new file mode 100644 index 00000000..fadf8383 --- /dev/null +++ b/README.md @@ -0,0 +1,327 @@ +# 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 +[npm registry](https://www.npmjs.com/). Installation is done using the +[`npm install` command](https://docs.npmjs.com/getting-started/installing-npm-packages-locally): + +```bash +$ npm install send +``` + +## API + +```js +var send = require('send') +``` + +### send(req, path, [options]) + +Create a new `SendStream` for the given path to send to a `res`. The `req` is +the Node.js HTTP request and the `path` is a urlencoded path to send (urlencoded, +not the actual file-system path). + +#### Options + +##### acceptRanges + +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. + +##### cacheControl + +Enable or disable setting `Cache-Control` response header, defaults to +true. Disabling this will ignore the `immutable` and `maxAge` options. + +##### dotfiles + +Set how "dotfiles" are treated when encountered. A dotfile is a file +or directory that begins with a dot ("."). Note this check is done on +the path itself without checking if the path actually exists on the +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. + +##### end + +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. + +##### etag + +Enable or disable etag generation, defaults to true. + +##### extensions + +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. + +##### immutable + +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. + +##### index + +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. + +##### lastModified + +Enable or disable `Last-Modified` header, defaults to true. Uses the file +system's last modified value. + +##### maxAge + +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. + +##### root + +Serve files relative to `path`. + +##### start + +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 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 +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 +such as Varnish for this, or those fancy things called CDNs. If your +application is small enough that it would benefit from single-node memory +caching, it's small enough that it does not need caching at all ;). + +## Debugging + +To enable `debug()` instrumentation output export __DEBUG__: + +``` +$ DEBUG=send node app +``` + +## Running tests + +``` +$ npm install +$ npm test +``` + +## Examples + +### Serve a specific file + +This simple example will send a specific file to all requests. + +```js +var http = require('http') +var send = require('send') + +var server = http.createServer(function onRequest (req, res) { + send(req, '/path/to/index.html') + .pipe(res) +}) + +server.listen(3000) +``` + +### Serve all files from a directory + +This simple example will just serve up all the files in a +given directory as the top-level. For example, a request +`GET /foo.txt` will send back `/www/public/foo.txt`. + +```js +var http = require('http') +var parseUrl = require('parseurl') +var send = require('send') + +var server = http.createServer(function onRequest (req, res) { + send(req, parseUrl(req).pathname, { root: '/www/public' }) + .pipe(res) +}) + +server.listen(3000) +``` + +### Custom file types + +```js +var http = require('http') +var parseUrl = require('parseurl') +var send = require('send') + +// Default unknown types to text/plain +send.mime.default_type = 'text/plain' + +// Add a custom type +send.mime.define({ + 'application/x-my-type': ['x-mt', 'x-mtt'] +}) + +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 a example of serving up a structure of directories with a +custom function to render a listing of a directory. + +```js +var http = require('http') +var fs = require('fs') +var parseUrl = require('parseurl') +var send = require('send') + +// 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('http') +var parseUrl = require('parseurl') +var send = require('send') + +var server = http.createServer(function onRequest (req, res) { + // your custom error-handling logic: + function error (err) { + res.statusCode = err.status || 500 + res.end(err.message) + } + + // your custom headers + function headers (res, path, stat) { + // serve all files for download + res.setHeader('Content-Disposition', 'attachment') + } + + // your custom directory handling logic: + function redirect () { + res.statusCode = 301 + res.setHeader('Location', req.url + '/') + res.end('Redirecting to ' + req.url + '/') + } + + // 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) +}) + +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/Readme.md b/Readme.md deleted file mode 100644 index 5c03b96a..00000000 --- a/Readme.md +++ /dev/null @@ -1,192 +0,0 @@ -# send - -[![NPM Version][npm-image]][npm-url] -[![NPM Downloads][downloads-image]][downloads-url] -[![Build Status][travis-image]][travis-url] -[![Test Coverage][coveralls-image]][coveralls-url] -[![Gittip][gittip-image]][gittip-url] - -Send is a library for streaming files from the file system as a http response -supporting partial responses (Ranges), conditional-GET negotiation, 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 - -```bash -$ npm install send -``` - -## API - -```js -var send = require('send') -``` - -### send(req, path, [options]) - -Create a new `SendStream` for the given path to send to a `res`. The `req` is -the Node.js HTTP request and the `path` is a urlencoded path to send (urlencoded, -not the actual file-system path). - -#### Options - -##### dotfiles - -Set how "dotfiles" are treated when encountered. A dotfile is a file -or directory that begins with a dot ("."). Note this check is done on -the path itself without checking if the path actually exists on the -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. - -##### etag - -Enable or disable etag generation, defaults to true. - -##### extensions - -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. - -##### index - -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. - -##### lastModified - -Enable or disable `Last-Modified` header, defaults to true. Uses the file -system's last modified value. - -##### maxAge - -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. - -##### root - -Serve files relative to `path`. - -### Events - -The `SendStream` is an event emitter and will emit the following events: - - - `error` an error occurred `(err)` - - `directory` a directory was requested - - `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)`. - -## 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 -such as Varnish for this, or those fancy things called CDNs. If your -application is small enough that it would benefit from single-node memory -caching, it's small enough that it does not need caching at all ;). - -## Debugging - -To enable `debug()` instrumentation output export __DEBUG__: - -``` -$ DEBUG=send node app -``` - -## Running tests - -``` -$ npm install -$ npm test -``` - -## Examples - -### Small example - -```js -var http = require('http'); -var send = require('send'); - -var app = http.createServer(function(req, res){ - send(req, req.url).pipe(res); -}).listen(3000); -``` - -Serving from a root directory with custom error-handling: - -```js -var http = require('http'); -var send = require('send'); -var url = require('url'); - -var app = http.createServer(function(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, url.parse(req.url).pathname, {root: '/www/example.com/public'}) - .on('error', error) - .on('directory', redirect) - .on('headers', headers) - .pipe(res); -}).listen(3000); -``` - -## License - -[MIT](LICENSE) - -[npm-image]: https://img.shields.io/npm/v/send.svg?style=flat -[npm-url]: https://npmjs.org/package/send -[travis-image]: https://img.shields.io/travis/tj/send.svg?style=flat -[travis-url]: https://travis-ci.org/tj/send -[coveralls-image]: https://img.shields.io/coveralls/tj/send.svg?style=flat -[coveralls-url]: https://coveralls.io/r/tj/send?branch=master -[downloads-image]: https://img.shields.io/npm/dm/send.svg?style=flat -[downloads-url]: https://npmjs.org/package/send -[gittip-image]: https://img.shields.io/gittip/dougwilson.svg?style=flat -[gittip-url]: https://www.gittip.com/dougwilson/ diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..46b48f7b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,24 @@ +# 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 new file mode 100644 index 00000000..1332a991 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,89 @@ +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 f63081d3..89afd7e5 100644 --- a/index.js +++ b/index.js @@ -1,68 +1,87 @@ +/*! + * send + * Copyright(c) 2012 TJ Holowaychuk + * Copyright(c) 2014-2022 Douglas Christopher Wilson + * MIT Licensed + */ + +'use strict' /** * Module dependencies. + * @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') - , parseRange = require('range-parser') - , Stream = require('stream') - , mime = require('mime') - , fresh = require('fresh') - , path = require('path') - , http = require('http') - , fs = require('fs') - , normalize = path.normalize - , join = path.join var etag = require('etag') -var EventEmitter = require('events').EventEmitter; -var ms = require('ms'); +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') /** - * Variables. + * Path function references. + * @private */ + var extname = path.extname -var maxMaxAge = 60 * 60 * 24 * 365 * 1000; // 1 year +var join = path.join +var normalize = path.normalize var resolve = path.resolve var sep = path.sep -var toString = Object.prototype.toString -var upPathRegexp = /(?:^|[\\\/])\.\.(?:[\\\/]|$)/ /** - * Expose `send`. + * Regular expression for identifying a bytes Range header. + * @private + */ + +var BYTES_RANGE_REGEXP = /^ *bytes=/ + +/** + * Maximum value allowed for the max age. + * @private */ -exports = module.exports = send; +var MAX_MAXAGE = 60 * 60 * 24 * 365 * 1000 // 1 year /** - * Expose mime module. + * Regular expression to match a path with a directory up component. + * @private */ -exports.mime = mime; +var UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/ /** - * Shim EventEmitter.listenerCount for node.js < 0.10 + * Module exports. + * @public */ -/* istanbul ignore next */ -var listenerCount = EventEmitter.listenerCount - || function(emitter, type){ return emitter.listeners(type).length; }; +module.exports = send +module.exports.mime = mime /** * Return a `SendStream` for `req` and `path`. * - * @param {Request} req - * @param {String} path - * @param {Object} options + * @param {object} req + * @param {string} path + * @param {object} [options] * @return {SendStream} - * @api public + * @public */ -function send(req, path, options) { - return new SendStream(req, path, options); +function send (req, path, options) { + return new SendStream(req, path, options) } /** @@ -70,74 +89,88 @@ function send(req, path, options) { * * @param {Request} req * @param {String} path - * @param {Object} options - * @api private + * @param {object} [options] + * @private */ -function SendStream(req, path, options) { - var self = this; - options = options || {}; - this.req = req; - this.path = path; - this.options = options; +function SendStream (req, path, options) { + Stream.call(this) + + var opts = options || {} - this._etag = options.etag !== undefined - ? Boolean(options.etag) + 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._dotfiles = options.dotfiles !== undefined - ? options.dotfiles + this._etag = opts.etag !== undefined + ? Boolean(opts.etag) + : true + + this._dotfiles = opts.dotfiles !== undefined + ? opts.dotfiles : 'ignore' - if (['allow', 'deny', 'ignore'].indexOf(this._dotfiles) === -1) { + if (this._dotfiles !== 'ignore' && this._dotfiles !== 'allow' && this._dotfiles !== 'deny') { throw new TypeError('dotfiles option must be "allow", "deny", or "ignore"') } - this._hidden = Boolean(options.hidden) + this._hidden = Boolean(opts.hidden) - if ('hidden' in options) { + if (opts.hidden !== undefined) { deprecate('hidden: use dotfiles: \'' + (this._hidden ? 'allow' : 'ignore') + '\' instead') } // legacy support - if (!('dotfiles' in options)) { + if (opts.dotfiles === undefined) { this._dotfiles = undefined } - this._extensions = options.extensions !== undefined - ? normalizeList(options.extensions) + this._extensions = opts.extensions !== undefined + ? normalizeList(opts.extensions, 'extensions option') : [] - this._index = options.index !== undefined - ? normalizeList(options.index) + this._immutable = opts.immutable !== undefined + ? Boolean(opts.immutable) + : false + + this._index = opts.index !== undefined + ? normalizeList(opts.index, 'index option') : ['index.html'] - this._lastModified = options.lastModified !== undefined - ? Boolean(options.lastModified) + this._lastModified = opts.lastModified !== undefined + ? Boolean(opts.lastModified) : true - this._maxage = options.maxAge || options.maxage + 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), maxMaxAge) + ? Math.min(Math.max(0, this._maxage), MAX_MAXAGE) : 0 - this._root = options.root - ? resolve(options.root) + this._root = opts.root + ? resolve(opts.root) : null - if (!this._root && options.from) { - this.from(options.from); + if (!this._root && opts.from) { + this.from(opts.from) } } /** - * Inherits from `Stream.prototype`. + * Inherits from `Stream`. */ -SendStream.prototype.__proto__ = Stream.prototype; +util.inherits(SendStream, Stream) /** * Enable or disable etag generation. @@ -147,12 +180,11 @@ SendStream.prototype.__proto__ = Stream.prototype; * @api public */ -SendStream.prototype.etag = deprecate.function(function etag(val) { - val = Boolean(val); - debug('etag %s', val); - this._etag = val; - return this; -}, 'send.etag: pass etag as option'); +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. @@ -162,13 +194,12 @@ SendStream.prototype.etag = deprecate.function(function etag(val) { * @api public */ -SendStream.prototype.hidden = deprecate.function(function hidden(val) { - val = Boolean(val); - debug('hidden %s', val); - this._hidden = val; +SendStream.prototype.hidden = deprecate.function(function hidden (val) { + this._hidden = Boolean(val) this._dotfiles = undefined - return this; -}, 'send.hidden: use dotfiles option'); + debug('hidden %s', this._hidden) + return this +}, 'send.hidden: use dotfiles option') /** * Set index `paths`, set to a falsy @@ -179,12 +210,12 @@ SendStream.prototype.hidden = deprecate.function(function hidden(val) { * @api public */ -SendStream.prototype.index = deprecate.function(function index(paths) { - var index = !paths ? [] : normalizeList(paths); - debug('index %o', paths); - this._index = index; - return this; -}, 'send.index: pass index as option'); +SendStream.prototype.index = deprecate.function(function index (paths) { + var index = !paths ? [] : normalizeList(paths, 'paths argument') + debug('index %o', paths) + this._index = index + return this +}, 'send.index: pass index as option') /** * Set root `path`. @@ -194,17 +225,17 @@ SendStream.prototype.index = deprecate.function(function index(paths) { * @api public */ -SendStream.prototype.root = function(path){ - path = String(path); - this._root = resolve(path) - return this; -}; +SendStream.prototype.root = function root (path) { + this._root = resolve(String(path)) + debug('root %s', this._root) + return this +} SendStream.prototype.from = deprecate.function(SendStream.prototype.root, - 'send.from: pass root as option'); + 'send.from: pass root as option') SendStream.prototype.root = deprecate.function(SendStream.prototype.root, - 'send.root: pass root as option'); + 'send.root: pass root as option') /** * Set max-age to `maxAge`. @@ -214,53 +245,62 @@ SendStream.prototype.root = deprecate.function(SendStream.prototype.root, * @api public */ -SendStream.prototype.maxage = deprecate.function(function maxage(maxAge) { - maxAge = typeof maxAge === 'string' +SendStream.prototype.maxage = deprecate.function(function maxage (maxAge) { + this._maxage = typeof maxAge === 'string' ? ms(maxAge) - : Number(maxAge); - if (isNaN(maxAge)) maxAge = 0; - if (Infinity == maxAge) maxAge = 60 * 60 * 24 * 365 * 1000; - debug('max-age %d', maxAge); - this._maxage = maxAge; - return this; -}, 'send.maxage: pass maxAge as option'); + : 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`. * - * @param {Number} status - * @api private + * @param {number} status + * @param {Error} [err] + * @private */ -SendStream.prototype.error = function(status, err){ - var res = this.res; - var msg = http.STATUS_CODES[status]; - - err = err || new Error(msg); - err.status = status; - +SendStream.prototype.error = function error (status, err) { // emit if listeners instead of responding - if (listenerCount(this, 'error') !== 0) { - return this.emit('error', err); + if (hasListeners(this, 'error')) { + return this.emit('error', createHttpError(status, err)) } - // wipe all existing headers - res._headers = undefined; + var res = this.res + var msg = statuses.message[status] || String(status) + var doc = createHtmlDocument('Error', escapeHtml(msg)) - res.statusCode = err.status; - res.end(msg); -}; + // clear existing headers + clearHeaders(res) + + // add error headers + if (err && err.headers) { + setHeaders(res, err.headers) + } + + // send basic response + res.statusCode = status + res.setHeader('Content-Type', 'text/html; charset=UTF-8') + res.setHeader('Content-Length', Buffer.byteLength(doc)) + res.setHeader('Content-Security-Policy', "default-src 'none'") + res.setHeader('X-Content-Type-Options', 'nosniff') + res.end(doc) +} /** * Check if the pathname ends with "/". * - * @return {Boolean} - * @api private + * @return {boolean} + * @private */ -SendStream.prototype.hasTrailingSlash = function(){ - return '/' == this.path[this.path.length - 1]; -}; +SendStream.prototype.hasTrailingSlash = function hasTrailingSlash () { + return this.path[this.path.length - 1] === '/' +} /** * Check if this is a conditional GET request. @@ -269,25 +309,58 @@ SendStream.prototype.hasTrailingSlash = function(){ * @api private */ -SendStream.prototype.isConditionalGET = function(){ - return this.req.headers['if-none-match'] - || this.req.headers['if-modified-since']; -}; +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'] +} /** - * Strip content-* header fields. + * Check if the request preconditions failed. * - * @api private + * @return {boolean} + * @private */ -SendStream.prototype.removeContentHeaderFields = function(){ - var res = this.res; - Object.keys(res._headers).forEach(function(field){ - if (0 == field.indexOf('content')) { - res.removeHeader(field); - } - }); -}; +SendStream.prototype.isPreconditionFailure = function isPreconditionFailure () { + var req = this.req + var res = this.res + + // if-match + var match = req.headers['if-match'] + if (match) { + var etag = res.getHeader('ETag') + return !etag || (match !== '*' && parseTokenList(match).every(function (match) { + return match !== etag && match !== 'W/' + etag && 'W/' + match !== etag + })) + } + + // if-unmodified-since + var unmodifiedSince = parseHttpDate(req.headers['if-unmodified-since']) + if (!isNaN(unmodifiedSince)) { + var lastModified = parseHttpDate(res.getHeader('Last-Modified')) + return isNaN(lastModified) || lastModified > unmodifiedSince + } + + return false +} + +/** + * Strip various content header fields for a change in entity. + * + * @private + */ + +SendStream.prototype.removeContentHeaderFields = function removeContentHeaderFields () { + var res = this.res + + res.removeHeader('Content-Encoding') + res.removeHeader('Content-Language') + res.removeHeader('Content-Length') + res.removeHeader('Content-Range') + res.removeHeader('Content-Type') +} /** * Respond with 304 not modified. @@ -295,13 +368,13 @@ SendStream.prototype.removeContentHeaderFields = function(){ * @api private */ -SendStream.prototype.notModified = function(){ - var res = this.res; - debug('not modified'); - this.removeContentHeaderFields(); - res.statusCode = 304; - res.end(); -}; +SendStream.prototype.notModified = function notModified () { + var res = this.res + debug('not modified') + this.removeContentHeaderFields() + res.statusCode = 304 + res.end() +} /** * Raise error that headers already sent. @@ -309,11 +382,11 @@ SendStream.prototype.notModified = function(){ * @api private */ -SendStream.prototype.headersAlreadySent = function headersAlreadySent(){ - var err = new Error('Can\'t set headers after they are sent.'); - debug('headers already sent'); - this.error(500, err); -}; +SendStream.prototype.headersAlreadySent = function headersAlreadySent () { + var err = new Error('Can\'t set headers after they are sent.') + debug('headers already sent') + this.error(500, err) +} /** * Check if the request is cacheable, aka @@ -323,23 +396,31 @@ SendStream.prototype.headersAlreadySent = function headersAlreadySent(){ * @api private */ -SendStream.prototype.isCachable = function(){ - var res = this.res; - return (res.statusCode >= 200 && res.statusCode < 300) || 304 == res.statusCode; -}; +SendStream.prototype.isCachable = function isCachable () { + var statusCode = this.res.statusCode + return (statusCode >= 200 && statusCode < 300) || + statusCode === 304 +} /** * Handle stat() error. * - * @param {Error} err - * @api private + * @param {Error} error + * @private */ -SendStream.prototype.onStatError = function(err){ - var notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR']; - if (~notfound.indexOf(err.code)) return this.error(404, err); - this.error(500, err); -}; +SendStream.prototype.onStatError = function onStatError (error) { + switch (error.code) { + case 'ENAMETOOLONG': + case 'ENOENT': + case 'ENOTDIR': + this.error(404, error) + break + default: + this.error(500, error) + break + } +} /** * Check if the cache is fresh. @@ -348,9 +429,12 @@ SendStream.prototype.onStatError = function(err){ * @api private */ -SendStream.prototype.isFresh = function(){ - return fresh(this.req.headers, this.res._headers); -}; +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. @@ -359,36 +443,57 @@ SendStream.prototype.isFresh = function(){ * @api private */ -SendStream.prototype.isRangeFresh = function isRangeFresh(){ - var ifRange = this.req.headers['if-range']; +SendStream.prototype.isRangeFresh = function isRangeFresh () { + var ifRange = this.req.headers['if-range'] - if (!ifRange) return true; + if (!ifRange) { + return true + } - return ~ifRange.indexOf('"') - ? ~ifRange.indexOf(this.res._headers['etag']) - : Date.parse(this.res._headers['last-modified']) <= Date.parse(ifRange); -}; + // if-range as etag + if (ifRange.indexOf('"') !== -1) { + var etag = this.res.getHeader('ETag') + return Boolean(etag && ifRange.indexOf(etag) !== -1) + } + + // if-range as modified date + var lastModified = this.res.getHeader('Last-Modified') + return parseHttpDate(lastModified) <= parseHttpDate(ifRange) +} /** - * Redirect to `path`. + * Redirect to path. * - * @param {String} path - * @api private + * @param {string} path + * @private */ -SendStream.prototype.redirect = function(path){ - if (listenerCount(this, 'directory') !== 0) { - return this.emit('directory'); +SendStream.prototype.redirect = function redirect (path) { + var res = this.res + + if (hasListeners(this, 'directory')) { + this.emit('directory', res, path) + return + } + + if (this.hasTrailingSlash()) { + this.error(403) + return } - if (this.hasTrailingSlash()) return this.error(403); - var res = this.res; - path += '/'; - res.statusCode = 301; - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.setHeader('Location', path); - res.end('Redirecting to ' + escapeHtml(path) + '\n'); -}; + var loc = encodeUrl(collapseLeadingSlashes(this.path + '/')) + var doc = createHtmlDocument('Redirecting', 'Redirecting to ' + + escapeHtml(loc) + '') + + // redirect + res.statusCode = 301 + res.setHeader('Content-Type', 'text/html; charset=UTF-8') + res.setHeader('Content-Length', Buffer.byteLength(doc)) + res.setHeader('Content-Security-Policy', "default-src 'none'") + res.setHeader('X-Content-Type-Options', 'nosniff') + res.setHeader('Location', loc) + res.end(doc) +} /** * Pipe to `res. @@ -398,40 +503,51 @@ SendStream.prototype.redirect = function(path){ * @api public */ -SendStream.prototype.pipe = function(res){ - var self = this - , args = arguments - , root = this._root; +SendStream.prototype.pipe = function pipe (res) { + // root path + var root = this._root // references - this.res = res; + this.res = res // decode the path var path = decode(this.path) - if (path === -1) return this.error(400) + if (path === -1) { + this.error(400) + return res + } // null byte(s) - if (~path.indexOf('\0')) return this.error(400); + if (~path.indexOf('\0')) { + this.error(400) + return res + } var parts if (root !== null) { + // normalize + if (path) { + path = normalize('.' + sep + path) + } + // malicious path - if (upPathRegexp.test(normalize('.' + sep + path))) { + if (UP_PATH_REGEXP.test(path)) { debug('malicious path "%s"', path) - return this.error(403) + this.error(403) + return res } + // explode path parts + parts = path.split(sep) + // join / normalize from optional root dir path = normalize(join(root, path)) - root = normalize(root + sep) - - // explode path parts - parts = path.substr(root.length).split(sep) } else { // ".." is malicious without "root" - if (upPathRegexp.test(path)) { + if (UP_PATH_REGEXP.test(path)) { debug('malicious path "%s"', path) - return this.error(403) + this.error(403) + return res } // explode path parts @@ -457,22 +573,24 @@ SendStream.prototype.pipe = function(res){ case 'allow': break case 'deny': - return this.error(403) + this.error(403) + return res case 'ignore': default: - return this.error(404) + this.error(404) + return res } } // index file support - if (this._index.length && this.path[this.path.length - 1] === '/') { - this.sendIndex(path); - return res; + if (this._index.length && this.hasTrailingSlash()) { + this.sendIndex(path) + return res } - this.sendFile(path); - return res; -}; + this.sendFile(path) + return res +} /** * Transfer `path`. @@ -481,85 +599,109 @@ SendStream.prototype.pipe = function(res){ * @api public */ -SendStream.prototype.send = function(path, stat){ - var options = this.options; - var len = stat.size; - var res = this.res; - var req = this.req; - var ranges = req.headers.range; - var offset = options.start || 0; +SendStream.prototype.send = function send (path, stat) { + var len = stat.size + var options = this.options + var opts = {} + var res = this.res + var req = this.req + var ranges = req.headers.range + var offset = options.start || 0 - if (res._header) { + if (headersSent(res)) { // impossible to send now - return this.headersAlreadySent(); + this.headersAlreadySent() + return } debug('pipe "%s"', path) // set header fields - this.setHeader(path, stat); + this.setHeader(path, stat) // set content-type - this.type(path); + this.type(path) // conditional GET support - if (this.isConditionalGET() - && this.isCachable() - && this.isFresh()) { - return this.notModified(); + 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); + len = Math.max(0, len - offset) if (options.end !== undefined) { - var bytes = options.end - offset + 1; - if (len > bytes) len = bytes; + var bytes = options.end - offset + 1 + if (len > bytes) len = bytes } // Range support - if (ranges) { - ranges = parseRange(len, ranges); + if (this._acceptRanges && BYTES_RANGE_REGEXP.test(ranges)) { + // parse + ranges = parseRange(len, ranges, { + combine: true + }) // If-Range support if (!this.isRangeFresh()) { - debug('range stale'); - ranges = -2; + debug('range stale') + ranges = -2 } // unsatisfiable - if (-1 == ranges) { - debug('range unsatisfiable'); - res.setHeader('Content-Range', 'bytes */' + stat.size); - return this.error(416); + 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 (-2 != ranges && ranges.length === 1) { - debug('range %j', ranges); - - options.start = offset + ranges[0].start; - options.end = offset + ranges[0].end; + if (ranges !== -2 && ranges.length === 1) { + debug('range %j', ranges) // Content-Range - res.statusCode = 206; - res.setHeader('Content-Range', 'bytes ' - + ranges[0].start - + '-' - + ranges[0].end - + '/' - + len); - len = options.end - options.start + 1; + res.statusCode = 206 + res.setHeader('Content-Range', contentRange('bytes', len, ranges[0])) + + // adjust for requested range + offset += ranges[0].start + len = ranges[0].end - ranges[0].start + 1 } } + // clone options + for (var prop in options) { + opts[prop] = options[prop] + } + + // set read options + opts.start = offset + opts.end = Math.max(offset, offset + len - 1) + // content-length - res.setHeader('Content-Length', len); + res.setHeader('Content-Length', len) // HEAD support - if ('HEAD' == req.method) return res.end(); + if (req.method === 'HEAD') { + res.end() + return + } - this.stream(path, options); -}; + this.stream(path, opts) +} /** * Transfer file for `path`. @@ -567,25 +709,23 @@ SendStream.prototype.send = function(path, stat){ * @param {String} path * @api private */ -SendStream.prototype.sendFile = function sendFile(path) { +SendStream.prototype.sendFile = function sendFile (path) { var i = 0 var 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) { + 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(self.path) + if (stat.isDirectory()) return self.redirect(path) self.emit('file', path, stat) self.send(path, stat) }) - function next(err) { + function next (err) { if (self._extensions.length <= i) { return err ? self.onStatError(err) @@ -610,29 +750,29 @@ SendStream.prototype.sendFile = function sendFile(path) { * @param {String} path * @api private */ -SendStream.prototype.sendIndex = function sendIndex(path){ - var i = -1; - var self = this; +SendStream.prototype.sendIndex = function sendIndex (path) { + var i = -1 + var self = this - function next(err){ + function next (err) { if (++i >= self._index.length) { - if (err) return self.onStatError(err); - return self.error(404); + if (err) return self.onStatError(err) + return self.error(404) } - var p = join(path, self._index[i]); + var p = join(path, self._index[i]) - debug('stat "%s"', p); - fs.stat(p, function(err, stat){ - if (err) return next(err); - if (stat.isDirectory()) return next(); - self.emit('file', p, stat); - self.send(p, stat); - }); + 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(); -}; + next() +} /** * Stream `path` to the response. @@ -642,42 +782,37 @@ SendStream.prototype.sendIndex = function sendIndex(path){ * @api private */ -SendStream.prototype.stream = function(path, options){ - // TODO: this is all lame, refactor meeee - var finished = false; - var self = this; - var res = this.res; - var req = this.req; +SendStream.prototype.stream = function stream (path, options) { + var self = this + var res = this.res // pipe - var stream = fs.createReadStream(path, options); - this.emit('stream', stream); - stream.pipe(res); + var stream = fs.createReadStream(path, options) + this.emit('stream', stream) + stream.pipe(res) - // response finished, done with the fd - onFinished(res, function onfinished(){ - finished = true; - destroy(stream); - }); + // cleanup + function cleanup () { + destroy(stream, true) + } - // error handling code-smell - stream.on('error', function onerror(err){ - // request already finished - if (finished) return; + // response finished, cleanup + onFinished(res, cleanup) - // clean up stream - finished = true; - destroy(stream); + // error handling + stream.on('error', function onerror (err) { + // clean up stream early + cleanup() // error - self.onStatError(err); - }); + self.onStatError(err) + }) // end - stream.on('end', function onend(){ - self.emit('end'); - }); -}; + stream.on('end', function onend () { + self.emit('end') + }) +} /** * Set content-type based on `path` @@ -687,14 +822,23 @@ SendStream.prototype.stream = function(path, options){ * @api private */ -SendStream.prototype.type = function(path){ - var res = this.res; - if (res.getHeader('Content-Type')) return; - var type = mime.lookup(path); - var charset = mime.charsets.lookup(type); - debug('content-type %s', type); - res.setHeader('Content-Type', type + (charset ? '; charset=' + charset : '')); -}; +SendStream.prototype.type = function type (path) { + var res = this.res + + if (res.getHeader('Content-Type')) return + + var type = mime.lookup(path) + + if (!type) { + debug('no content-type') + return + } + + var charset = mime.charsets.lookup(type) + + debug('content-type %s', type) + res.setHeader('Content-Type', type + (charset ? '; charset=' + charset : '')) +} /** * Set response header fields, most @@ -705,14 +849,26 @@ SendStream.prototype.type = function(path){ * @api private */ -SendStream.prototype.setHeader = function setHeader(path, stat){ - var res = this.res; +SendStream.prototype.setHeader = function setHeader (path, stat) { + var res = this.res - this.emit('headers', res, path, stat); + this.emit('headers', res, path, stat) - if (!res.getHeader('Accept-Ranges')) res.setHeader('Accept-Ranges', 'bytes'); - if (!res.getHeader('Date')) res.setHeader('Date', new Date().toUTCString()); - if (!res.getHeader('Cache-Control')) res.setHeader('Cache-Control', 'public, max-age=' + Math.floor(this._maxage / 1000)); + if (this._acceptRanges && !res.getHeader('Accept-Ranges')) { + debug('accept ranges') + res.setHeader('Accept-Ranges', 'bytes') + } + + if (this._cacheControl && !res.getHeader('Cache-Control')) { + var cacheControl = 'public, max-age=' + Math.floor(this._maxage / 1000) + + if (this._immutable) { + cacheControl += ', immutable' + } + + debug('cache-control %s', cacheControl) + res.setHeader('Cache-Control', cacheControl) + } if (this._lastModified && !res.getHeader('Last-Modified')) { var modified = stat.mtime.toUTCString() @@ -725,7 +881,40 @@ SendStream.prototype.setHeader = function setHeader(path, stat){ debug('etag %s', val) res.setHeader('ETag', val) } -}; +} + +/** + * Clear all headers from a response. + * + * @param {object} res + * @private + */ + +function clearHeaders (res) { + var headers = getHeaderNames(res) + + for (var 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) { + for (var i = 0; i < str.length; i++) { + if (str[i] !== '/') { + break + } + } + + return i > 1 + ? '/' + str.substr(i) + : str +} /** * Determine if path parts contain a dotfile. @@ -733,9 +922,10 @@ SendStream.prototype.setHeader = function setHeader(path, stat){ * @api private */ -function containsDotFile(parts) { +function containsDotFile (parts) { for (var i = 0; i < parts.length; i++) { - if (parts[i][0] === '.') { + var part = parts[i] + if (part.length > 1 && part[0] === '.') { return true } } @@ -743,6 +933,57 @@ function containsDotFile(parts) { return false } +/** + * Create a Content-Range header. + * + * @param {string} type + * @param {number} size + * @param {array} [range] + */ + +function contentRange (type, size, range) { + return type + ' ' + (range ? range.start + '-' + range.end : '*') + '/' + size +} + +/** + * Create a minimal HTML document. + * + * @param {string} title + * @param {string} body + * @private + */ + +function createHtmlDocument (title, body) { + return '\n' + + '\n' + + '\n' + + '\n' + + '' + title + '\n' + + '\n' + + '\n' + + '
' + body + '
\n' + + '\n' + + '\n' +} + +/** + * Create a HttpError object from simple arguments. + * + * @param {number} status + * @param {Error|object} err + * @private + */ + +function createHttpError (status, err) { + if (!err) { + return createError(status) + } + + return err instanceof Error + ? createError(status, err, { expose: false }) + : createError(status, err) +} + /** * decodeURIComponent. * @@ -753,7 +994,7 @@ function containsDotFile(parts) { * @api private */ -function decode(path) { +function decode (path) { try { return decodeURIComponent(path) } catch (err) { @@ -761,13 +1002,142 @@ 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 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) { + var count = typeof emitter.listenerCount !== 'function' + ? emitter.listeners(type).length + : emitter.listenerCount(type) + + return count > 0 +} + +/** + * 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. * * @param {boolean|string|array} val - * @api private + * @param {string} name + * @private + */ + +function normalizeList (val, name) { + var list = [].concat(val || []) + + for (var i = 0; i < list.length; i++) { + if (typeof list[i] !== 'string') { + throw new TypeError(name + ' must be array of strings or false') + } + } + + return list +} + +/** + * Parse an HTTP Date into a number. + * + * @param {string} date + * @private + */ + +function parseHttpDate (date) { + var timestamp = date && Date.parse(date) + + return typeof timestamp === 'number' + ? timestamp + : NaN +} + +/** + * Parse a HTTP token list. + * + * @param {string} str + * @private + */ + +function parseTokenList (str) { + var end = 0 + var list = [] + var start = 0 + + // gather tokens + for (var i = 0, len = str.length; i < len; i++) { + switch (str.charCodeAt(i)) { + case 0x20: /* */ + if (start === end) { + start = end = i + 1 + } + break + case 0x2c: /* , */ + if (start !== end) { + list.push(str.substring(start, end)) + } + start = end = i + 1 + break + default: + end = i + 1 + break + } + } + + // final token + if (start !== end) { + list.push(str.substring(start, end)) + } + + return list +} + +/** + * Set an object of headers on a response. + * + * @param {object} res + * @param {object} headers + * @private */ -function normalizeList(val){ - return [].concat(val || []) +function setHeaders (res, headers) { + var keys = Object.keys(headers) + + for (var i = 0; i < keys.length; i++) { + var key = keys[i] + res.setHeader(key, headers[key]) + } } diff --git a/package.json b/package.json index 24553cf2..7f269d51 100644 --- a/package.json +++ b/package.json @@ -1,47 +1,62 @@ { "name": "send", "description": "Better streaming static file server with Range and conditional-GET support", - "version": "0.11.1", + "version": "0.18.0", "author": "TJ Holowaychuk ", "contributors": [ - "Douglas Christopher Wilson " + "Douglas Christopher Wilson ", + "James Wyatt Cready ", + "Jesús Leganés Combarro " ], "license": "MIT", - "repository": "tj/send", + "repository": "pillarjs/send", "keywords": [ "static", "file", "server" ], "dependencies": { - "debug": "~2.1.1", - "depd": "~1.0.0", - "destroy": "1.0.3", - "escape-html": "1.0.1", - "etag": "~1.5.1", - "fresh": "0.2.4", - "mime": "1.2.11", - "ms": "0.7.0", - "on-finished": "~2.2.0", - "range-parser": "~1.0.2" + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" }, "devDependencies": { - "after": "0.8.1", - "istanbul": "0.3.5", - "mocha": "~2.1.0", - "supertest": "~0.15.0" + "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", + "supertest": "6.2.2" }, "files": [ - "History.md", + "HISTORY.md", "LICENSE", + "README.md", + "SECURITY.md", "index.js" ], "engines": { "node": ">= 0.8.0" }, "scripts": { + "lint": "eslint .", "test": "mocha --check-leaks --reporter spec --bail", - "test-cov": "istanbul cover node_modules/mocha/bin/_mocha -- --check-leaks --reporter dot", - "test-travis": "istanbul cover node_modules/mocha/bin/_mocha --report lcovonly -- --check-leaks --reporter spec" + "test-ci": "nyc --reporter=lcov --reporter=text npm test", + "test-cov": "nyc --reporter=html --reporter=text npm test" } } diff --git a/test/.eslintrc.yml b/test/.eslintrc.yml new file mode 100644 index 00000000..9808c3b2 --- /dev/null +++ b/test/.eslintrc.yml @@ -0,0 +1,2 @@ +env: + mocha: true diff --git a/test/fixtures/.hidden.txt b/test/fixtures/.hidden.txt new file mode 100644 index 00000000..536aca34 --- /dev/null +++ b/test/fixtures/.hidden.txt @@ -0,0 +1 @@ +secret \ No newline at end of file diff --git a/test/fixtures/.hidden b/test/fixtures/.mine/.hidden similarity index 100% rename from test/fixtures/.hidden rename to test/fixtures/.mine/.hidden diff --git a/test/fixtures/empty.txt b/test/fixtures/empty.txt new file mode 100644 index 00000000..e69de29b diff --git a/test/fixtures/no_ext b/test/fixtures/no_ext new file mode 100644 index 00000000..f6ea0495 --- /dev/null +++ b/test/fixtures/no_ext @@ -0,0 +1 @@ +foobar \ No newline at end of file diff --git a/test/fixtures/nums b/test/fixtures/nums.txt similarity index 100% rename from test/fixtures/nums rename to test/fixtures/nums.txt diff --git a/test/fixtures/pets/.hidden b/test/fixtures/pets/.hidden new file mode 100644 index 00000000..d97c5ead --- /dev/null +++ b/test/fixtures/pets/.hidden @@ -0,0 +1 @@ +secret diff --git "a/test/fixtures/snow \342\230\203/index.html" "b/test/fixtures/snow \342\230\203/index.html" new file mode 100644 index 00000000..e69de29b diff --git a/test/send.js b/test/send.js index 0ed46820..d419f8f9 100644 --- a/test/send.js +++ b/test/send.js @@ -1,278 +1,278 @@ -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'); +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]); +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]) } - function redirect() { - res.statusCode = 301; - res.setHeader('Location', req.url + '/'); - res.end('Redirecting to ' + req.url + '/'); - } - - send(req, req.url, {root: fixtures}) - .on('error', error) - .on('directory', redirect) - .pipe(res); -}); + send(req, req.url, { root: fixtures }) + .on('error', error) + .pipe(res) +}) -describe('send.mime', function(){ - it('should be exposed', function(){ - assert(send.mime); +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) }) -}) -describe('send(file).pipe(res)', function(){ - it('should stream the file contents', function(done){ + it('should stream a zero-length file', function (done) { request(app) - .get('/name.txt') - .expect('Content-Length', '4') - .expect(200, 'tobi', done) + .get('/empty.txt') + .expect('Content-Length', '0') + .expect(200, '', done) }) - it('should decode the given path as a URI', function(done){ + it('should decode the given path as a URI', function (done) { request(app) - .get('/some%20thing.txt') - .expect(200, 'hey', done) + .get('/some%20thing.txt') + .expect(200, 'hey', done) }) - it('should serve files with dots in name', function(done){ + it('should serve files with dots in name', function (done) { request(app) - .get('/do..ts.txt') - .expect(200, '...', done) + .get('/do..ts.txt') + .expect(200, '...', done) }) - it('should treat a malformed URI as a bad request', function(done){ + it('should treat a malformed URI as a bad request', function (done) { request(app) - .get('/some%99thing.txt') - .expect(400, 'Bad Request', done) + .get('/some%99thing.txt') + .expect(400, 'Bad Request', done) }) - it('should 400 on NULL bytes', function(done){ + it('should 400 on NULL bytes', function (done) { request(app) - .get('/some%00thing.txt') - .expect(400, 'Bad Request', done) + .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'); + it('should treat an ENAMETOOLONG as a 404', function (done) { + var path = Array(100).join('foobar') request(app) - .get('/' + path) - .expect(404, done); + .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); - }); + 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('/nums') - .expect(200, '0 - Can\'t set headers after they are sent.', done); + .get('/name.txt') + .expect(200, '0 - Can\'t set headers after they are sent.', done) }) - it('should support HEAD', function(done){ + it('should support HEAD', function (done) { request(app) - .head('/name.txt') - .expect('Content-Length', '4') - .expect(200, '', done) + .head('/name.txt') + .expect(200) + .expect('Content-Length', '4') + .expect(shouldNotHaveBody()) + .end(done) }) - it('should add an ETag header field', function(done){ + it('should add an ETag header field', function (done) { request(app) - .get('/name.txt') - .expect('etag', /^W\/"[^"]+"$/) - .end(done); + .get('/name.txt') + .expect('etag', /^W\/"[^"]+"$/) + .end(done) }) - it('should add a Date header field', function(done){ + it('should add a Date header field', function (done) { request(app) - .get('/name.txt') - .expect('date', dateRegExp, done) + .get('/name.txt') + .expect('date', dateRegExp, done) }) - it('should add a Last-Modified header field', function(done){ + it('should add a Last-Modified header field', function (done) { request(app) - .get('/name.txt') - .expect('last-modified', dateRegExp, done) + .get('/name.txt') + .expect('last-modified', dateRegExp, done) }) - it('should add a Accept-Ranges header field', function(done){ + it('should add a Accept-Ranges header field', function (done) { request(app) - .get('/name.txt') - .expect('Accept-Ranges', 'bytes', done) + .get('/name.txt') + .expect('Accept-Ranges', 'bytes', done) }) - it('should 404 if the file does not exist', function(done){ + it('should 404 if the file does not exist', function (done) { request(app) - .get('/meow') - .expect(404, 'Not Found', done) + .get('/meow') + .expect(404, 'Not Found', done) }) - it('should 301 if the directory exists', function(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('/pets') - .expect('Location', '/pets/') - .expect(301, 'Redirecting to /pets/', done) + .get('/meow') + .expect(200, '404 ENOENT', done) }) - it('should not override content-type', function(done){ - var app = http.createServer(function(req, res){ + 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) - }); + send(req, req.url, { root: fixtures }).pipe(res) + }) request(app) - .get('/nums') - .expect('Content-Type', 'application/x-custom', done); + .get('/name.txt') + .expect('Content-Type', 'application/x-custom', done) }) - it('should set Content-Type via mime map', function(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) - }); + .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){ - var app = http.createServer(function(req, res){ - send(req, req.url, {root: 'test/fixtures'}) - .on('file', function(){ + 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){ - path += '__xxx_no_exist'; - fn.call(this, path, stat); - }; - }) - .pipe(res); - }); + 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); + .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){ + 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 - process.nextTick(function(){ - stream.emit('error', new Error('boom!')); - }); - }) - .pipe(res); - }); + stream.on('open', function () { + stream.emit('error', new Error('boom!')) + }) + }) + .pipe(res) + }) request(app) - .get('/name.txt') - .expect(500, done); + .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) + send(req, req.url, { root: fixtures }) + .on('headers', function () { cb() }) + .pipe(res) }) request(server) - .get('/nums') - .expect(200, '123456789', cb) + .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) + send(req, req.url, { root: fixtures }) + .on('headers', function () { cb() }) + .pipe(res) }) request(server) - .get('/bogus') - .expect(404, cb) + .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) + send(req, req.url, { root: fixtures }) + .on('headers', function () { cb() }) + .pipe(res) }) request(server) - .get('/pets/') - .expect(200, /tobi/, cb) + .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) + send(req, req.url, { root: fixtures }) + .on('headers', function () { cb() }) + .pipe(res) }) request(server) - .get('/pets') - .expect(301, cb) + .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) + send(req, req.url, { root: fixtures }) + .on('headers', onHeaders) + .pipe(res) }) - function onHeaders(res, filePath) { + function onHeaders (res, filePath) { assert.ok(filePath) - assert.equal(path.normalize(filePath), path.normalize(path.join(fixtures, 'nums'))) + assert.strictEqual(path.normalize(filePath), path.normalize(path.join(fixtures, 'name.txt'))) cb() } request(server) - .get('/nums') - .expect(200, '123456789', cb) + .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) + send(req, req.url, { root: fixtures }) + .on('headers', onHeaders) + .pipe(res) }) - function onHeaders(res, path, stat) { + function onHeaders (res, path, stat) { assert.ok(stat) assert.ok('ctime' in stat) assert.ok('mtime' in stat) @@ -280,18 +280,18 @@ describe('send(file).pipe(res)', function(){ } request(server) - .get('/nums') - .expect(200, '123456789', cb) + .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) + send(req, req.url, { root: fixtures }) + .on('headers', onHeaders) + .pipe(res) }) - function onHeaders(res, path, stat) { + function onHeaders (res, path, stat) { res.setHeader('Cache-Control', 'no-cache') res.setHeader('Content-Type', 'text/x-custom') res.setHeader('ETag', 'W/"everything"') @@ -299,928 +299,1219 @@ describe('send(file).pipe(res)', function(){ } request(server) - .get('/nums') - .expect('Cache-Control', 'no-cache') - .expect('Content-Type', 'text/x-custom') - .expect('ETag', 'W/"everything"') - .expect('X-Created', dateRegExp) - .expect(200, '123456789', done) + .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 no "directory" listeners are present', function(){ - var server - before(function(){ - server = http.createServer(function(req, res){ - send(req, req.url, {root: 'test/fixtures'}) - .pipe(res) + 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 respond with an HTML redirect', function(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('Location', '/pets/') - .expect('Content-Type', 'text/html; charset=utf-8') - .expect(301, 'Redirecting to /pets/\n', done) + .get('/pets') + .expect(200, path.normalize(path.join(fixtures, 'pets')), done) }) }) - describe('when no "error" listeners are present', function(){ - it('should respond to errors directly', function(done){ - var app = http.createServer(function(req, res){ - send(req, 'test/fixtures' + req.url).pipe(res); - }); - + 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); + .get('/') + .expect(200, '

tobi

', done) }) - it('should support disabling', function(done){ - var app = http.createServer(function(req, res){ - send(req, req.url, {root: fixtures}) - .index(false) - .pipe(res); - }); + it('should support disabling', function (done) { + var app = http.createServer(function (req, res) { + send(req, req.url, { root: fixtures }) + .index(false) + .pipe(res) + }) request(app) - .get('/pets/') - .expect(403, done); + .get('/pets/') + .expect(403, done) }) - it('should support fallbacks', function(done){ - var app = http.createServer(function(req, res){ - send(req, req.url, {root: fixtures}) - .index(['default.htm', 'index.html']) - .pipe(res); - }); + it('should support fallbacks', function (done) { + var 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) + .get('/pets/') + .expect(200, fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8'), done) }) }) - describe('.maxage()', function(){ - it('should default to 0', function(done){ - var app = http.createServer(function(req, res){ + describe('.maxage()', function () { + it('should default to 0', function (done) { + var app = http.createServer(function (req, res) { send(req, 'test/fixtures/name.txt') - .maxage(undefined) - .pipe(res); - }); + .maxage(undefined) + .pipe(res) + }) request(app) - .get('/name.txt') - .expect('Cache-Control', 'public, max-age=0', done) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=0', done) }) - it('should floor to integer', function(done){ - var app = http.createServer(function(req, res){ + it('should floor to integer', function (done) { + var app = http.createServer(function (req, res) { send(req, 'test/fixtures/name.txt') - .maxage(1234) - .pipe(res); - }); + .maxage(1234) + .pipe(res) + }) request(app) - .get('/name.txt') - .expect('Cache-Control', 'public, max-age=1', done) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=1', done) }) - it('should accept string', function(done){ - var app = http.createServer(function(req, res){ + it('should accept string', function (done) { + var app = http.createServer(function (req, res) { send(req, 'test/fixtures/name.txt') - .maxage('30d') - .pipe(res); - }); + .maxage('30d') + .pipe(res) + }) request(app) - .get('/name.txt') - .expect('Cache-Control', 'public, max-age=2592000', done) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=2592000', done) }) - it('should max at 1 year', function(done){ - var app = http.createServer(function(req, res){ + it('should max at 1 year', function (done) { + var app = http.createServer(function (req, res) { send(req, 'test/fixtures/name.txt') - .maxage(Infinity) - .pipe(res); - }); + .maxage(Infinity) + .pipe(res) + }) request(app) - .get('/name.txt') - .expect('Cache-Control', 'public, max-age=31536000', done) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=31536000', done) }) }) - describe('.root()', function(){ - it('should set root', function(done){ - var app = http.createServer(function(req, res){ + describe('.root()', function () { + it('should set root', function (done) { + var app = http.createServer(function (req, res) { send(req, req.url) - .root(__dirname + '/fixtures') - .pipe(res); - }); + .root(fixtures) + .pipe(res) + }) request(app) - .get('/pets/../name.txt') - .expect(200, 'tobi', done) + .get('/pets/../name.txt') + .expect(200, 'tobi', done) }) }) }) -describe('send(file, options)', function(){ - describe('etag', function(){ - it('should support disabling etags', function(done){ - var app = http.createServer(function(req, res){ - send(req, req.url, {etag: false, root: fixtures}) - .pipe(res); - }); +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) + }) - request(app) - .get('/nums') - .expect(shouldNotHaveHeader('ETag')) - .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 be not be enabled by default', function (done) { - var server = createServer({root: fixtures}); + it('should reject numbers', function (done) { + request(createServer({ extensions: 42, root: fixtures })) + .get('/pets/') + .expect(500, /TypeError: extensions option/, done) + }) - request(server) - .get('/tobi') - .expect(404, done) + it('should reject true', function (done) { + request(createServer({ extensions: true, root: fixtures })) + .get('/pets/') + .expect(500, /TypeError: extensions option/, done) }) - it('should be configurable', function (done) { - var server = createServer({extensions: 'txt', root: fixtures}) + it('should be not be enabled by default', function (done) { + request(createServer({ root: fixtures })) + .get('/tobi') + .expect(404, done) + }) - request(server) - .get('/name') - .expect(200, 'tobi', 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) { - var server = createServer({extensions: false, root: fixtures}) - - request(server) - .get('/name') - .expect(404, done) + request(createServer({ extensions: false, root: fixtures })) + .get('/name') + .expect(404, done) }) it('should support fallbacks', function (done) { - var server = createServer({extensions: ['htm', 'html', 'txt'], root: fixtures}) - - request(server) - .get('/name') - .expect(200, '

tobi

', done) + request(createServer({ extensions: ['htm', 'html', 'txt'], root: fixtures })) + .get('/name') + .expect(200, '

tobi

', done) }) it('should 404 if nothing found', function (done) { - var server = createServer({extensions: ['htm', 'html', 'txt'], root: fixtures}) - - request(server) - .get('/bob') - .expect(404, done) + request(createServer({ extensions: ['htm', 'html', 'txt'], root: fixtures })) + .get('/bob') + .expect(404, done) }) it('should skip directories', function (done) { - var server = createServer({extensions: ['file', 'dir'], root: fixtures}) - - request(server) - .get('/name') - .expect(404, done) + request(createServer({ extensions: ['file', 'dir'], root: fixtures })) + .get('/name') + .expect(404, done) }) it('should not search if file has extension', function (done) { - var server = createServer({extensions: 'html', root: fixtures}) - - request(server) - .get('/thing.html') - .expect(404, done) + request(createServer({ extensions: 'html', root: fixtures })) + .get('/thing.html') + .expect(404, done) }) }) describe('lastModified', function () { it('should support disabling last-modified', function (done) { - var app = http.createServer(function(req, res){ - send(req, req.url, {lastModified: false, root: fixtures}) - .pipe(res) - }) - - request(app) - .get('/nums') - .expect(shouldNotHaveHeader('Last-Modified')) - .expect(200, 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){ - var app = http.createServer(function(req, res){ - send(req, req.url, {from: __dirname + '/fixtures'}) - .pipe(res) - }); - - request(app) - .get('/pets/../name.txt') - .expect(200, 'tobi', 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') - .expect(404, 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) + request(createServer({ root: fixtures })) + .get('/.mine/name.txt') + .expect(200, /tobi/, done) }) it('should reject bad value', function (done) { - request(createServer({dotfiles: 'bogus'})) - .get('/nums') - .expect(500, /dotfiles/, 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') - .expect(200, /secret/, 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) + 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) + 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') - .expect(403, 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + .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') - .expect(404, 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + .get('/name.txt') + .expect(404, done) }) }) }) - describe('hidden', function(){ - it('should default to false', function(done){ + describe('hidden', function () { + it('should default to false', function (done) { request(app) - .get('/.hidden') - .expect(404, 'Not Found', done) + .get('/.hidden.txt') + .expect(404, 'Not Found', done) }) - it('should default support sending hidden files', function(done){ - var app = http.createServer(function(req, res){ - send(req, req.url, {hidden: true, root: fixtures}) - .pipe(res); - }); - - request(app) - .get('/.hidden') - .expect(200, /secret/, done) + it('should default support sending hidden files', function (done) { + request(createServer({ hidden: true, root: fixtures })) + .get('/.hidden.txt') + .expect(200, 'secret', done) }) }) - describe('maxAge', function(){ - it('should default to 0', function(done){ - request(app) - .get('/name.txt') - .expect('Cache-Control', 'public, max-age=0', 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 floor to integer', function(done){ - var app = http.createServer(function(req, res){ - send(req, 'test/fixtures/name.txt', {maxAge: 123956}) - .pipe(res); - }); + 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) + }) + }) - request(app) - .get('/name.txt') - .expect('Cache-Control', 'public, max-age=123', 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 accept string', function(done){ - var app = http.createServer(function(req, res){ - send(req, 'test/fixtures/name.txt', {maxAge: '30d'}) - .pipe(res); - }); + it('should floor to integer', function (done) { + request(createServer({ maxAge: 123956, root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=123', done) + }) - request(app) - .get('/name.txt') - .expect('Cache-Control', 'public, max-age=2592000', 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){ - var 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) + 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 default to index.html', function(done){ - request(app) - .get('/pets/') - .expect(fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8'), 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 be configurable', function(done){ - var app = http.createServer(function(req, res){ - send(req, req.url, {root: fixtures, index: 'tobi.html'}) - .pipe(res); - }); - - request(app) - .get('/') - .expect(200, '

tobi

', done); + it('should reject true', function (done) { + request(createServer({ root: fixtures, index: true })) + .get('/pets/') + .expect(500, /TypeError: index option/, done) }) - it('should support disabling', function(done){ - var 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 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 support fallbacks', function(done){ - var 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) + it('should be configurable', function (done) { + request(createServer({ root: fixtures, index: 'tobi.html' })) + .get('/') + .expect(200, '

tobi

', done) }) - it('should 404 if no index file found (file)', function(done){ - var app = http.createServer(function(req, res){ - send(req, req.url, {root: fixtures, index: 'default.htm'}) - .pipe(res); - }); - - request(app) - .get('/pets/') - .expect(404, done) + it('should support disabling', function (done) { + request(createServer({ root: fixtures, index: false })) + .get('/pets/') + .expect(403, done) }) - it('should 404 if no index file found (dir)', function(done){ - var app = http.createServer(function(req, res){ - send(req, req.url, {root: fixtures, index: 'pets'}) - .pipe(res); - }); + 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) + }) - request(app) - .get('/') - .expect(404, 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 not follow directories', function(done){ - var app = http.createServer(function(req, res){ - send(req, req.url, {root: fixtures, index: ['pets', 'name.txt']}) - .pipe(res); - }); + it('should 404 if no index file found (dir)', function (done) { + request(createServer({ root: fixtures, index: 'pets' })) + .get('/') + .expect(404, done) + }) - request(app) - .get('/') - .expect(200, 'tobi', 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); - }); + 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) + .get('/') + .expect(200, /tobi/, done) }) }) - describe('root', function(){ - describe('when given', function(){ - it('should join root', function(done){ - var app = http.createServer(function(req, res){ - send(req, req.url, {root: __dirname + '/fixtures'}) - .pipe(res); - }); - - request(app) - .get('/pets/../name.txt') - .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){ - var app = http.createServer(function(req, res){ - send(req, req.url, {root: __dirname + '/fixtures/'}) - .pipe(res); - }); + 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) + .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: __dirname + '/fixtures'}) - .pipe(res); - }); + 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) + .get('/name.txt') + .expect(301, /Redirecting to/, done) }) - it('should restrict paths to within root', function(done){ - var app = http.createServer(function(req, res){ - send(req, req.url, {root: __dirname + '/fixtures'}) - .pipe(res); - }); + // + // 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('/pets/../../send.js') - .expect(403, done) + .get('/') + .expect(200, 'tobi', done) }) - it('should allow .. in root', function(done){ - var app = http.createServer(function(req, res){ - send(req, req.url, {root: __dirname + '/fixtures/../fixtures'}) - .pipe(res); - }); - - request(app) - .get('/pets/../../send.js') - .expect(403, done) + it('should restrict paths to within root', function (done) { + request(createServer({ root: fixtures })) + .get('/pets/../../send.js') + .expect(403, done) }) - it('should not allow root transversal', function(done){ - var app = http.createServer(function(req, res){ - send(req, req.url, {root: __dirname + '/fixtures/name.d'}) - .pipe(res); - }); + 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('/../name.dir/name.txt') - .expect(403, done) + .get('/pets/../../send.js') + .expect(403, done) }) - it('should not allow root path disclosure', function(done){ - var app = http.createServer(function(req, res){ - send(req, req.url, {root: __dirname + '/fixtures'}) - .pipe(res); - }); + it('should not allow root transversal', function (done) { + request(createServer({ root: path.join(fixtures, 'name.d') })) + .get('/../name.dir/name.txt') + .expect(403, done) + }) - request(app) - .get('/pets/../../fixtures/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){ + describe('when missing', function () { + it('should consider .. malicious', function (done) { + var app = http.createServer(function (req, res) { send(req, fixtures + req.url) - .pipe(res); - }); + .pipe(res) + }) request(app) - .get('/../send.js') - .expect(403, done) + .get('/../send.js') + .expect(403, done) }) - it('should still serve files with dots in name', function(done){ - var app = http.createServer(function(req, res){ + 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); - }); + .pipe(res) + }) request(app) - .get('/do..ts.txt') - .expect(200, '...', done); + .get('/do..ts.txt') + .expect(200, '...', done) }) }) }) }) -function createServer(opts) { - return http.createServer(function onRequest(req, res) { +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(err.message) + res.end(String(err)) } }) } -function shouldNotHaveHeader(header) { +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) }