├── .eslintignore ├── .eslintrc.yml ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── codeql.yml │ └── scorecard.yml ├── .gitignore ├── HISTORY.md ├── LICENSE ├── README.md ├── index.js ├── package.json ├── scripts └── version-history.js └── test ├── .eslintrc ├── fixtures ├── .hidden ├── empty.txt ├── foo bar ├── nums.txt ├── pets │ └── names.txt ├── snow ☃ │ └── .gitkeep ├── todo.html ├── todo.txt └── users │ ├── index.html │ └── tobi.txt └── test.js /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules 3 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | root: true 2 | extends: 3 | - standard 4 | - plugin:markdown/recommended 5 | plugins: 6 | - markdown 7 | overrides: 8 | - files: '**/*.md' 9 | processor: 'markdown/markdown' 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: monthly 7 | 8 | - package-ecosystem: npm 9 | directory: / 10 | schedule: 11 | interval: monthly 12 | time: "23:00" 13 | timezone: Europe/London 14 | open-pull-requests-limit: 10 15 | ignore: 16 | - dependency-name: "*" 17 | update-types: ["version-update:semver-major"] 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths-ignore: 8 | - '*.md' 9 | pull_request: 10 | paths-ignore: 11 | - '*.md' 12 | 13 | permissions: 14 | contents: read 15 | 16 | # Cancel in progress workflows 17 | # in the scenario where we already had a run going for that PR/branch/tag but then triggered a new run 18 | concurrency: 19 | group: "${{ github.workflow }} ✨ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}" 20 | cancel-in-progress: true 21 | 22 | jobs: 23 | lint: 24 | name: Lint 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 28 | 29 | - name: Setup Node.js 30 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 31 | with: 32 | node-version: "lts/*" 33 | 34 | - name: Install dependencies 35 | run: npm install --ignore-scripts --include=dev 36 | 37 | - name: Run lint 38 | run: npm run lint 39 | 40 | test: 41 | name: Test - Node.js ${{ matrix.node-version }} - ${{ matrix.os }} 42 | runs-on: ${{ matrix.os }} 43 | strategy: 44 | fail-fast: false 45 | matrix: 46 | os: [ubuntu-latest, windows-latest] 47 | # Node.js release schedule: https://nodejs.org/en/about/releases/ 48 | node-version: [18, 19, 20, 21, 22, 23, 24] 49 | steps: 50 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 51 | 52 | - name: Setup Node.js ${{ matrix.node-version }} 53 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 54 | with: 55 | check-latest: true 56 | node-version: ${{ matrix.node-version }} 57 | 58 | - name: Configure npm loglevel 59 | run: npm config set loglevel error 60 | 61 | - name: Install dependencies 62 | run: npm install 63 | 64 | - name: Run tests 65 | run: npm run test-ci 66 | 67 | - name: Upload code coverage 68 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 69 | with: 70 | name: coverage-node-${{ matrix.node-version }}-${{ matrix.os }} 71 | path: ./coverage/lcov.info 72 | retention-days: 1 73 | 74 | coverage: 75 | needs: test 76 | runs-on: ubuntu-latest 77 | permissions: 78 | contents: read 79 | checks: write 80 | steps: 81 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 82 | 83 | - name: Install lcov 84 | run: sudo apt-get -y install lcov 85 | 86 | - name: Collect coverage reports 87 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 88 | with: 89 | path: ./coverage 90 | pattern: coverage-node-* 91 | 92 | - name: Merge coverage reports 93 | run: find ./coverage -name lcov.info -exec printf '-a %q\n' {} \; | xargs lcov -o ./lcov.info 94 | 95 | - name: Upload coverage report 96 | uses: coverallsapp/github-action@648a8eb78e6d50909eff900e4ec85cab4524a45b # v2.3.6 97 | with: 98 | file: ./lcov.info 99 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: ["master"] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: ["master"] 20 | schedule: 21 | - cron: "0 0 * * 1" 22 | 23 | permissions: 24 | contents: read 25 | 26 | jobs: 27 | analyze: 28 | name: Analyze 29 | runs-on: ubuntu-latest 30 | permissions: 31 | actions: read 32 | contents: read 33 | security-events: write 34 | 35 | steps: 36 | - name: Checkout repository 37 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 38 | 39 | # Initializes the CodeQL tools for scanning. 40 | - name: Initialize CodeQL 41 | uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 42 | with: 43 | languages: javascript 44 | # If you wish to specify custom queries, you can do so here or in a config file. 45 | # By default, queries listed here will override any specified in a config file. 46 | # Prefix the list here with "+" to use these queries and those in the config file. 47 | 48 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 49 | # If this step fails, then you should remove it and run the build manually (see below) 50 | # - name: Autobuild 51 | # uses: github/codeql-action/autobuild@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3.28.13 52 | 53 | # ℹ️ Command-line programs to run using the OS shell. 54 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 55 | 56 | # If the Autobuild fails above, remove it and uncomment the following three lines. 57 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 58 | 59 | # - run: | 60 | # echo "Run, Build Application using script" 61 | # ./location_of_script_within_repo/buildscript.sh 62 | 63 | - name: Perform CodeQL Analysis 64 | uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 65 | with: 66 | category: "/language:javascript" 67 | -------------------------------------------------------------------------------- /.github/workflows/scorecard.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. They are provided 2 | # by a third-party and are governed by separate terms of service, privacy 3 | # policy, and support documentation. 4 | 5 | name: Scorecard supply-chain security 6 | 7 | on: 8 | # For Branch-Protection check. Only the default branch is supported. See 9 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 10 | branch_protection_rule: 11 | # To guarantee Maintained check is occasionally updated. See 12 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 13 | schedule: 14 | - cron: '16 21 * * 1' 15 | push: 16 | branches: [ "master" ] 17 | 18 | # Declare default permissions as read only. 19 | permissions: read-all 20 | 21 | jobs: 22 | analysis: 23 | name: Scorecard analysis 24 | runs-on: ubuntu-latest 25 | permissions: 26 | # Needed to upload the results to code-scanning dashboard. 27 | security-events: write 28 | # Needed to publish results and get a badge (see publish_results below). 29 | id-token: write 30 | 31 | steps: 32 | - name: "Checkout code" 33 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 34 | with: 35 | persist-credentials: false 36 | 37 | - name: "Run analysis" 38 | uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 39 | with: 40 | results_file: results.sarif 41 | results_format: sarif 42 | # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: 43 | # - you want to enable the Branch-Protection check on a *public* repository, or 44 | # - you are installing Scorecard on a *private* repository 45 | # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. 46 | # repo_token: ${{ secrets.SCORECARD_TOKEN }} 47 | 48 | # Public repositories: 49 | # - Publish results to OpenSSF REST API for easy access by consumers 50 | # - Allows the repository to include the Scorecard badge. 51 | # - See https://github.com/ossf/scorecard-action#publishing-results. 52 | # For private repositories: 53 | # - `publish_results` will always be set to `false`, regardless 54 | # of the value entered here. 55 | publish_results: true 56 | 57 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 58 | # format to the repository Actions tab. 59 | - name: "Upload artifact" 60 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 61 | with: 62 | name: SARIF file 63 | path: results.sarif 64 | retention-days: 5 65 | 66 | # Upload the results to GitHub's code scanning dashboard. 67 | - name: "Upload to code-scanning" 68 | uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 69 | with: 70 | sarif_file: results.sarif 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .nyc_output/ 2 | coverage/ 3 | node_modules/ 4 | npm-debug.log 5 | package-lock.json 6 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | 2.2.0 / 2025-03-27 2 | ================== 3 | 4 | * deps: send@^1.2.0 5 | 6 | 2.1.0 / 2024-09-10 7 | =================== 8 | 9 | * Changes from 1.16.0 10 | * deps: send@^1.2.0 11 | 12 | 2.0.0 / 2024-08-23 13 | ================== 14 | 15 | * deps: 16 | * parseurl@^1.3.3 17 | * excape-html@^1.0.3 18 | * encodeurl@^2.0.0 19 | * supertest@^6.3.4 20 | * safe-buffer@^5.2.1 21 | * nyc@^17.0.0 22 | * mocha@^10.7.0 23 | * Changes from 1.x 24 | 25 | 2.0.0-beta.2 / 2024-03-20 26 | ========================= 27 | 28 | * deps: send@1.0.0-beta.2 29 | 30 | 2.0.0-beta.1 / 2022-02-05 31 | ========================= 32 | 33 | * Change `dotfiles` option default to `'ignore'` 34 | * Drop support for Node.js 0.8 35 | * Remove `hidden` option; use `dotfiles` option instead 36 | * Remove `mime` export; use `mime-types` package instead 37 | * deps: send@1.0.0-beta.1 38 | - Use `mime-types` for file to content type mapping 39 | - deps: debug@3.1.0 40 | 41 | 1.16.0 / 2024-09-10 42 | =================== 43 | 44 | * Remove link renderization in html while redirecting 45 | 46 | 1.15.0 / 2022-03-24 47 | =================== 48 | 49 | * deps: send@0.18.0 50 | - Fix emitted 416 error missing headers property 51 | - Limit the headers removed for 304 response 52 | - deps: depd@2.0.0 53 | - deps: destroy@1.2.0 54 | - deps: http-errors@2.0.0 55 | - deps: on-finished@2.4.1 56 | - deps: statuses@2.0.1 57 | 58 | 1.14.2 / 2021-12-15 59 | =================== 60 | 61 | * deps: send@0.17.2 62 | - deps: http-errors@1.8.1 63 | - deps: ms@2.1.3 64 | - pref: ignore empty http tokens 65 | 66 | 1.14.1 / 2019-05-10 67 | =================== 68 | 69 | * Set stricter CSP header in redirect response 70 | * deps: send@0.17.1 71 | - deps: range-parser@~1.2.1 72 | 73 | 1.14.0 / 2019-05-07 74 | =================== 75 | 76 | * deps: parseurl@~1.3.3 77 | * deps: send@0.17.0 78 | - deps: http-errors@~1.7.2 79 | - deps: mime@1.6.0 80 | - deps: ms@2.1.1 81 | - deps: statuses@~1.5.0 82 | - perf: remove redundant `path.normalize` call 83 | 84 | 1.13.2 / 2018-02-07 85 | =================== 86 | 87 | * Fix incorrect end tag in redirects 88 | * deps: encodeurl@~1.0.2 89 | - Fix encoding `%` as last character 90 | * deps: send@0.16.2 91 | - deps: depd@~1.1.2 92 | - deps: encodeurl@~1.0.2 93 | - deps: statuses@~1.4.0 94 | 95 | 1.13.1 / 2017-09-29 96 | =================== 97 | 98 | * Fix regression when `root` is incorrectly set to a file 99 | * deps: send@0.16.1 100 | 101 | 1.13.0 / 2017-09-27 102 | =================== 103 | 104 | * deps: send@0.16.0 105 | - Add 70 new types for file extensions 106 | - Add `immutable` option 107 | - Fix missing `` in default error & redirects 108 | - Set charset as "UTF-8" for .js and .json 109 | - Use instance methods on steam to check for listeners 110 | - deps: mime@1.4.1 111 | - perf: improve path validation speed 112 | 113 | 1.12.6 / 2017-09-22 114 | =================== 115 | 116 | * deps: send@0.15.6 117 | - deps: debug@2.6.9 118 | - perf: improve `If-Match` token parsing 119 | * perf: improve slash collapsing 120 | 121 | 1.12.5 / 2017-09-21 122 | =================== 123 | 124 | * deps: parseurl@~1.3.2 125 | - perf: reduce overhead for full URLs 126 | - perf: unroll the "fast-path" `RegExp` 127 | * deps: send@0.15.5 128 | - Fix handling of modified headers with invalid dates 129 | - deps: etag@~1.8.1 130 | - deps: fresh@0.5.2 131 | 132 | 1.12.4 / 2017-08-05 133 | =================== 134 | 135 | * deps: send@0.15.4 136 | - deps: debug@2.6.8 137 | - deps: depd@~1.1.1 138 | - deps: http-errors@~1.6.2 139 | 140 | 1.12.3 / 2017-05-16 141 | =================== 142 | 143 | * deps: send@0.15.3 144 | - deps: debug@2.6.7 145 | 146 | 1.12.2 / 2017-04-26 147 | =================== 148 | 149 | * deps: send@0.15.2 150 | - deps: debug@2.6.4 151 | 152 | 1.12.1 / 2017-03-04 153 | =================== 154 | 155 | * deps: send@0.15.1 156 | - Fix issue when `Date.parse` does not return `NaN` on invalid date 157 | - Fix strict violation in broken environments 158 | 159 | 1.12.0 / 2017-02-25 160 | =================== 161 | 162 | * Send complete HTML document in redirect response 163 | * Set default CSP header in redirect response 164 | * deps: send@0.15.0 165 | - Fix false detection of `no-cache` request directive 166 | - Fix incorrect result when `If-None-Match` has both `*` and ETags 167 | - Fix weak `ETag` matching to match spec 168 | - Remove usage of `res._headers` private field 169 | - Support `If-Match` and `If-Unmodified-Since` headers 170 | - Use `res.getHeaderNames()` when available 171 | - Use `res.headersSent` when available 172 | - deps: debug@2.6.1 173 | - deps: etag@~1.8.0 174 | - deps: fresh@0.5.0 175 | - deps: http-errors@~1.6.1 176 | 177 | 1.11.2 / 2017-01-23 178 | =================== 179 | 180 | * deps: send@0.14.2 181 | - deps: http-errors@~1.5.1 182 | - deps: ms@0.7.2 183 | - deps: statuses@~1.3.1 184 | 185 | 1.11.1 / 2016-06-10 186 | =================== 187 | 188 | * Fix redirect error when `req.url` contains raw non-URL characters 189 | * deps: send@0.14.1 190 | 191 | 1.11.0 / 2016-06-07 192 | =================== 193 | 194 | * Use status code 301 for redirects 195 | * deps: send@0.14.0 196 | - Add `acceptRanges` option 197 | - Add `cacheControl` option 198 | - Attempt to combine multiple ranges into single range 199 | - Correctly inherit from `Stream` class 200 | - Fix `Content-Range` header in 416 responses when using `start`/`end` options 201 | - Fix `Content-Range` header missing from default 416 responses 202 | - Ignore non-byte `Range` headers 203 | - deps: http-errors@~1.5.0 204 | - deps: range-parser@~1.2.0 205 | - deps: statuses@~1.3.0 206 | - perf: remove argument reassignment 207 | 208 | 1.10.3 / 2016-05-30 209 | =================== 210 | 211 | * deps: send@0.13.2 212 | - Fix invalid `Content-Type` header when `send.mime.default_type` unset 213 | 214 | 1.10.2 / 2016-01-19 215 | =================== 216 | 217 | * deps: parseurl@~1.3.1 218 | - perf: enable strict mode 219 | 220 | 1.10.1 / 2016-01-16 221 | =================== 222 | 223 | * deps: escape-html@~1.0.3 224 | - perf: enable strict mode 225 | - perf: optimize string replacement 226 | - perf: use faster string coercion 227 | * deps: send@0.13.1 228 | - deps: depd@~1.1.0 229 | - deps: destroy@~1.0.4 230 | - deps: escape-html@~1.0.3 231 | - deps: range-parser@~1.0.3 232 | 233 | 1.10.0 / 2015-06-17 234 | =================== 235 | 236 | * Add `fallthrough` option 237 | - Allows declaring this middleware is the final destination 238 | - Provides better integration with Express patterns 239 | * Fix reading options from options prototype 240 | * Improve the default redirect response headers 241 | * deps: escape-html@1.0.2 242 | * deps: send@0.13.0 243 | - Allow Node.js HTTP server to set `Date` response header 244 | - Fix incorrectly removing `Content-Location` on 304 response 245 | - Improve the default redirect response headers 246 | - Send appropriate headers on default error response 247 | - Use `http-errors` for standard emitted errors 248 | - Use `statuses` instead of `http` module for status messages 249 | - deps: escape-html@1.0.2 250 | - deps: etag@~1.7.0 251 | - deps: fresh@0.3.0 252 | - deps: on-finished@~2.3.0 253 | - perf: enable strict mode 254 | - perf: remove unnecessary array allocations 255 | * perf: enable strict mode 256 | * perf: remove argument reassignment 257 | 258 | 1.9.3 / 2015-05-14 259 | ================== 260 | 261 | * deps: send@0.12.3 262 | - deps: debug@~2.2.0 263 | - deps: depd@~1.0.1 264 | - deps: etag@~1.6.0 265 | - deps: ms@0.7.1 266 | - deps: on-finished@~2.2.1 267 | 268 | 1.9.2 / 2015-03-14 269 | ================== 270 | 271 | * deps: send@0.12.2 272 | - Throw errors early for invalid `extensions` or `index` options 273 | - deps: debug@~2.1.3 274 | 275 | 1.9.1 / 2015-02-17 276 | ================== 277 | 278 | * deps: send@0.12.1 279 | - Fix regression sending zero-length files 280 | 281 | 1.9.0 / 2015-02-16 282 | ================== 283 | 284 | * deps: send@0.12.0 285 | - Always read the stat size from the file 286 | - Fix mutating passed-in `options` 287 | - deps: mime@1.3.4 288 | 289 | 1.8.1 / 2015-01-20 290 | ================== 291 | 292 | * Fix redirect loop in Node.js 0.11.14 293 | * deps: send@0.11.1 294 | - Fix root path disclosure 295 | 296 | 1.8.0 / 2015-01-05 297 | ================== 298 | 299 | * deps: send@0.11.0 300 | - deps: debug@~2.1.1 301 | - deps: etag@~1.5.1 302 | - deps: ms@0.7.0 303 | - deps: on-finished@~2.2.0 304 | 305 | 1.7.2 / 2015-01-02 306 | ================== 307 | 308 | * Fix potential open redirect when mounted at root 309 | 310 | 1.7.1 / 2014-10-22 311 | ================== 312 | 313 | * deps: send@0.10.1 314 | - deps: on-finished@~2.1.1 315 | 316 | 1.7.0 / 2014-10-15 317 | ================== 318 | 319 | * deps: send@0.10.0 320 | - deps: debug@~2.1.0 321 | - deps: depd@~1.0.0 322 | - deps: etag@~1.5.0 323 | 324 | 1.6.5 / 2015-02-04 325 | ================== 326 | 327 | * Fix potential open redirect when mounted at root 328 | - Back-ported from v1.7.2 329 | 330 | 1.6.4 / 2014-10-08 331 | ================== 332 | 333 | * Fix redirect loop when index file serving disabled 334 | 335 | 1.6.3 / 2014-09-24 336 | ================== 337 | 338 | * deps: send@0.9.3 339 | - deps: etag@~1.4.0 340 | 341 | 1.6.2 / 2014-09-15 342 | ================== 343 | 344 | * deps: send@0.9.2 345 | - deps: depd@0.4.5 346 | - deps: etag@~1.3.1 347 | - deps: range-parser@~1.0.2 348 | 349 | 1.6.1 / 2014-09-07 350 | ================== 351 | 352 | * deps: send@0.9.1 353 | - deps: fresh@0.2.4 354 | 355 | 1.6.0 / 2014-09-07 356 | ================== 357 | 358 | * deps: send@0.9.0 359 | - Add `lastModified` option 360 | - Use `etag` to generate `ETag` header 361 | - deps: debug@~2.0.0 362 | 363 | 1.5.4 / 2014-09-04 364 | ================== 365 | 366 | * deps: send@0.8.5 367 | - Fix a path traversal issue when using `root` 368 | - Fix malicious path detection for empty string path 369 | 370 | 1.5.3 / 2014-08-17 371 | ================== 372 | 373 | * deps: send@0.8.3 374 | 375 | 1.5.2 / 2014-08-14 376 | ================== 377 | 378 | * deps: send@0.8.2 379 | - Work around `fd` leak in Node.js 0.10 for `fs.ReadStream` 380 | 381 | 1.5.1 / 2014-08-09 382 | ================== 383 | 384 | * Fix parsing of weird `req.originalUrl` values 385 | * deps: parseurl@~1.3.0 386 | * deps: utils-merge@1.0.0 387 | 388 | 1.5.0 / 2014-08-05 389 | ================== 390 | 391 | * deps: send@0.8.1 392 | - Add `extensions` option 393 | 394 | 1.4.4 / 2014-08-04 395 | ================== 396 | 397 | * deps: send@0.7.4 398 | - Fix serving index files without root dir 399 | 400 | 1.4.3 / 2014-07-29 401 | ================== 402 | 403 | * deps: send@0.7.3 404 | - Fix incorrect 403 on Windows and Node.js 0.11 405 | 406 | 1.4.2 / 2014-07-27 407 | ================== 408 | 409 | * deps: send@0.7.2 410 | - deps: depd@0.4.4 411 | 412 | 1.4.1 / 2014-07-26 413 | ================== 414 | 415 | * deps: send@0.7.1 416 | - deps: depd@0.4.3 417 | 418 | 1.4.0 / 2014-07-21 419 | ================== 420 | 421 | * deps: parseurl@~1.2.0 422 | - Cache URLs based on original value 423 | - Remove no-longer-needed URL mis-parse work-around 424 | - Simplify the "fast-path" `RegExp` 425 | * deps: send@0.7.0 426 | - Add `dotfiles` option 427 | - deps: debug@1.0.4 428 | - deps: depd@0.4.2 429 | 430 | 1.3.2 / 2014-07-11 431 | ================== 432 | 433 | * deps: send@0.6.0 434 | - Cap `maxAge` value to 1 year 435 | - deps: debug@1.0.3 436 | 437 | 1.3.1 / 2014-07-09 438 | ================== 439 | 440 | * deps: parseurl@~1.1.3 441 | - faster parsing of href-only URLs 442 | 443 | 1.3.0 / 2014-06-28 444 | ================== 445 | 446 | * Add `setHeaders` option 447 | * Include HTML link in redirect response 448 | * deps: send@0.5.0 449 | - Accept string for `maxAge` (converted by `ms`) 450 | 451 | 1.2.3 / 2014-06-11 452 | ================== 453 | 454 | * deps: send@0.4.3 455 | - Do not throw un-catchable error on file open race condition 456 | - Use `escape-html` for HTML escaping 457 | - deps: debug@1.0.2 458 | - deps: finished@1.2.2 459 | - deps: fresh@0.2.2 460 | 461 | 1.2.2 / 2014-06-09 462 | ================== 463 | 464 | * deps: send@0.4.2 465 | - fix "event emitter leak" warnings 466 | - deps: debug@1.0.1 467 | - deps: finished@1.2.1 468 | 469 | 1.2.1 / 2014-06-02 470 | ================== 471 | 472 | * use `escape-html` for escaping 473 | * deps: send@0.4.1 474 | - Send `max-age` in `Cache-Control` in correct format 475 | 476 | 1.2.0 / 2014-05-29 477 | ================== 478 | 479 | * deps: send@0.4.0 480 | - Calculate ETag with md5 for reduced collisions 481 | - Fix wrong behavior when index file matches directory 482 | - Ignore stream errors after request ends 483 | - Skip directories in index file search 484 | - deps: debug@0.8.1 485 | 486 | 1.1.0 / 2014-04-24 487 | ================== 488 | 489 | * Accept options directly to `send` module 490 | * deps: send@0.3.0 491 | 492 | 1.0.4 / 2014-04-07 493 | ================== 494 | 495 | * Resolve relative paths at middleware setup 496 | * Use parseurl to parse the URL from request 497 | 498 | 1.0.3 / 2014-03-20 499 | ================== 500 | 501 | * Do not rely on connect-like environments 502 | 503 | 1.0.2 / 2014-03-06 504 | ================== 505 | 506 | * deps: send@0.2.0 507 | 508 | 1.0.1 / 2014-03-05 509 | ================== 510 | 511 | * Add mime export for back-compat 512 | 513 | 1.0.0 / 2014-03-05 514 | ================== 515 | 516 | * Genesis from `connect` 517 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2010 Sencha Inc. 4 | Copyright (c) 2011 LearnBoost 5 | Copyright (c) 2011 TJ Holowaychuk 6 | Copyright (c) 2014-2016 Douglas Christopher Wilson 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining 9 | a copy of this software and associated documentation files (the 10 | 'Software'), to deal in the Software without restriction, including 11 | without limitation the rights to use, copy, modify, merge, publish, 12 | distribute, sublicense, and/or sell copies of the Software, and to 13 | permit persons to whom the Software is furnished to do so, subject to 14 | the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be 17 | included in all copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 20 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 21 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 22 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 23 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 24 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 25 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # serve-static 2 | 3 | [![NPM Version][npm-version-image]][npm-url] 4 | [![NPM Downloads][npm-downloads-image]][npm-url] 5 | [![CI][github-actions-ci-image]][github-actions-ci-url] 6 | [![Test Coverage][coveralls-image]][coveralls-url] 7 | 8 | ## Install 9 | 10 | This is a [Node.js](https://nodejs.org/en/) module available through the 11 | [npm registry](https://www.npmjs.com/). Installation is done using the 12 | [`npm install` command](https://docs.npmjs.com/getting-started/installing-npm-packages-locally): 13 | 14 | ```sh 15 | $ npm install serve-static 16 | ``` 17 | 18 | ## API 19 | 20 | ```js 21 | var serveStatic = require('serve-static') 22 | ``` 23 | 24 | ### serveStatic(root, options) 25 | 26 | Create a new middleware function to serve files from within a given root 27 | directory. The file to serve will be determined by combining `req.url` 28 | with the provided root directory. When a file is not found, instead of 29 | sending a 404 response, this module will instead call `next()` to move on 30 | to the next middleware, allowing for stacking and fall-backs. 31 | 32 | #### Options 33 | 34 | ##### acceptRanges 35 | 36 | Enable or disable accepting ranged requests, defaults to true. 37 | Disabling this will not send `Accept-Ranges` and ignore the contents 38 | of the `Range` request header. 39 | 40 | ##### cacheControl 41 | 42 | Enable or disable setting `Cache-Control` response header, defaults to 43 | true. Disabling this will ignore the `immutable` and `maxAge` options. 44 | 45 | ##### dotfiles 46 | 47 | Set how "dotfiles" are treated when encountered. A dotfile is a file 48 | or directory that begins with a dot ("."). Note this check is done on 49 | the path itself without checking if the path actually exists on the 50 | disk. If `root` is specified, only the dotfiles above the root are 51 | checked (i.e. the root itself can be within a dotfile when set 52 | to "deny"). 53 | 54 | - `'allow'` No special treatment for dotfiles. 55 | - `'deny'` Deny a request for a dotfile and 403/`next()`. 56 | - `'ignore'` Pretend like the dotfile does not exist and 404/`next()`. 57 | 58 | The default value is `'ignore'`. 59 | 60 | ##### etag 61 | 62 | Enable or disable etag generation, defaults to true. 63 | 64 | ##### extensions 65 | 66 | Set file extension fallbacks. When set, if a file is not found, the given 67 | extensions will be added to the file name and search for. The first that 68 | exists will be served. Example: `['html', 'htm']`. 69 | 70 | The default value is `false`. 71 | 72 | ##### fallthrough 73 | 74 | Set the middleware to have client errors fall-through as just unhandled 75 | requests, otherwise forward a client error. The difference is that client 76 | errors like a bad request or a request to a non-existent file will cause 77 | this middleware to simply `next()` to your next middleware when this value 78 | is `true`. When this value is `false`, these errors (even 404s), will invoke 79 | `next(err)`. 80 | 81 | Typically `true` is desired such that multiple physical directories can be 82 | mapped to the same web address or for routes to fill in non-existent files. 83 | 84 | The value `false` can be used if this middleware is mounted at a path that 85 | is designed to be strictly a single file system directory, which allows for 86 | short-circuiting 404s for less overhead. This middleware will also reply to 87 | all methods. 88 | 89 | The default value is `true`. 90 | 91 | ##### immutable 92 | 93 | Enable or disable the `immutable` directive in the `Cache-Control` response 94 | header, defaults to `false`. If set to `true`, the `maxAge` option should 95 | also be specified to enable caching. The `immutable` directive will prevent 96 | supported clients from making conditional requests during the life of the 97 | `maxAge` option to check if the file has changed. 98 | 99 | ##### index 100 | 101 | By default this module will send "index.html" files in response to a request 102 | on a directory. To disable this set `false` or to supply a new index pass a 103 | string or an array in preferred order. 104 | 105 | ##### lastModified 106 | 107 | Enable or disable `Last-Modified` header, defaults to true. Uses the file 108 | system's last modified value. 109 | 110 | ##### maxAge 111 | 112 | Provide a max-age in milliseconds for http caching, defaults to 0. This 113 | can also be a string accepted by the [ms](https://www.npmjs.org/package/ms#readme) 114 | module. 115 | 116 | ##### redirect 117 | 118 | Redirect to trailing "/" when the pathname is a dir. Defaults to `true`. 119 | 120 | ##### setHeaders 121 | 122 | Function to set custom headers on response. Alterations to the headers need to 123 | occur synchronously. The function is called as `fn(res, path, stat)`, where 124 | the arguments are: 125 | 126 | - `res` the response object 127 | - `path` the file path that is being sent 128 | - `stat` the stat object of the file that is being sent 129 | 130 | ## Examples 131 | 132 | ### Serve files with vanilla node.js http server 133 | 134 | ```js 135 | var finalhandler = require('finalhandler') 136 | var http = require('http') 137 | var serveStatic = require('serve-static') 138 | 139 | // Serve up public/ftp folder 140 | var serve = serveStatic('public/ftp', { index: ['index.html', 'index.htm'] }) 141 | 142 | // Create server 143 | var server = http.createServer(function onRequest (req, res) { 144 | serve(req, res, finalhandler(req, res)) 145 | }) 146 | 147 | // Listen 148 | server.listen(3000) 149 | ``` 150 | 151 | ### Serve all files as downloads 152 | 153 | ```js 154 | var contentDisposition = require('content-disposition') 155 | var finalhandler = require('finalhandler') 156 | var http = require('http') 157 | var serveStatic = require('serve-static') 158 | 159 | // Serve up public/ftp folder 160 | var serve = serveStatic('public/ftp', { 161 | index: false, 162 | setHeaders: setHeaders 163 | }) 164 | 165 | // Set header to force download 166 | function setHeaders (res, path) { 167 | res.setHeader('Content-Disposition', contentDisposition(path)) 168 | } 169 | 170 | // Create server 171 | var server = http.createServer(function onRequest (req, res) { 172 | serve(req, res, finalhandler(req, res)) 173 | }) 174 | 175 | // Listen 176 | server.listen(3000) 177 | ``` 178 | 179 | ### Serving using express 180 | 181 | #### Simple 182 | 183 | This is a simple example of using Express. 184 | 185 | ```js 186 | var express = require('express') 187 | var serveStatic = require('serve-static') 188 | 189 | var app = express() 190 | 191 | app.use(serveStatic('public/ftp', { index: ['default.html', 'default.htm'] })) 192 | app.listen(3000) 193 | ``` 194 | 195 | #### Multiple roots 196 | 197 | This example shows a simple way to search through multiple directories. 198 | Files are searched for in `public-optimized/` first, then `public/` second 199 | as a fallback. 200 | 201 | ```js 202 | var express = require('express') 203 | var path = require('path') 204 | var serveStatic = require('serve-static') 205 | 206 | var app = express() 207 | 208 | app.use(serveStatic(path.join(__dirname, 'public-optimized'))) 209 | app.use(serveStatic(path.join(__dirname, 'public'))) 210 | app.listen(3000) 211 | ``` 212 | 213 | #### Different settings for paths 214 | 215 | This example shows how to set a different max age depending on the served 216 | file. In this example, HTML files are not cached, while everything else 217 | is for 1 day. 218 | 219 | ```js 220 | var express = require('express') 221 | var path = require('path') 222 | var serveStatic = require('serve-static') 223 | 224 | var app = express() 225 | 226 | app.use(serveStatic(path.join(__dirname, 'public'), { 227 | maxAge: '1d', 228 | setHeaders: setCustomCacheControl 229 | })) 230 | 231 | app.listen(3000) 232 | 233 | function setCustomCacheControl (res, file) { 234 | if (path.extname(file) === '.html') { 235 | // Custom Cache-Control for HTML files 236 | res.setHeader('Cache-Control', 'public, max-age=0') 237 | } 238 | } 239 | ``` 240 | 241 | ## License 242 | 243 | [MIT](LICENSE) 244 | 245 | [coveralls-image]: https://badgen.net/coveralls/c/github/expressjs/serve-static/master 246 | [coveralls-url]: https://coveralls.io/r/expressjs/serve-static?branch=master 247 | [github-actions-ci-image]: https://badgen.net/github/checks/expressjs/serve-static/master?label=linux 248 | [github-actions-ci-url]: https://github.com/expressjs/serve-static/actions/workflows/ci.yml 249 | [node-image]: https://badgen.net/npm/node/serve-static 250 | [node-url]: https://nodejs.org/en/download/ 251 | [npm-downloads-image]: https://badgen.net/npm/dm/serve-static 252 | [npm-url]: https://npmjs.org/package/serve-static 253 | [npm-version-image]: https://badgen.net/npm/v/serve-static 254 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * serve-static 3 | * Copyright(c) 2010 Sencha Inc. 4 | * Copyright(c) 2011 TJ Holowaychuk 5 | * Copyright(c) 2014-2016 Douglas Christopher Wilson 6 | * MIT Licensed 7 | */ 8 | 9 | 'use strict' 10 | 11 | /** 12 | * Module dependencies. 13 | * @private 14 | */ 15 | 16 | var encodeUrl = require('encodeurl') 17 | var escapeHtml = require('escape-html') 18 | var parseUrl = require('parseurl') 19 | var resolve = require('path').resolve 20 | var send = require('send') 21 | var url = require('url') 22 | 23 | /** 24 | * Module exports. 25 | * @public 26 | */ 27 | 28 | module.exports = serveStatic 29 | 30 | /** 31 | * @param {string} root 32 | * @param {object} [options] 33 | * @return {function} 34 | * @public 35 | */ 36 | 37 | function serveStatic (root, options) { 38 | if (!root) { 39 | throw new TypeError('root path required') 40 | } 41 | 42 | if (typeof root !== 'string') { 43 | throw new TypeError('root path must be a string') 44 | } 45 | 46 | // copy options object 47 | var opts = Object.create(options || null) 48 | 49 | // fall-though 50 | var fallthrough = opts.fallthrough !== false 51 | 52 | // default redirect 53 | var redirect = opts.redirect !== false 54 | 55 | // headers listener 56 | var setHeaders = opts.setHeaders 57 | 58 | if (setHeaders && typeof setHeaders !== 'function') { 59 | throw new TypeError('option setHeaders must be function') 60 | } 61 | 62 | // setup options for send 63 | opts.maxage = opts.maxage || opts.maxAge || 0 64 | opts.root = resolve(root) 65 | 66 | // construct directory listener 67 | var onDirectory = redirect 68 | ? createRedirectDirectoryListener() 69 | : createNotFoundDirectoryListener() 70 | 71 | return function serveStatic (req, res, next) { 72 | if (req.method !== 'GET' && req.method !== 'HEAD') { 73 | if (fallthrough) { 74 | return next() 75 | } 76 | 77 | // method not allowed 78 | res.statusCode = 405 79 | res.setHeader('Allow', 'GET, HEAD') 80 | res.setHeader('Content-Length', '0') 81 | res.end() 82 | return 83 | } 84 | 85 | var forwardError = !fallthrough 86 | var originalUrl = parseUrl.original(req) 87 | var path = parseUrl(req).pathname 88 | 89 | // make sure redirect occurs at mount 90 | if (path === '/' && originalUrl.pathname.substr(-1) !== '/') { 91 | path = '' 92 | } 93 | 94 | // create send stream 95 | var stream = send(req, path, opts) 96 | 97 | // add directory handler 98 | stream.on('directory', onDirectory) 99 | 100 | // add headers listener 101 | if (setHeaders) { 102 | stream.on('headers', setHeaders) 103 | } 104 | 105 | // add file listener for fallthrough 106 | if (fallthrough) { 107 | stream.on('file', function onFile () { 108 | // once file is determined, always forward error 109 | forwardError = true 110 | }) 111 | } 112 | 113 | // forward errors 114 | stream.on('error', function error (err) { 115 | if (forwardError || !(err.statusCode < 500)) { 116 | next(err) 117 | return 118 | } 119 | 120 | next() 121 | }) 122 | 123 | // pipe 124 | stream.pipe(res) 125 | } 126 | } 127 | 128 | /** 129 | * Collapse all leading slashes into a single slash 130 | * @private 131 | */ 132 | function collapseLeadingSlashes (str) { 133 | for (var i = 0; i < str.length; i++) { 134 | if (str.charCodeAt(i) !== 0x2f /* / */) { 135 | break 136 | } 137 | } 138 | 139 | return i > 1 140 | ? '/' + str.substr(i) 141 | : str 142 | } 143 | 144 | /** 145 | * Create a minimal HTML document. 146 | * 147 | * @param {string} title 148 | * @param {string} body 149 | * @private 150 | */ 151 | 152 | function createHtmlDocument (title, body) { 153 | return '\n' + 154 | '\n' + 155 | '\n' + 156 | '\n' + 157 | '' + title + '\n' + 158 | '\n' + 159 | '\n' + 160 | '
' + body + '
\n' + 161 | '\n' + 162 | '\n' 163 | } 164 | 165 | /** 166 | * Create a directory listener that just 404s. 167 | * @private 168 | */ 169 | 170 | function createNotFoundDirectoryListener () { 171 | return function notFound () { 172 | this.error(404) 173 | } 174 | } 175 | 176 | /** 177 | * Create a directory listener that performs a redirect. 178 | * @private 179 | */ 180 | 181 | function createRedirectDirectoryListener () { 182 | return function redirect (res) { 183 | if (this.hasTrailingSlash()) { 184 | this.error(404) 185 | return 186 | } 187 | 188 | // get original URL 189 | var originalUrl = parseUrl.original(this.req) 190 | 191 | // append trailing slash 192 | originalUrl.path = null 193 | originalUrl.pathname = collapseLeadingSlashes(originalUrl.pathname + '/') 194 | 195 | // reformat the URL 196 | var loc = encodeUrl(url.format(originalUrl)) 197 | var doc = createHtmlDocument('Redirecting', 'Redirecting to ' + escapeHtml(loc)) 198 | 199 | // send redirect response 200 | res.statusCode = 301 201 | res.setHeader('Content-Type', 'text/html; charset=UTF-8') 202 | res.setHeader('Content-Length', Buffer.byteLength(doc)) 203 | res.setHeader('Content-Security-Policy', "default-src 'none'") 204 | res.setHeader('X-Content-Type-Options', 'nosniff') 205 | res.setHeader('Location', loc) 206 | res.end(doc) 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serve-static", 3 | "description": "Serve static files", 4 | "version": "2.2.0", 5 | "author": "Douglas Christopher Wilson ", 6 | "license": "MIT", 7 | "repository": "expressjs/serve-static", 8 | "dependencies": { 9 | "encodeurl": "^2.0.0", 10 | "escape-html": "^1.0.3", 11 | "parseurl": "^1.3.3", 12 | "send": "^1.2.0" 13 | }, 14 | "devDependencies": { 15 | "eslint": "7.32.0", 16 | "eslint-config-standard": "14.1.1", 17 | "eslint-plugin-import": "2.25.4", 18 | "eslint-plugin-markdown": "2.2.1", 19 | "eslint-plugin-node": "11.1.0", 20 | "eslint-plugin-promise": "5.2.0", 21 | "eslint-plugin-standard": "4.1.0", 22 | "mocha": "^10.7.0", 23 | "nyc": "^17.0.0", 24 | "supertest": "^6.3.4" 25 | }, 26 | "files": [ 27 | "LICENSE", 28 | "HISTORY.md", 29 | "index.js" 30 | ], 31 | "engines": { 32 | "node": ">= 18" 33 | }, 34 | "scripts": { 35 | "lint": "eslint .", 36 | "test": "mocha --reporter spec --bail --check-leaks test/", 37 | "test-ci": "nyc --reporter=lcov --reporter=text npm test", 38 | "test-cov": "nyc --reporter=html --reporter=text npm test", 39 | "version": "node scripts/version-history.js && git add HISTORY.md" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /scripts/version-history.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var fs = require('fs') 4 | var path = require('path') 5 | 6 | var HISTORY_FILE_PATH = path.join(__dirname, '..', 'HISTORY.md') 7 | var MD_HEADER_REGEXP = /^====*$/ 8 | var VERSION = process.env.npm_package_version 9 | var VERSION_PLACEHOLDER_REGEXP = /^(?:unreleased|(\d+\.)+x)$/ 10 | 11 | var historyFileLines = fs.readFileSync(HISTORY_FILE_PATH, 'utf-8').split('\n') 12 | 13 | if (!MD_HEADER_REGEXP.test(historyFileLines[1])) { 14 | console.error('Missing header in HISTORY.md') 15 | process.exit(1) 16 | } 17 | 18 | if (!VERSION_PLACEHOLDER_REGEXP.test(historyFileLines[0])) { 19 | console.error('Missing placegolder version in HISTORY.md') 20 | process.exit(1) 21 | } 22 | 23 | if (historyFileLines[0].indexOf('x') !== -1) { 24 | var versionCheckRegExp = new RegExp('^' + historyFileLines[0].replace('x', '.+') + '$') 25 | 26 | if (!versionCheckRegExp.test(VERSION)) { 27 | console.error('Version %s does not match placeholder %s', VERSION, historyFileLines[0]) 28 | process.exit(1) 29 | } 30 | } 31 | 32 | historyFileLines[0] = VERSION + ' / ' + getLocaleDate() 33 | historyFileLines[1] = '='.repeat(historyFileLines[0].length) 34 | 35 | fs.writeFileSync(HISTORY_FILE_PATH, historyFileLines.join('\n')) 36 | 37 | function getLocaleDate () { 38 | var now = new Date() 39 | 40 | return zeroPad(now.getFullYear(), 4) + '-' + 41 | zeroPad(now.getMonth() + 1, 2) + '-' + 42 | zeroPad(now.getDate(), 2) 43 | } 44 | 45 | function zeroPad (number, length) { 46 | return number.toString().padStart(length, '0') 47 | } 48 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/.hidden: -------------------------------------------------------------------------------- 1 | I am hidden -------------------------------------------------------------------------------- /test/fixtures/empty.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/expressjs/serve-static/da88c0dc76984a0d313bf64b6ea527f35381c24e/test/fixtures/empty.txt -------------------------------------------------------------------------------- /test/fixtures/foo bar: -------------------------------------------------------------------------------- 1 | baz -------------------------------------------------------------------------------- /test/fixtures/nums.txt: -------------------------------------------------------------------------------- 1 | 123456789 -------------------------------------------------------------------------------- /test/fixtures/pets/names.txt: -------------------------------------------------------------------------------- 1 | tobi,loki -------------------------------------------------------------------------------- /test/fixtures/snow ☃/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/expressjs/serve-static/da88c0dc76984a0d313bf64b6ea527f35381c24e/test/fixtures/snow ☃/.gitkeep -------------------------------------------------------------------------------- /test/fixtures/todo.html: -------------------------------------------------------------------------------- 1 |
  • groceries
  • -------------------------------------------------------------------------------- /test/fixtures/todo.txt: -------------------------------------------------------------------------------- 1 | - groceries -------------------------------------------------------------------------------- /test/fixtures/users/index.html: -------------------------------------------------------------------------------- 1 |

    tobi, loki, jane

    -------------------------------------------------------------------------------- /test/fixtures/users/tobi.txt: -------------------------------------------------------------------------------- 1 | ferret -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | 2 | var assert = require('assert') 3 | var http = require('http') 4 | var path = require('path') 5 | var request = require('supertest') 6 | var serveStatic = require('..') 7 | 8 | var fixtures = path.join(__dirname, '/fixtures') 9 | var relative = path.relative(process.cwd(), fixtures) 10 | 11 | var skipRelative = ~relative.indexOf('..') || path.resolve(relative) === relative 12 | 13 | describe('serveStatic()', function () { 14 | describe('basic operations', function () { 15 | var server 16 | before(function () { 17 | server = createServer() 18 | }) 19 | 20 | it('should require root path', function () { 21 | assert.throws(serveStatic.bind(), /root path required/) 22 | }) 23 | 24 | it('should require root path to be string', function () { 25 | assert.throws(serveStatic.bind(null, 42), /root path.*string/) 26 | }) 27 | 28 | it('should serve static files', function (done) { 29 | request(server) 30 | .get('/todo.txt') 31 | .expect(200, '- groceries', done) 32 | }) 33 | 34 | it('should support nesting', function (done) { 35 | request(server) 36 | .get('/users/tobi.txt') 37 | .expect(200, 'ferret', done) 38 | }) 39 | 40 | it('should set Content-Type', function (done) { 41 | request(server) 42 | .get('/todo.txt') 43 | .expect('Content-Type', 'text/plain; charset=utf-8') 44 | .expect(200, done) 45 | }) 46 | 47 | it('should set Last-Modified', function (done) { 48 | request(server) 49 | .get('/todo.txt') 50 | .expect('Last-Modified', /\d{2} \w{3} \d{4}/) 51 | .expect(200, done) 52 | }) 53 | 54 | it('should default max-age=0', function (done) { 55 | request(server) 56 | .get('/todo.txt') 57 | .expect('Cache-Control', 'public, max-age=0') 58 | .expect(200, done) 59 | }) 60 | 61 | it('should support urlencoded pathnames', function (done) { 62 | request(server) 63 | .get('/foo%20bar') 64 | .expect(200) 65 | .expect(shouldHaveBody(Buffer.from('baz'))) 66 | .end(done) 67 | }) 68 | 69 | it('should not choke on auth-looking URL', function (done) { 70 | request(server) 71 | .get('//todo@txt') 72 | .expect(404, done) 73 | }) 74 | 75 | it('should support index.html', function (done) { 76 | request(server) 77 | .get('/users/') 78 | .expect(200) 79 | .expect('Content-Type', /html/) 80 | .expect('

    tobi, loki, jane

    ', done) 81 | }) 82 | 83 | it('should support ../', function (done) { 84 | request(server) 85 | .get('/users/../todo.txt') 86 | .expect(200, '- groceries', done) 87 | }) 88 | 89 | it('should support HEAD', function (done) { 90 | request(server) 91 | .head('/todo.txt') 92 | .expect(200) 93 | .expect(shouldNotHaveBody()) 94 | .end(done) 95 | }) 96 | 97 | it('should skip POST requests', function (done) { 98 | request(server) 99 | .post('/todo.txt') 100 | .expect(404, 'sorry!', done) 101 | }) 102 | 103 | it('should support conditional requests', function (done) { 104 | request(server) 105 | .get('/todo.txt') 106 | .end(function (err, res) { 107 | if (err) throw err 108 | request(server) 109 | .get('/todo.txt') 110 | .set('If-None-Match', res.headers.etag) 111 | .expect(304, done) 112 | }) 113 | }) 114 | 115 | it('should support precondition checks', function (done) { 116 | request(server) 117 | .get('/todo.txt') 118 | .set('If-Match', '"foo"') 119 | .expect(412, done) 120 | }) 121 | 122 | it('should serve zero-length files', function (done) { 123 | request(server) 124 | .get('/empty.txt') 125 | .expect(200, '', done) 126 | }) 127 | 128 | it('should ignore hidden files', function (done) { 129 | request(server) 130 | .get('/.hidden') 131 | .expect(404, done) 132 | }) 133 | }); 134 | 135 | (skipRelative ? describe.skip : describe)('current dir', function () { 136 | var server 137 | before(function () { 138 | server = createServer('.') 139 | }) 140 | 141 | it('should be served with "."', function (done) { 142 | var dest = relative.split(path.sep).join('/') 143 | request(server) 144 | .get('/' + dest + '/todo.txt') 145 | .expect(200, '- groceries', done) 146 | }) 147 | }) 148 | 149 | describe('acceptRanges', function () { 150 | describe('when false', function () { 151 | it('should not include Accept-Ranges', function (done) { 152 | request(createServer(fixtures, { acceptRanges: false })) 153 | .get('/nums.txt') 154 | .expect(shouldNotHaveHeader('Accept-Ranges')) 155 | .expect(200, '123456789', done) 156 | }) 157 | 158 | it('should ignore Rage request header', function (done) { 159 | request(createServer(fixtures, { acceptRanges: false })) 160 | .get('/nums.txt') 161 | .set('Range', 'bytes=0-3') 162 | .expect(shouldNotHaveHeader('Accept-Ranges')) 163 | .expect(shouldNotHaveHeader('Content-Range')) 164 | .expect(200, '123456789', done) 165 | }) 166 | }) 167 | 168 | describe('when true', function () { 169 | it('should include Accept-Ranges', function (done) { 170 | request(createServer(fixtures, { acceptRanges: true })) 171 | .get('/nums.txt') 172 | .expect('Accept-Ranges', 'bytes') 173 | .expect(200, '123456789', done) 174 | }) 175 | 176 | it('should obey Rage request header', function (done) { 177 | request(createServer(fixtures, { acceptRanges: true })) 178 | .get('/nums.txt') 179 | .set('Range', 'bytes=0-3') 180 | .expect('Accept-Ranges', 'bytes') 181 | .expect('Content-Range', 'bytes 0-3/9') 182 | .expect(206, '1234', done) 183 | }) 184 | }) 185 | }) 186 | 187 | describe('cacheControl', function () { 188 | describe('when false', function () { 189 | it('should not include Cache-Control', function (done) { 190 | request(createServer(fixtures, { cacheControl: false })) 191 | .get('/nums.txt') 192 | .expect(shouldNotHaveHeader('Cache-Control')) 193 | .expect(200, '123456789', done) 194 | }) 195 | 196 | it('should ignore maxAge', function (done) { 197 | request(createServer(fixtures, { cacheControl: false, maxAge: 12000 })) 198 | .get('/nums.txt') 199 | .expect(shouldNotHaveHeader('Cache-Control')) 200 | .expect(200, '123456789', done) 201 | }) 202 | }) 203 | 204 | describe('when true', function () { 205 | it('should include Cache-Control', function (done) { 206 | request(createServer(fixtures, { cacheControl: true })) 207 | .get('/nums.txt') 208 | .expect('Cache-Control', 'public, max-age=0') 209 | .expect(200, '123456789', done) 210 | }) 211 | }) 212 | }) 213 | 214 | describe('extensions', function () { 215 | it('should be not be enabled by default', function (done) { 216 | var server = createServer(fixtures) 217 | 218 | request(server) 219 | .get('/todo') 220 | .expect(404, done) 221 | }) 222 | 223 | it('should be configurable', function (done) { 224 | var server = createServer(fixtures, { extensions: 'txt' }) 225 | 226 | request(server) 227 | .get('/todo') 228 | .expect(200, '- groceries', done) 229 | }) 230 | 231 | it('should support disabling extensions', function (done) { 232 | var server = createServer(fixtures, { extensions: false }) 233 | 234 | request(server) 235 | .get('/todo') 236 | .expect(404, done) 237 | }) 238 | 239 | it('should support fallbacks', function (done) { 240 | var server = createServer(fixtures, { extensions: ['htm', 'html', 'txt'] }) 241 | 242 | request(server) 243 | .get('/todo') 244 | .expect(200, '
  • groceries
  • ', done) 245 | }) 246 | 247 | it('should 404 if nothing found', function (done) { 248 | var server = createServer(fixtures, { extensions: ['htm', 'html', 'txt'] }) 249 | 250 | request(server) 251 | .get('/bob') 252 | .expect(404, done) 253 | }) 254 | }) 255 | 256 | describe('fallthrough', function () { 257 | it('should default to true', function (done) { 258 | request(createServer()) 259 | .get('/does-not-exist') 260 | .expect(404, 'sorry!', done) 261 | }) 262 | 263 | describe('when true', function () { 264 | before(function () { 265 | this.server = createServer(fixtures, { fallthrough: true }) 266 | }) 267 | 268 | it('should fall-through when OPTIONS request', function (done) { 269 | request(this.server) 270 | .options('/todo.txt') 271 | .expect(404, 'sorry!', done) 272 | }) 273 | 274 | it('should fall-through when URL malformed', function (done) { 275 | request(this.server) 276 | .get('/%') 277 | .expect(404, 'sorry!', done) 278 | }) 279 | 280 | it('should fall-through when traversing past root', function (done) { 281 | request(this.server) 282 | .get('/users/../../todo.txt') 283 | .expect(404, 'sorry!', done) 284 | }) 285 | 286 | it('should fall-through when URL too long', function (done) { 287 | var root = fixtures + Array(10000).join('/foobar') 288 | 289 | request(createServer(root, { fallthrough: true })) 290 | .get('/') 291 | .expect(404, 'sorry!', done) 292 | }) 293 | 294 | describe('with redirect: true', function () { 295 | before(function () { 296 | this.server = createServer(fixtures, { fallthrough: true, redirect: true }) 297 | }) 298 | 299 | it('should fall-through when directory', function (done) { 300 | request(this.server) 301 | .get('/pets/') 302 | .expect(404, 'sorry!', done) 303 | }) 304 | 305 | it('should redirect when directory without slash', function (done) { 306 | request(this.server) 307 | .get('/pets') 308 | .expect(301, /Redirecting/, done) 309 | }) 310 | }) 311 | 312 | describe('with redirect: false', function () { 313 | before(function () { 314 | this.server = createServer(fixtures, { fallthrough: true, redirect: false }) 315 | }) 316 | 317 | it('should fall-through when directory', function (done) { 318 | request(this.server) 319 | .get('/pets/') 320 | .expect(404, 'sorry!', done) 321 | }) 322 | 323 | it('should fall-through when directory without slash', function (done) { 324 | request(this.server) 325 | .get('/pets') 326 | .expect(404, 'sorry!', done) 327 | }) 328 | }) 329 | }) 330 | 331 | describe('when false', function () { 332 | before(function () { 333 | this.server = createServer(fixtures, { fallthrough: false }) 334 | }) 335 | 336 | it('should 405 when OPTIONS request', function (done) { 337 | request(this.server) 338 | .options('/todo.txt') 339 | .expect('Allow', 'GET, HEAD') 340 | .expect(405, done) 341 | }) 342 | 343 | it('should 400 when URL malformed', function (done) { 344 | request(this.server) 345 | .get('/%') 346 | .expect(400, /BadRequestError/, done) 347 | }) 348 | 349 | it('should 403 when traversing past root', function (done) { 350 | request(this.server) 351 | .get('/users/../../todo.txt') 352 | .expect(403, /ForbiddenError/, done) 353 | }) 354 | 355 | it('should 404 when URL too long', function (done) { 356 | var root = fixtures + Array(10000).join('/foobar') 357 | 358 | request(createServer(root, { fallthrough: false })) 359 | .get('/') 360 | .expect(404, /ENAMETOOLONG/, done) 361 | }) 362 | 363 | describe('with redirect: true', function () { 364 | before(function () { 365 | this.server = createServer(fixtures, { fallthrough: false, redirect: true }) 366 | }) 367 | 368 | it('should 404 when directory', function (done) { 369 | request(this.server) 370 | .get('/pets/') 371 | .expect(404, /NotFoundError|ENOENT/, done) 372 | }) 373 | 374 | it('should redirect when directory without slash', function (done) { 375 | request(this.server) 376 | .get('/pets') 377 | .expect(301, /Redirecting/, done) 378 | }) 379 | }) 380 | 381 | describe('with redirect: false', function () { 382 | before(function () { 383 | this.server = createServer(fixtures, { fallthrough: false, redirect: false }) 384 | }) 385 | 386 | it('should 404 when directory', function (done) { 387 | request(this.server) 388 | .get('/pets/') 389 | .expect(404, /NotFoundError|ENOENT/, done) 390 | }) 391 | 392 | it('should 404 when directory without slash', function (done) { 393 | request(this.server) 394 | .get('/pets') 395 | .expect(404, /NotFoundError|ENOENT/, done) 396 | }) 397 | }) 398 | }) 399 | }) 400 | 401 | describe('hidden files', function () { 402 | var server 403 | before(function () { 404 | server = createServer(fixtures, { dotfiles: 'allow' }) 405 | }) 406 | 407 | it('should be served when dotfiles: "allow" is given', function (done) { 408 | request(server) 409 | .get('/.hidden') 410 | .expect(200) 411 | .expect(shouldHaveBody(Buffer.from('I am hidden'))) 412 | .end(done) 413 | }) 414 | }) 415 | 416 | describe('immutable', function () { 417 | it('should default to false', function (done) { 418 | request(createServer(fixtures)) 419 | .get('/nums.txt') 420 | .expect('Cache-Control', 'public, max-age=0', done) 421 | }) 422 | 423 | it('should set immutable directive in Cache-Control', function (done) { 424 | request(createServer(fixtures, { immutable: true, maxAge: '1h' })) 425 | .get('/nums.txt') 426 | .expect('Cache-Control', 'public, max-age=3600, immutable', done) 427 | }) 428 | }) 429 | 430 | describe('lastModified', function () { 431 | describe('when false', function () { 432 | it('should not include Last-Modifed', function (done) { 433 | request(createServer(fixtures, { lastModified: false })) 434 | .get('/nums.txt') 435 | .expect(shouldNotHaveHeader('Last-Modified')) 436 | .expect(200, '123456789', done) 437 | }) 438 | }) 439 | 440 | describe('when true', function () { 441 | it('should include Last-Modifed', function (done) { 442 | request(createServer(fixtures, { lastModified: true })) 443 | .get('/nums.txt') 444 | .expect('Last-Modified', /^\w{3}, \d+ \w+ \d+ \d+:\d+:\d+ \w+$/) 445 | .expect(200, '123456789', done) 446 | }) 447 | }) 448 | }) 449 | 450 | describe('maxAge', function () { 451 | it('should accept string', function (done) { 452 | request(createServer(fixtures, { maxAge: '30d' })) 453 | .get('/todo.txt') 454 | .expect('cache-control', 'public, max-age=' + (60 * 60 * 24 * 30)) 455 | .expect(200, done) 456 | }) 457 | 458 | it('should be reasonable when infinite', function (done) { 459 | request(createServer(fixtures, { maxAge: Infinity })) 460 | .get('/todo.txt') 461 | .expect('cache-control', 'public, max-age=' + (60 * 60 * 24 * 365)) 462 | .expect(200, done) 463 | }) 464 | }) 465 | 466 | describe('redirect', function () { 467 | var server 468 | before(function () { 469 | server = createServer(fixtures, null, function (req, res) { 470 | req.url = req.url.replace(/\/snow(\/|$)/, '/snow \u2603$1') 471 | }) 472 | }) 473 | 474 | it('should redirect directories', function (done) { 475 | request(server) 476 | .get('/users') 477 | .expect('Location', '/users/') 478 | .expect(301, done) 479 | }) 480 | 481 | it('should include HTML link', function (done) { 482 | request(server) 483 | .get('/users') 484 | .expect('Location', '/users/') 485 | .expect(301, /\/users\//, done) 486 | }) 487 | 488 | it('should redirect directories with query string', function (done) { 489 | request(server) 490 | .get('/users?name=john') 491 | .expect('Location', '/users/?name=john') 492 | .expect(301, done) 493 | }) 494 | 495 | it('should not redirect to protocol-relative locations', function (done) { 496 | request(server) 497 | .get('//users') 498 | .expect('Location', '/users/') 499 | .expect(301, done) 500 | }) 501 | 502 | it('should ensure redirect URL is properly encoded', function (done) { 503 | request(server) 504 | .get('/snow') 505 | .expect('Location', '/snow%20%E2%98%83/') 506 | .expect('Content-Type', /html/) 507 | .expect(301, />Redirecting to \/snow%20%E2%98%83\/