├── .github ├── no-response.yml └── workflows │ └── test-package.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── example ├── example.dart └── files │ ├── dart.png │ ├── favicon.ico │ └── index.html ├── lib ├── shelf_static.dart └── src │ ├── directory_listing.dart │ ├── static_handler.dart │ └── util.dart ├── pubspec.yaml └── test ├── alternative_root_test.dart ├── basic_file_test.dart ├── create_file_handler_test.dart ├── default_document_test.dart ├── directory_listing_test.dart ├── get_handler_test.dart ├── sample_test.dart ├── symbolic_link_test.dart └── test_util.dart /.github/no-response.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-no-response - https://github.com/probot/no-response 2 | 3 | # Number of days of inactivity before an issue is closed for lack of response. 4 | daysUntilClose: 21 5 | 6 | # Label requiring a response. 7 | responseRequiredLabel: "Information needed" 8 | 9 | # Comment to post when closing an Issue for lack of response. 10 | closeComment: >- 11 | Without additional information, we are unfortunately not sure how to 12 | resolve this issue. We are therefore reluctantly going to close this 13 | bug for now. Please don't hesitate to comment on the bug if you have 14 | any more information for us; we will reopen it right away! 15 | 16 | Thanks for your contribution. 17 | -------------------------------------------------------------------------------- /.github/workflows/test-package.yml: -------------------------------------------------------------------------------- 1 | name: Dart CI 2 | 3 | on: 4 | # Run on PRs and pushes to the default branch. 5 | push: 6 | branches: [ master ] 7 | pull_request: 8 | branches: [ master ] 9 | schedule: 10 | - cron: "0 0 * * 0" 11 | 12 | env: 13 | PUB_ENVIRONMENT: bot.github 14 | 15 | jobs: 16 | # Check code formatting and static analysis on a single OS (linux) 17 | # against Dart dev. 18 | analyze: 19 | runs-on: ubuntu-latest 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | sdk: [dev] 24 | steps: 25 | - uses: actions/checkout@v2 26 | - uses: dart-lang/setup-dart@v1.0 27 | with: 28 | sdk: ${{ matrix.sdk }} 29 | - id: install 30 | name: Install dependencies 31 | run: dart pub get 32 | - name: Check formatting 33 | run: dart format --output=none --set-exit-if-changed . 34 | if: always() && steps.install.outcome == 'success' 35 | - name: Analyze code 36 | run: dart analyze --fatal-infos 37 | if: always() && steps.install.outcome == 'success' 38 | 39 | # Run tests on a matrix consisting of two dimensions: 40 | # 1. OS: ubuntu-latest, windows-latest, (macos-latest) 41 | # 2. release channel: dev 42 | test: 43 | needs: analyze 44 | runs-on: ${{ matrix.os }} 45 | strategy: 46 | fail-fast: false 47 | matrix: 48 | os: [ubuntu-latest, windows-latest] 49 | sdk: [2.12.0, dev] 50 | steps: 51 | - uses: actions/checkout@v2 52 | - uses: dart-lang/setup-dart@v1.0 53 | with: 54 | sdk: ${{ matrix.sdk }} 55 | - id: install 56 | name: Install dependencies 57 | run: dart pub get 58 | - name: Run VM tests 59 | run: dart test --platform vm 60 | if: always() && steps.install.outcome == 'success' 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .dart_tool/ 2 | .packages 3 | .pub/ 4 | pubspec.lock 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.1.1-dev 2 | 3 | ## 1.1.0 4 | 5 | * Correctly handle `HEAD` requests. 6 | * Support HTTP range requests. 7 | 8 | ## 1.0.0 9 | 10 | * Migrate to null safety. 11 | 12 | ## 0.2.9+2 13 | 14 | * Change version constraint for the `shelf` dependency, so it accepts null-safe versions. 15 | 16 | ## 0.2.9+1 17 | 18 | * Change version constraint for the `mime` dependency, so it accepts null-safe versions. 19 | 20 | ## 0.2.9 21 | 22 | * Update SDK constraint to `>=2.3.0 <3.0.0`. 23 | * Allow `3.x` versions of `package:convert`. 24 | * Allow `4.x` versions of `package:http_parser`. 25 | * Use file `modified` dates instead of `changed` for `304 Not Modified` checks 26 | as `changed` returns creation dates on Windows. 27 | 28 | ## 0.2.8 29 | 30 | * Update SDK constraint to `>=2.0.0-dev.61 <3.0.0`. 31 | 32 | * Directory listings are now sorted. 33 | 34 | ## 0.2.7+1 35 | 36 | * Updated SDK version to 2.0.0-dev.17.0 37 | 38 | ## 0.2.7 39 | 40 | * Require at least Dart SDK 1.24.0. 41 | * Other internal changes e.g. removing dep on `scheduled_test`. 42 | 43 | ## 0.2.6 44 | 45 | * Add a `createFileHandler()` function that serves a single static file. 46 | 47 | ## 0.2.5 48 | 49 | * Add an optional `contentTypeResolver` argument to `createStaticHandler`. 50 | 51 | ## 0.2.4 52 | 53 | * Add support for "sniffing" the content of the file for the content-type via an optional 54 | `useHeaderBytesForContentType` argument on `createStaticHandler`. 55 | 56 | ## 0.2.3+4 57 | 58 | * Support `http_parser` 3.0.0. 59 | 60 | ## 0.2.3+3 61 | 62 | * Support `shelf` 0.7.0. 63 | 64 | ## 0.2.3+2 65 | 66 | * Support `http_parser` 2.0.0. 67 | 68 | ## 0.2.3+1 69 | 70 | * Support `http_parser` 1.0.0. 71 | 72 | ## 0.2.3 73 | 74 | * Added `listDirectories` argument to `createStaticHandler`. 75 | 76 | ## 0.2.2 77 | 78 | * Bumped up minimum SDK to 1.7.0. 79 | 80 | * Added support for `shelf` 0.6.0. 81 | 82 | ## 0.2.1 83 | 84 | * Removed `Uri` format checks now that the core libraries is more strict. 85 | 86 | ## 0.2.0 87 | 88 | * Removed deprecated `getHandler`. 89 | 90 | * Send correct mime type for default document. 91 | 92 | ## 0.1.4+6 93 | 94 | * Updated development dependencies. 95 | 96 | ## 0.1.4+5 97 | 98 | * Handle differences in resolution between `DateTime` and HTTP date format. 99 | 100 | ## 0.1.4+4 101 | 102 | * Using latest `shelf`. Cleaned up test code by using new features. 103 | 104 | ## 0.1.4 105 | 106 | * Added named (optional) `defaultDocument` argument to `createStaticHandler`. 107 | 108 | ## 0.1.3 109 | 110 | * `createStaticHandler` added `serveFilesOutsidePath` optional parameter. 111 | 112 | ## 0.1.2 113 | 114 | * The preferred top-level method is now `createStaticHandler`. `getHandler` is deprecated. 115 | * Set `content-type` header if the mime type of the requested file can be determined from the file extension. 116 | * Respond with `304-Not modified` against `IF-MODIFIED-SINCE` request header. 117 | * Better error when provided a non-existent `fileSystemPath`. 118 | * Added `example/example_server.dart`. 119 | 120 | ## 0.1.1+1 121 | 122 | * Removed work around for [issue](https://codereview.chromium.org/278783002/). 123 | 124 | ## 0.1.1 125 | 126 | * Correctly handle requests when not hosted at the root of a site. 127 | * Send `last-modified` header. 128 | * Work around [known issue](https://codereview.chromium.org/278783002/) with HTTP date formatting. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Want to contribute? Great! First, read this page (including the small print at 2 | the end). 3 | 4 | ### Before you contribute 5 | Before we can use your code, you must sign the 6 | [Google Individual Contributor License Agreement](https://cla.developers.google.com/about/google-individual) 7 | (CLA), which you can do online. The CLA is necessary mainly because you own the 8 | copyright to your changes, even after your contribution becomes part of our 9 | codebase, so we need your permission to use and distribute your code. We also 10 | need to be sure of various other things—for instance that you'll tell us if you 11 | know that your code infringes on other people's patents. You don't have to sign 12 | the CLA until after you've submitted your code for review and a member has 13 | approved it, but you must do it before we can put your code into our codebase. 14 | 15 | Before you start working on a larger contribution, you should get in touch with 16 | us first through the issue tracker with your idea so that we can help out and 17 | possibly guide you. Coordinating up front makes it much easier to avoid 18 | frustration later on. 19 | 20 | ### Code reviews 21 | All submissions, including submissions by project members, require review. 22 | 23 | ### File headers 24 | All files in the project must start with the following header. 25 | 26 | // Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file 27 | // for details. All rights reserved. Use of this source code is governed by a 28 | // BSD-style license that can be found in the LICENSE file. 29 | 30 | ### The small print 31 | Contributions made by corporations are covered by a different agreement than the 32 | one above, the 33 | [Software Grant and Corporate Contributor License Agreement](https://developers.google.com/open-source/cla/corporate). 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2015, the Dart project authors. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following 11 | disclaimer in the documentation and/or other materials provided 12 | with the distribution. 13 | * Neither the name of Google LLC nor the names of its 14 | contributors may be used to endorse or promote products derived 15 | from this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This package has moved to https://github.com/dart-lang/shelf/tree/master/pkgs/shelf_static 2 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:lints/recommended.yaml 2 | 3 | analyzer: 4 | strong-mode: 5 | implicit-casts: false 6 | 7 | linter: 8 | rules: 9 | - avoid_bool_literals_in_conditional_expressions 10 | - avoid_catching_errors 11 | - avoid_classes_with_only_static_members 12 | - avoid_function_literals_in_foreach_calls 13 | - avoid_private_typedef_functions 14 | - avoid_redundant_argument_values 15 | - avoid_renaming_method_parameters 16 | - avoid_returning_null 17 | - avoid_returning_null_for_future 18 | - avoid_returning_null_for_void 19 | - avoid_returning_this 20 | - avoid_single_cascade_in_expression_statements 21 | - avoid_unused_constructor_parameters 22 | - avoid_void_async 23 | - await_only_futures 24 | - camel_case_types 25 | - cancel_subscriptions 26 | - cascade_invocations 27 | - comment_references 28 | - constant_identifier_names 29 | - control_flow_in_finally 30 | - directives_ordering 31 | - empty_statements 32 | - file_names 33 | - hash_and_equals 34 | - implementation_imports 35 | - invariant_booleans 36 | - iterable_contains_unrelated_type 37 | - join_return_with_assignment 38 | - lines_longer_than_80_chars 39 | - list_remove_unrelated_type 40 | - literal_only_boolean_expressions 41 | - missing_whitespace_between_adjacent_strings 42 | - no_adjacent_strings_in_list 43 | - no_runtimeType_toString 44 | - non_constant_identifier_names 45 | - only_throw_errors 46 | - overridden_fields 47 | - package_api_docs 48 | - package_names 49 | - package_prefixed_library_names 50 | - prefer_asserts_in_initializer_lists 51 | - prefer_const_constructors 52 | - prefer_const_declarations 53 | - prefer_expression_function_bodies 54 | - prefer_final_locals 55 | - prefer_function_declarations_over_variables 56 | - prefer_initializing_formals 57 | - prefer_inlined_adds 58 | - prefer_interpolation_to_compose_strings 59 | - prefer_is_not_operator 60 | - prefer_null_aware_operators 61 | - prefer_relative_imports 62 | - prefer_typing_uninitialized_variables 63 | - prefer_void_to_null 64 | - provide_deprecation_message 65 | - sort_pub_dependencies 66 | - test_types_in_equals 67 | - throw_in_finally 68 | - type_annotate_public_apis 69 | - unnecessary_await_in_return 70 | - unnecessary_brace_in_string_interps 71 | - unnecessary_getters_setters 72 | - unnecessary_lambdas 73 | - unnecessary_null_aware_assignments 74 | - unnecessary_overrides 75 | - unnecessary_parenthesis 76 | - unnecessary_statements 77 | - unnecessary_string_interpolations 78 | - use_is_even_rather_than_modulo 79 | - use_string_buffers 80 | - void_checks 81 | -------------------------------------------------------------------------------- /example/example.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | library shelf_static.example; 6 | 7 | import 'dart:io'; 8 | import 'package:args/args.dart'; 9 | import 'package:shelf/shelf.dart' as shelf; 10 | import 'package:shelf/shelf_io.dart' as io; 11 | import 'package:shelf_static/shelf_static.dart'; 12 | 13 | void main(List args) { 14 | final parser = _getParser(); 15 | 16 | int port; 17 | bool logging; 18 | bool listDirectories; 19 | 20 | try { 21 | final result = parser.parse(args); 22 | port = int.parse(result['port'] as String); 23 | logging = result['logging'] as bool; 24 | listDirectories = result['list-directories'] as bool; 25 | } on FormatException catch (e) { 26 | stderr 27 | ..writeln(e.message) 28 | ..writeln(parser.usage); 29 | // http://linux.die.net/include/sysexits.h 30 | // #define EX_USAGE 64 /* command line usage error */ 31 | exit(64); 32 | } 33 | 34 | if (!FileSystemEntity.isFileSync('example/example.dart')) { 35 | throw StateError('Server expects to be started the ' 36 | 'root of the project.'); 37 | } 38 | var pipeline = const shelf.Pipeline(); 39 | 40 | if (logging) { 41 | pipeline = pipeline.addMiddleware(shelf.logRequests()); 42 | } 43 | 44 | String? defaultDoc = _defaultDoc; 45 | if (listDirectories) { 46 | defaultDoc = null; 47 | } 48 | 49 | final handler = pipeline.addHandler(createStaticHandler('example/files', 50 | defaultDocument: defaultDoc, listDirectories: listDirectories)); 51 | 52 | io.serve(handler, 'localhost', port).then((server) { 53 | print('Serving at http://${server.address.host}:${server.port}'); 54 | }); 55 | } 56 | 57 | ArgParser _getParser() => ArgParser() 58 | ..addFlag('logging', abbr: 'l', defaultsTo: true) 59 | ..addOption('port', abbr: 'p', defaultsTo: '8080') 60 | ..addFlag('list-directories', 61 | abbr: 'f', 62 | negatable: false, 63 | help: 'List the files in the source directory instead of serving the ' 64 | 'default document - "$_defaultDoc".'); 65 | 66 | const _defaultDoc = 'index.html'; 67 | -------------------------------------------------------------------------------- /example/files/dart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dart-archive/shelf_static/e2445ca446e4734acf7f348e53940a0fe0f1461b/example/files/dart.png -------------------------------------------------------------------------------- /example/files/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dart-archive/shelf_static/e2445ca446e4734acf7f348e53940a0fe0f1461b/example/files/favicon.ico -------------------------------------------------------------------------------- /example/files/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | shelf_static 7 | 8 | 9 |

Hello, shelf_static!

10 | Dart logo 11 | 12 | 13 | -------------------------------------------------------------------------------- /lib/shelf_static.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | export 'src/static_handler.dart'; 6 | -------------------------------------------------------------------------------- /lib/src/directory_listing.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'dart:async'; 6 | import 'dart:convert'; 7 | import 'dart:io'; 8 | 9 | import 'package:path/path.dart' as path; 10 | import 'package:shelf/shelf.dart'; 11 | 12 | String _getHeader(String sanitizedHeading) => ''' 13 | 14 | 15 | Directory listing for $sanitizedHeading 16 | 42 | 43 | 44 |

$sanitizedHeading

45 | 49 | 50 | 51 | '''; 52 | 53 | Response listDirectory(String fileSystemPath, String dirPath) { 54 | final controller = StreamController>(); 55 | const encoding = Utf8Codec(); 56 | const sanitizer = HtmlEscape(); 57 | 58 | void add(String string) { 59 | controller.add(encoding.encode(string)); 60 | } 61 | 62 | var heading = path.relative(dirPath, from: fileSystemPath); 63 | if (heading == '.') { 64 | heading = '/'; 65 | } else { 66 | heading = '/$heading/'; 67 | } 68 | 69 | add(_getHeader(sanitizer.convert(heading))); 70 | 71 | // Return a sorted listing of the directory contents asynchronously. 72 | Directory(dirPath).list().toList().then((entities) { 73 | entities.sort((e1, e2) { 74 | if (e1 is Directory && e2 is! Directory) { 75 | return -1; 76 | } 77 | if (e1 is! Directory && e2 is Directory) { 78 | return 1; 79 | } 80 | return e1.path.compareTo(e2.path); 81 | }); 82 | 83 | for (var entity in entities) { 84 | var name = path.relative(entity.path, from: dirPath); 85 | if (entity is Directory) name += '/'; 86 | final sanitizedName = sanitizer.convert(name); 87 | add('
  • $sanitizedName
  • \n'); 88 | } 89 | 90 | add(_trailer); 91 | controller.close(); 92 | }); 93 | 94 | return Response.ok( 95 | controller.stream, 96 | encoding: encoding, 97 | headers: {HttpHeaders.contentTypeHeader: 'text/html'}, 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /lib/src/static_handler.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'dart:async'; 6 | import 'dart:io'; 7 | import 'dart:math' as math; 8 | 9 | import 'package:convert/convert.dart'; 10 | import 'package:http_parser/http_parser.dart'; 11 | import 'package:mime/mime.dart'; 12 | import 'package:path/path.dart' as p; 13 | import 'package:shelf/shelf.dart'; 14 | 15 | import 'directory_listing.dart'; 16 | import 'util.dart'; 17 | 18 | /// The default resolver for MIME types based on file extensions. 19 | final _defaultMimeTypeResolver = MimeTypeResolver(); 20 | 21 | // TODO option to exclude hidden files? 22 | 23 | /// Creates a Shelf [Handler] that serves files from the provided 24 | /// [fileSystemPath]. 25 | /// 26 | /// Accessing a path containing symbolic links will succeed only if the resolved 27 | /// path is within [fileSystemPath]. To allow access to paths outside of 28 | /// [fileSystemPath], set [serveFilesOutsidePath] to `true`. 29 | /// 30 | /// When a existing directory is requested and a [defaultDocument] is specified 31 | /// the directory is checked for a file with that name. If it exists, it is 32 | /// served. 33 | /// 34 | /// If no [defaultDocument] is found and [listDirectories] is true, then the 35 | /// handler produces a listing of the directory. 36 | /// 37 | /// If [useHeaderBytesForContentType] is `true`, the contents of the 38 | /// file will be used along with the file path to determine the content type. 39 | /// 40 | /// Specify a custom [contentTypeResolver] to customize automatic content type 41 | /// detection. 42 | Handler createStaticHandler(String fileSystemPath, 43 | {bool serveFilesOutsidePath = false, 44 | String? defaultDocument, 45 | bool listDirectories = false, 46 | bool useHeaderBytesForContentType = false, 47 | MimeTypeResolver? contentTypeResolver}) { 48 | final rootDir = Directory(fileSystemPath); 49 | if (!rootDir.existsSync()) { 50 | throw ArgumentError('A directory corresponding to fileSystemPath ' 51 | '"$fileSystemPath" could not be found'); 52 | } 53 | 54 | fileSystemPath = rootDir.resolveSymbolicLinksSync(); 55 | 56 | if (defaultDocument != null) { 57 | if (defaultDocument != p.basename(defaultDocument)) { 58 | throw ArgumentError('defaultDocument must be a file name.'); 59 | } 60 | } 61 | 62 | final mimeResolver = contentTypeResolver ?? _defaultMimeTypeResolver; 63 | 64 | return (Request request) { 65 | final segs = [fileSystemPath, ...request.url.pathSegments]; 66 | 67 | final fsPath = p.joinAll(segs); 68 | 69 | final entityType = FileSystemEntity.typeSync(fsPath); 70 | 71 | File? fileFound; 72 | 73 | if (entityType == FileSystemEntityType.file) { 74 | fileFound = File(fsPath); 75 | } else if (entityType == FileSystemEntityType.directory) { 76 | fileFound = _tryDefaultFile(fsPath, defaultDocument); 77 | if (fileFound == null && listDirectories) { 78 | final uri = request.requestedUri; 79 | if (!uri.path.endsWith('/')) return _redirectToAddTrailingSlash(uri); 80 | return listDirectory(fileSystemPath, fsPath); 81 | } 82 | } 83 | 84 | if (fileFound == null) { 85 | return Response.notFound('Not Found'); 86 | } 87 | final file = fileFound; 88 | 89 | if (!serveFilesOutsidePath) { 90 | final resolvedPath = file.resolveSymbolicLinksSync(); 91 | 92 | // Do not serve a file outside of the original fileSystemPath 93 | if (!p.isWithin(fileSystemPath, resolvedPath)) { 94 | return Response.notFound('Not Found'); 95 | } 96 | } 97 | 98 | // when serving the default document for a directory, if the requested 99 | // path doesn't end with '/', redirect to the path with a trailing '/' 100 | final uri = request.requestedUri; 101 | if (entityType == FileSystemEntityType.directory && 102 | !uri.path.endsWith('/')) { 103 | return _redirectToAddTrailingSlash(uri); 104 | } 105 | 106 | return _handleFile(request, file, () async { 107 | if (useHeaderBytesForContentType) { 108 | final length = 109 | math.min(mimeResolver.magicNumbersMaxLength, file.lengthSync()); 110 | 111 | final byteSink = ByteAccumulatorSink(); 112 | 113 | await file.openRead(0, length).listen(byteSink.add).asFuture(); 114 | 115 | return mimeResolver.lookup(file.path, headerBytes: byteSink.bytes); 116 | } else { 117 | return mimeResolver.lookup(file.path); 118 | } 119 | }); 120 | }; 121 | } 122 | 123 | Response _redirectToAddTrailingSlash(Uri uri) { 124 | final location = Uri( 125 | scheme: uri.scheme, 126 | userInfo: uri.userInfo, 127 | host: uri.host, 128 | port: uri.port, 129 | path: '${uri.path}/', 130 | query: uri.query); 131 | 132 | return Response.movedPermanently(location.toString()); 133 | } 134 | 135 | File? _tryDefaultFile(String dirPath, String? defaultFile) { 136 | if (defaultFile == null) return null; 137 | 138 | final filePath = p.join(dirPath, defaultFile); 139 | 140 | final file = File(filePath); 141 | 142 | if (file.existsSync()) { 143 | return file; 144 | } 145 | 146 | return null; 147 | } 148 | 149 | /// Creates a shelf [Handler] that serves the file at [path]. 150 | /// 151 | /// This returns a 404 response for any requests whose [Request.url] doesn't 152 | /// match [url]. The [url] defaults to the basename of [path]. 153 | /// 154 | /// This uses the given [contentType] for the Content-Type header. It defaults 155 | /// to looking up a content type based on [path]'s file extension, and failing 156 | /// that doesn't sent a [contentType] header at all. 157 | Handler createFileHandler(String path, {String? url, String? contentType}) { 158 | final file = File(path); 159 | if (!file.existsSync()) { 160 | throw ArgumentError.value(path, 'path', 'does not exist.'); 161 | } else if (url != null && !p.url.isRelative(url)) { 162 | throw ArgumentError.value(url, 'url', 'must be relative.'); 163 | } 164 | 165 | final mimeType = contentType ?? _defaultMimeTypeResolver.lookup(path); 166 | url ??= p.toUri(p.basename(path)).toString(); 167 | 168 | return (request) { 169 | if (request.url.path != url) return Response.notFound('Not Found'); 170 | return _handleFile(request, file, () => mimeType); 171 | }; 172 | } 173 | 174 | /// Serves the contents of [file] in response to [request]. 175 | /// 176 | /// This handles caching, and sends a 304 Not Modified response if the request 177 | /// indicates that it has the latest version of a file. Otherwise, it calls 178 | /// [getContentType] and uses it to populate the Content-Type header. 179 | Future _handleFile(Request request, File file, 180 | FutureOr Function() getContentType) async { 181 | final stat = file.statSync(); 182 | final ifModifiedSince = request.ifModifiedSince; 183 | 184 | if (ifModifiedSince != null) { 185 | final fileChangeAtSecResolution = toSecondResolution(stat.modified); 186 | if (!fileChangeAtSecResolution.isAfter(ifModifiedSince)) { 187 | return Response.notModified(); 188 | } 189 | } 190 | 191 | final contentType = await getContentType(); 192 | final headers = { 193 | HttpHeaders.lastModifiedHeader: formatHttpDate(stat.modified), 194 | HttpHeaders.acceptRangesHeader: 'bytes', 195 | if (contentType != null) HttpHeaders.contentTypeHeader: contentType, 196 | }; 197 | 198 | return _fileRangeResponse(request, file, headers) ?? 199 | Response.ok( 200 | request.method == 'HEAD' ? null : file.openRead(), 201 | headers: headers..[HttpHeaders.contentLengthHeader] = '${stat.size}', 202 | ); 203 | } 204 | 205 | /// Serves a range of [file], if [request] is valid 'bytes' range request. 206 | /// 207 | /// If the request does not specify a range, specifies a range of the wrong 208 | /// type, or has a syntactic error the range is ignored and `null` is returned. 209 | /// 210 | /// If the range request is valid but the file is not long enough to include the 211 | /// start of the range a range not satisfiable response is returned. 212 | /// 213 | /// Ranges that end past the end of the file are truncated. 214 | Response? _fileRangeResponse( 215 | Request request, File file, Map headers) { 216 | final range = request.headers[HttpHeaders.rangeHeader]; 217 | if (range == null) return null; 218 | final matches = RegExp(r'^bytes=(\d*)\-(\d*)$').firstMatch(range); 219 | // Ignore ranges other than bytes 220 | if (matches == null) return null; 221 | 222 | final actualLength = file.lengthSync(); 223 | final startMatch = matches[1]!; 224 | final endMatch = matches[2]!; 225 | if (startMatch.isEmpty && endMatch.isEmpty) return null; 226 | 227 | int start; // First byte position - inclusive. 228 | int end; // Last byte position - inclusive. 229 | if (startMatch.isEmpty) { 230 | start = actualLength - int.parse(endMatch); 231 | if (start < 0) start = 0; 232 | end = actualLength - 1; 233 | } else { 234 | start = int.parse(startMatch); 235 | end = endMatch.isEmpty ? actualLength - 1 : int.parse(endMatch); 236 | } 237 | 238 | // If the range is syntactically invalid the Range header 239 | // MUST be ignored (RFC 2616 section 14.35.1). 240 | if (start > end) return null; 241 | 242 | if (end >= actualLength) { 243 | end = actualLength - 1; 244 | } 245 | if (start >= actualLength) { 246 | return Response( 247 | HttpStatus.requestedRangeNotSatisfiable, 248 | headers: headers, 249 | ); 250 | } 251 | return Response( 252 | HttpStatus.partialContent, 253 | body: request.method == 'HEAD' ? null : file.openRead(start, end + 1), 254 | headers: headers 255 | ..[HttpHeaders.contentLengthHeader] = (end - start + 1).toString() 256 | ..[HttpHeaders.contentRangeHeader] = 'bytes $start-$end/$actualLength', 257 | ); 258 | } 259 | -------------------------------------------------------------------------------- /lib/src/util.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | DateTime toSecondResolution(DateTime dt) { 6 | if (dt.millisecond == 0) return dt; 7 | return dt.subtract(Duration(milliseconds: dt.millisecond)); 8 | } 9 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: shelf_static 2 | version: 1.1.1-dev 3 | description: Static file server support for the shelf package and ecosystem 4 | repository: https://github.com/dart-lang/shelf_static 5 | 6 | environment: 7 | sdk: '>=2.12.0 <3.0.0' 8 | 9 | dependencies: 10 | convert: ^3.0.0 11 | http_parser: ^4.0.0 12 | mime: ^1.0.0 13 | path: ^1.8.0 14 | # shelf version that allows correctly setting content-length w/ HEAD 15 | shelf: ^1.1.2 16 | 17 | dev_dependencies: 18 | args: ^2.0.0 19 | lints: ^1.0.0 20 | test: ^1.16.0 21 | test_descriptor: ^2.0.0 22 | -------------------------------------------------------------------------------- /test/alternative_root_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'dart:io'; 6 | 7 | import 'package:shelf_static/shelf_static.dart'; 8 | import 'package:test/test.dart'; 9 | import 'package:test_descriptor/test_descriptor.dart' as d; 10 | 11 | import 'test_util.dart'; 12 | 13 | void main() { 14 | setUp(() async { 15 | await d.file('root.txt', 'root txt').create(); 16 | await d.dir('files', [ 17 | d.file('test.txt', 'test txt content'), 18 | d.file('with space.txt', 'with space content') 19 | ]).create(); 20 | }); 21 | 22 | test('access root file', () async { 23 | final handler = createStaticHandler(d.sandbox); 24 | 25 | final response = 26 | await makeRequest(handler, '/static/root.txt', handlerPath: 'static'); 27 | expect(response.statusCode, HttpStatus.ok); 28 | expect(response.contentLength, 8); 29 | expect(response.readAsString(), completion('root txt')); 30 | }); 31 | 32 | test('access root file with space', () async { 33 | final handler = createStaticHandler(d.sandbox); 34 | 35 | final response = await makeRequest( 36 | handler, '/static/files/with%20space.txt', 37 | handlerPath: 'static'); 38 | expect(response.statusCode, HttpStatus.ok); 39 | expect(response.contentLength, 18); 40 | expect(response.readAsString(), completion('with space content')); 41 | }); 42 | 43 | test('access root file with unencoded space', () async { 44 | final handler = createStaticHandler(d.sandbox); 45 | 46 | final response = await makeRequest( 47 | handler, '/static/files/with%20space.txt', 48 | handlerPath: 'static'); 49 | expect(response.statusCode, HttpStatus.ok); 50 | expect(response.contentLength, 18); 51 | expect(response.readAsString(), completion('with space content')); 52 | }); 53 | 54 | test('access file under directory', () async { 55 | final handler = createStaticHandler(d.sandbox); 56 | 57 | final response = await makeRequest(handler, '/static/files/test.txt', 58 | handlerPath: 'static'); 59 | expect(response.statusCode, HttpStatus.ok); 60 | expect(response.contentLength, 16); 61 | expect(response.readAsString(), completion('test txt content')); 62 | }); 63 | 64 | test('file not found', () async { 65 | final handler = createStaticHandler(d.sandbox); 66 | 67 | final response = await makeRequest(handler, '/static/not_here.txt', 68 | handlerPath: 'static'); 69 | expect(response.statusCode, HttpStatus.notFound); 70 | }); 71 | } 72 | -------------------------------------------------------------------------------- /test/basic_file_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'dart:convert'; 6 | import 'dart:io'; 7 | 8 | import 'package:http_parser/http_parser.dart'; 9 | import 'package:mime/mime.dart' as mime; 10 | import 'package:path/path.dart' as p; 11 | import 'package:shelf_static/shelf_static.dart'; 12 | import 'package:test/test.dart'; 13 | import 'package:test_descriptor/test_descriptor.dart' as d; 14 | 15 | import 'test_util.dart'; 16 | 17 | void main() { 18 | setUp(() async { 19 | await d.file('index.html', '').create(); 20 | await d.file('root.txt', 'root txt').create(); 21 | await d.file('random.unknown', 'no clue').create(); 22 | 23 | const pngBytesContent = 24 | r'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAABmJLR0QA/wD/AP+gvae' 25 | r'TAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4AYRETkSXaxBzQAAAB1pVFh0Q2' 26 | r'9tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAbUlEQVQI1wXBvwpBYRwA0' 27 | r'HO/kjBKJmXRLWXxJ4PsnsMTeAEPILvNZrybF7B4A6XvQW6k+DkHwqgM1TnMpoEoDMtw' 28 | r'OJE7pB/VXmF3CdseucmjxaAruR41Pl9p/Gbyoq5B9FeL2OR7zJ+3aC/X8QdQCyIArPs' 29 | r'HkQAAAABJRU5ErkJggg=='; 30 | 31 | const webpBytesContent = 32 | r'UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAQAcJaQAA3AA/v3AgAA='; 33 | 34 | await d.dir('files', [ 35 | d.file('test.txt', 'test txt content'), 36 | d.file('with space.txt', 'with space content'), 37 | d.file('header_bytes_test_image', base64Decode(pngBytesContent)), 38 | d.file('header_bytes_test_webp', base64Decode(webpBytesContent)) 39 | ]).create(); 40 | }); 41 | 42 | test('access root file', () async { 43 | final handler = createStaticHandler(d.sandbox); 44 | 45 | final response = await makeRequest(handler, '/root.txt'); 46 | expect(response.statusCode, HttpStatus.ok); 47 | expect(response.contentLength, 8); 48 | expect(response.readAsString(), completion('root txt')); 49 | }); 50 | 51 | test('HEAD', () async { 52 | final handler = createStaticHandler(d.sandbox); 53 | 54 | final response = await makeRequest(handler, '/root.txt', method: 'HEAD'); 55 | expect(response.statusCode, HttpStatus.ok); 56 | expect(response.contentLength, 8); 57 | expect(await response.readAsString(), isEmpty); 58 | }); 59 | 60 | test('access root file with space', () async { 61 | final handler = createStaticHandler(d.sandbox); 62 | 63 | final response = await makeRequest(handler, '/files/with%20space.txt'); 64 | expect(response.statusCode, HttpStatus.ok); 65 | expect(response.contentLength, 18); 66 | expect(response.readAsString(), completion('with space content')); 67 | }); 68 | 69 | test('access root file with unencoded space', () async { 70 | final handler = createStaticHandler(d.sandbox); 71 | 72 | final response = await makeRequest(handler, '/files/with%20space.txt'); 73 | expect(response.statusCode, HttpStatus.ok); 74 | expect(response.contentLength, 18); 75 | expect(response.readAsString(), completion('with space content')); 76 | }); 77 | 78 | test('access file under directory', () async { 79 | final handler = createStaticHandler(d.sandbox); 80 | 81 | final response = await makeRequest(handler, '/files/test.txt'); 82 | expect(response.statusCode, HttpStatus.ok); 83 | expect(response.contentLength, 16); 84 | expect(response.readAsString(), completion('test txt content')); 85 | }); 86 | 87 | test('file not found', () async { 88 | final handler = createStaticHandler(d.sandbox); 89 | 90 | final response = await makeRequest(handler, '/not_here.txt'); 91 | expect(response.statusCode, HttpStatus.notFound); 92 | }); 93 | 94 | test('last modified', () async { 95 | final handler = createStaticHandler(d.sandbox); 96 | 97 | final rootPath = p.join(d.sandbox, 'root.txt'); 98 | final modified = File(rootPath).statSync().modified.toUtc(); 99 | 100 | final response = await makeRequest(handler, '/root.txt'); 101 | expect(response.lastModified, atSameTimeToSecond(modified)); 102 | }); 103 | 104 | group('if modified since', () { 105 | test('same as last modified', () async { 106 | final handler = createStaticHandler(d.sandbox); 107 | 108 | final rootPath = p.join(d.sandbox, 'root.txt'); 109 | final modified = File(rootPath).statSync().modified.toUtc(); 110 | 111 | final headers = { 112 | HttpHeaders.ifModifiedSinceHeader: formatHttpDate(modified) 113 | }; 114 | 115 | final response = 116 | await makeRequest(handler, '/root.txt', headers: headers); 117 | expect(response.statusCode, HttpStatus.notModified); 118 | expect(response.contentLength, 0); 119 | }); 120 | 121 | test('before last modified', () async { 122 | final handler = createStaticHandler(d.sandbox); 123 | 124 | final rootPath = p.join(d.sandbox, 'root.txt'); 125 | final modified = File(rootPath).statSync().modified.toUtc(); 126 | 127 | final headers = { 128 | HttpHeaders.ifModifiedSinceHeader: 129 | formatHttpDate(modified.subtract(const Duration(seconds: 1))) 130 | }; 131 | 132 | final response = 133 | await makeRequest(handler, '/root.txt', headers: headers); 134 | expect(response.statusCode, HttpStatus.ok); 135 | expect(response.lastModified, atSameTimeToSecond(modified)); 136 | }); 137 | 138 | test('after last modified', () async { 139 | final handler = createStaticHandler(d.sandbox); 140 | 141 | final rootPath = p.join(d.sandbox, 'root.txt'); 142 | final modified = File(rootPath).statSync().modified.toUtc(); 143 | 144 | final headers = { 145 | HttpHeaders.ifModifiedSinceHeader: 146 | formatHttpDate(modified.add(const Duration(seconds: 1))) 147 | }; 148 | 149 | final response = 150 | await makeRequest(handler, '/root.txt', headers: headers); 151 | expect(response.statusCode, HttpStatus.notModified); 152 | expect(response.contentLength, 0); 153 | }); 154 | 155 | test('after file modification', () async { 156 | // This test updates a file on disk to ensure the file stamp is updated 157 | // which was previously not the case on Windows due to the files "changed" 158 | // date being the creation date. 159 | // https://github.com/dart-lang/shelf_static/issues/37 160 | 161 | final handler = createStaticHandler(d.sandbox); 162 | final rootPath = p.join(d.sandbox, 'root.txt'); 163 | 164 | final response1 = await makeRequest(handler, '/root.txt'); 165 | final originalModificationDate = response1.lastModified!; 166 | 167 | // Ensure the timestamp change is > 1s. 168 | await Future.delayed(const Duration(seconds: 2)); 169 | File(rootPath).writeAsStringSync('updated root txt'); 170 | 171 | final headers = { 172 | HttpHeaders.ifModifiedSinceHeader: 173 | formatHttpDate(originalModificationDate) 174 | }; 175 | 176 | final response2 = 177 | await makeRequest(handler, '/root.txt', headers: headers); 178 | expect(response2.statusCode, HttpStatus.ok); 179 | expect(response2.lastModified!.millisecondsSinceEpoch, 180 | greaterThan(originalModificationDate.millisecondsSinceEpoch)); 181 | }); 182 | }); 183 | 184 | group('content type', () { 185 | test('root.txt should be text/plain', () async { 186 | final handler = createStaticHandler(d.sandbox); 187 | 188 | final response = await makeRequest(handler, '/root.txt'); 189 | expect(response.mimeType, 'text/plain'); 190 | }); 191 | 192 | test('index.html should be text/html', () async { 193 | final handler = createStaticHandler(d.sandbox); 194 | 195 | final response = await makeRequest(handler, '/index.html'); 196 | expect(response.mimeType, 'text/html'); 197 | }); 198 | 199 | test('random.unknown should be null', () async { 200 | final handler = createStaticHandler(d.sandbox); 201 | 202 | final response = await makeRequest(handler, '/random.unknown'); 203 | expect(response.mimeType, isNull); 204 | }); 205 | 206 | test('header_bytes_test_image should be image/png', () async { 207 | final handler = 208 | createStaticHandler(d.sandbox, useHeaderBytesForContentType: true); 209 | 210 | final response = 211 | await makeRequest(handler, '/files/header_bytes_test_image'); 212 | expect(response.mimeType, 'image/png'); 213 | }); 214 | 215 | test('header_bytes_test_webp should be image/webp', () async { 216 | final resolver = mime.MimeTypeResolver() 217 | ..addMagicNumber( 218 | [ 219 | 0x52, 0x49, 0x46, 0x46, 0x00, 0x00, // 220 | 0x00, 0x00, 0x57, 0x45, 0x42, 0x50 221 | ], 222 | 'image/webp', 223 | mask: [ 224 | 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, // 225 | 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF 226 | ], 227 | ); 228 | final handler = createStaticHandler(d.sandbox, 229 | useHeaderBytesForContentType: true, contentTypeResolver: resolver); 230 | 231 | final response = 232 | await makeRequest(handler, '/files/header_bytes_test_webp'); 233 | expect(response.mimeType, 'image/webp'); 234 | }); 235 | }); 236 | } 237 | -------------------------------------------------------------------------------- /test/create_file_handler_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'dart:io'; 6 | 7 | import 'package:path/path.dart' as p; 8 | import 'package:shelf_static/shelf_static.dart'; 9 | import 'package:test/test.dart'; 10 | import 'package:test_descriptor/test_descriptor.dart' as d; 11 | 12 | import 'test_util.dart'; 13 | 14 | void main() { 15 | setUp(() async { 16 | await d.file('file.txt', 'contents').create(); 17 | await d.file('random.unknown', 'no clue').create(); 18 | }); 19 | 20 | test('serves the file contents', () async { 21 | final handler = createFileHandler(p.join(d.sandbox, 'file.txt')); 22 | final response = await makeRequest(handler, '/file.txt'); 23 | expect(response.statusCode, equals(HttpStatus.ok)); 24 | expect(response.contentLength, equals(8)); 25 | expect(response.readAsString(), completion(equals('contents'))); 26 | }); 27 | 28 | test('serves a 404 for a non-matching URL', () async { 29 | final handler = createFileHandler(p.join(d.sandbox, 'file.txt')); 30 | final response = await makeRequest(handler, '/foo/file.txt'); 31 | expect(response.statusCode, equals(HttpStatus.notFound)); 32 | }); 33 | 34 | test('serves the file contents under a custom URL', () async { 35 | final handler = 36 | createFileHandler(p.join(d.sandbox, 'file.txt'), url: 'foo/bar'); 37 | final response = await makeRequest(handler, '/foo/bar'); 38 | expect(response.statusCode, equals(HttpStatus.ok)); 39 | expect(response.contentLength, equals(8)); 40 | expect(response.readAsString(), completion(equals('contents'))); 41 | }); 42 | 43 | test("serves a 404 if the custom URL isn't matched", () async { 44 | final handler = 45 | createFileHandler(p.join(d.sandbox, 'file.txt'), url: 'foo/bar'); 46 | final response = await makeRequest(handler, '/file.txt'); 47 | expect(response.statusCode, equals(HttpStatus.notFound)); 48 | }); 49 | 50 | group('the content type header', () { 51 | test('is inferred from the file path', () async { 52 | final handler = createFileHandler(p.join(d.sandbox, 'file.txt')); 53 | final response = await makeRequest(handler, '/file.txt'); 54 | expect(response.statusCode, equals(HttpStatus.ok)); 55 | expect(response.mimeType, equals('text/plain')); 56 | }); 57 | 58 | test("is omitted if it can't be inferred", () async { 59 | final handler = createFileHandler(p.join(d.sandbox, 'random.unknown')); 60 | final response = await makeRequest(handler, '/random.unknown'); 61 | expect(response.statusCode, equals(HttpStatus.ok)); 62 | expect(response.mimeType, isNull); 63 | }); 64 | 65 | test('comes from the contentType parameter', () async { 66 | final handler = createFileHandler(p.join(d.sandbox, 'file.txt'), 67 | contentType: 'something/weird'); 68 | final response = await makeRequest(handler, '/file.txt'); 69 | expect(response.statusCode, equals(HttpStatus.ok)); 70 | expect(response.mimeType, equals('something/weird')); 71 | }); 72 | }); 73 | 74 | group('the content range header', () { 75 | test('is bytes from 0 to 4', () async { 76 | final handler = createFileHandler(p.join(d.sandbox, 'file.txt')); 77 | final response = await makeRequest( 78 | handler, 79 | '/file.txt', 80 | headers: {'range': 'bytes=0-4'}, 81 | ); 82 | expect(response.statusCode, equals(HttpStatus.partialContent)); 83 | expect( 84 | response.headers, 85 | containsPair(HttpHeaders.acceptRangesHeader, 'bytes'), 86 | ); 87 | expect( 88 | response.headers, 89 | containsPair(HttpHeaders.contentRangeHeader, 'bytes 0-4/8'), 90 | ); 91 | expect(response.headers, containsPair('content-length', '5')); 92 | }); 93 | 94 | test('at the end of has overflow from 0 to 9', () async { 95 | final handler = createFileHandler(p.join(d.sandbox, 'file.txt')); 96 | final response = await makeRequest( 97 | handler, 98 | '/file.txt', 99 | headers: {'range': 'bytes=0-9'}, 100 | ); 101 | expect( 102 | response.statusCode, 103 | equals(HttpStatus.partialContent), 104 | ); 105 | expect( 106 | response.headers, 107 | containsPair(HttpHeaders.acceptRangesHeader, 'bytes'), 108 | ); 109 | expect( 110 | response.headers, 111 | containsPair(HttpHeaders.contentRangeHeader, 'bytes 0-7/8'), 112 | ); 113 | expect(response.headers, containsPair('content-length', '8')); 114 | }); 115 | 116 | test('at the start of has overflow from 8 to 9', () async { 117 | final handler = createFileHandler(p.join(d.sandbox, 'file.txt')); 118 | final response = await makeRequest( 119 | handler, 120 | '/file.txt', 121 | headers: {'range': 'bytes=8-9'}, 122 | ); 123 | expect(response.headers, containsPair('content-length', '0')); 124 | expect( 125 | response.headers, 126 | containsPair(HttpHeaders.acceptRangesHeader, 'bytes'), 127 | ); 128 | expect( 129 | response.statusCode, 130 | HttpStatus.requestedRangeNotSatisfiable, 131 | ); 132 | }); 133 | 134 | test('ignores invalid request with start > end', () async { 135 | final handler = createFileHandler(p.join(d.sandbox, 'file.txt')); 136 | final response = await makeRequest( 137 | handler, 138 | '/file.txt', 139 | headers: {'range': 'bytes=2-1'}, 140 | ); 141 | expect(response.statusCode, equals(HttpStatus.ok)); 142 | expect(response.contentLength, equals(8)); 143 | expect(response.readAsString(), completion(equals('contents'))); 144 | }); 145 | 146 | test('ignores request with start > end', () async { 147 | final handler = createFileHandler(p.join(d.sandbox, 'file.txt')); 148 | final response = await makeRequest( 149 | handler, 150 | '/file.txt', 151 | headers: {'range': 'bytes=2-1'}, 152 | ); 153 | expect(response.statusCode, equals(HttpStatus.ok)); 154 | expect(response.contentLength, equals(8)); 155 | expect(response.readAsString(), completion(equals('contents'))); 156 | }); 157 | 158 | test('ignores request with units other than bytes', () async { 159 | final handler = createFileHandler(p.join(d.sandbox, 'file.txt')); 160 | final response = await makeRequest( 161 | handler, 162 | '/file.txt', 163 | headers: {'range': 'not-bytes=0-1'}, 164 | ); 165 | expect(response.statusCode, equals(HttpStatus.ok)); 166 | expect(response.contentLength, equals(8)); 167 | expect(response.readAsString(), completion(equals('contents'))); 168 | }); 169 | 170 | test('ignores request with no start or end', () async { 171 | final handler = createFileHandler(p.join(d.sandbox, 'file.txt')); 172 | final response = await makeRequest( 173 | handler, 174 | '/file.txt', 175 | headers: {'range': 'bytes=-'}, 176 | ); 177 | expect(response.statusCode, equals(HttpStatus.ok)); 178 | expect(response.contentLength, equals(8)); 179 | expect(response.readAsString(), completion(equals('contents'))); 180 | }); 181 | }); 182 | 183 | group('throws an ArgumentError for', () { 184 | test("a file that doesn't exist", () { 185 | expect(() => createFileHandler(p.join(d.sandbox, 'nothing.txt')), 186 | throwsArgumentError); 187 | }); 188 | 189 | test('an absolute URL', () { 190 | expect( 191 | () => createFileHandler(p.join(d.sandbox, 'nothing.txt'), 192 | url: '/foo/bar'), 193 | throwsArgumentError); 194 | }); 195 | }); 196 | } 197 | -------------------------------------------------------------------------------- /test/default_document_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'dart:io'; 6 | 7 | import 'package:shelf_static/shelf_static.dart'; 8 | import 'package:test/test.dart'; 9 | import 'package:test_descriptor/test_descriptor.dart' as d; 10 | 11 | import 'test_util.dart'; 12 | 13 | void main() { 14 | setUp(() async { 15 | await d.file('index.html', '').create(); 16 | await d.file('root.txt', 'root txt').create(); 17 | await d.dir('files', [ 18 | d.file('index.html', 'files'), 19 | d.file('with space.txt', 'with space content') 20 | ]).create(); 21 | }); 22 | 23 | group('default document value', () { 24 | test('cannot contain slashes', () { 25 | final invalidValues = [ 26 | 'file/foo.txt', 27 | '/bar.txt', 28 | '//bar.txt', 29 | '//news/bar.txt', 30 | 'foo/../bar.txt' 31 | ]; 32 | 33 | for (var val in invalidValues) { 34 | expect(() => createStaticHandler(d.sandbox, defaultDocument: val), 35 | throwsArgumentError); 36 | } 37 | }); 38 | }); 39 | 40 | group('no default document specified', () { 41 | test('access "/index.html"', () async { 42 | final handler = createStaticHandler(d.sandbox); 43 | 44 | final response = await makeRequest(handler, '/index.html'); 45 | expect(response.statusCode, HttpStatus.ok); 46 | expect(response.contentLength, 13); 47 | expect(response.readAsString(), completion('')); 48 | }); 49 | 50 | test('access "/"', () async { 51 | final handler = createStaticHandler(d.sandbox); 52 | 53 | final response = await makeRequest(handler, '/'); 54 | expect(response.statusCode, HttpStatus.notFound); 55 | }); 56 | 57 | test('access "/files"', () async { 58 | final handler = createStaticHandler(d.sandbox); 59 | 60 | final response = await makeRequest(handler, '/files'); 61 | expect(response.statusCode, HttpStatus.notFound); 62 | }); 63 | 64 | test('access "/files/" dir', () async { 65 | final handler = createStaticHandler(d.sandbox); 66 | 67 | final response = await makeRequest(handler, '/files/'); 68 | expect(response.statusCode, HttpStatus.notFound); 69 | }); 70 | }); 71 | 72 | group('default document specified', () { 73 | test('access "/index.html"', () async { 74 | final handler = 75 | createStaticHandler(d.sandbox, defaultDocument: 'index.html'); 76 | 77 | final response = await makeRequest(handler, '/index.html'); 78 | expect(response.statusCode, HttpStatus.ok); 79 | expect(response.contentLength, 13); 80 | expect(response.readAsString(), completion('')); 81 | expect(response.mimeType, 'text/html'); 82 | }); 83 | 84 | test('access "/"', () async { 85 | final handler = 86 | createStaticHandler(d.sandbox, defaultDocument: 'index.html'); 87 | 88 | final response = await makeRequest(handler, '/'); 89 | expect(response.statusCode, HttpStatus.ok); 90 | expect(response.contentLength, 13); 91 | expect(response.readAsString(), completion('')); 92 | expect(response.mimeType, 'text/html'); 93 | }); 94 | 95 | test('access "/files"', () async { 96 | final handler = 97 | createStaticHandler(d.sandbox, defaultDocument: 'index.html'); 98 | 99 | final response = await makeRequest(handler, '/files'); 100 | expect(response.statusCode, HttpStatus.movedPermanently); 101 | expect(response.headers, 102 | containsPair(HttpHeaders.locationHeader, 'http://localhost/files/')); 103 | }); 104 | 105 | test('access "/files/" dir', () async { 106 | final handler = 107 | createStaticHandler(d.sandbox, defaultDocument: 'index.html'); 108 | 109 | final response = await makeRequest(handler, '/files/'); 110 | expect(response.statusCode, HttpStatus.ok); 111 | expect(response.contentLength, 31); 112 | expect(response.readAsString(), 113 | completion('files')); 114 | expect(response.mimeType, 'text/html'); 115 | }); 116 | }); 117 | } 118 | -------------------------------------------------------------------------------- /test/directory_listing_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'dart:io'; 6 | 7 | import 'package:shelf_static/shelf_static.dart'; 8 | import 'package:test/test.dart'; 9 | import 'package:test_descriptor/test_descriptor.dart' as d; 10 | 11 | import 'test_util.dart'; 12 | 13 | void main() { 14 | setUp(() async { 15 | await d.file('index.html', '').create(); 16 | await d.file('root.txt', 'root txt').create(); 17 | await d.dir('files', [ 18 | d.file('index.html', 'files'), 19 | d.file('with space.txt', 'with space content'), 20 | d.dir('empty subfolder', []), 21 | ]).create(); 22 | }); 23 | 24 | test('access "/"', () async { 25 | final handler = createStaticHandler(d.sandbox, listDirectories: true); 26 | 27 | final response = await makeRequest(handler, '/'); 28 | expect(response.statusCode, HttpStatus.ok); 29 | expect(response.readAsString(), completes); 30 | }); 31 | 32 | test('access "/files"', () async { 33 | final handler = createStaticHandler(d.sandbox, listDirectories: true); 34 | 35 | final response = await makeRequest(handler, '/files'); 36 | expect(response.statusCode, HttpStatus.movedPermanently); 37 | expect(response.headers, 38 | containsPair(HttpHeaders.locationHeader, 'http://localhost/files/')); 39 | }); 40 | 41 | test('access "/files/"', () async { 42 | final handler = createStaticHandler(d.sandbox, listDirectories: true); 43 | 44 | final response = await makeRequest(handler, '/files/'); 45 | expect(response.statusCode, HttpStatus.ok); 46 | expect(response.readAsString(), completes); 47 | }); 48 | 49 | test('access "/files/empty subfolder"', () async { 50 | final handler = createStaticHandler(d.sandbox, listDirectories: true); 51 | 52 | final response = await makeRequest(handler, '/files/empty subfolder'); 53 | expect(response.statusCode, HttpStatus.movedPermanently); 54 | expect( 55 | response.headers, 56 | containsPair(HttpHeaders.locationHeader, 57 | 'http://localhost/files/empty%20subfolder/')); 58 | }); 59 | 60 | test('access "/files/empty subfolder/"', () async { 61 | final handler = createStaticHandler(d.sandbox, listDirectories: true); 62 | 63 | final response = await makeRequest(handler, '/files/empty subfolder/'); 64 | expect(response.statusCode, HttpStatus.ok); 65 | expect(response.readAsString(), completes); 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /test/get_handler_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'package:path/path.dart' as p; 6 | import 'package:shelf_static/shelf_static.dart'; 7 | import 'package:test/test.dart'; 8 | import 'package:test_descriptor/test_descriptor.dart' as d; 9 | 10 | void main() { 11 | setUp(() async { 12 | await d.file('root.txt', 'root txt').create(); 13 | await d.dir('files', [ 14 | d.file('test.txt', 'test txt content'), 15 | d.file('with space.txt', 'with space content') 16 | ]).create(); 17 | }); 18 | 19 | test('non-existent relative path', () async { 20 | expect(() => createStaticHandler('random/relative'), throwsArgumentError); 21 | }); 22 | 23 | test('existing relative path', () async { 24 | final existingRelative = p.relative(d.sandbox); 25 | expect(() => createStaticHandler(existingRelative), returnsNormally); 26 | }); 27 | 28 | test('non-existent absolute path', () { 29 | final nonExistingAbsolute = p.join(d.sandbox, 'not_here'); 30 | expect(() => createStaticHandler(nonExistingAbsolute), throwsArgumentError); 31 | }); 32 | 33 | test('existing absolute path', () { 34 | expect(() => createStaticHandler(d.sandbox), returnsNormally); 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /test/sample_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'dart:io'; 6 | 7 | import 'package:path/path.dart' as p; 8 | import 'package:shelf/shelf.dart'; 9 | import 'package:shelf_static/shelf_static.dart'; 10 | import 'package:test/test.dart'; 11 | 12 | import 'test_util.dart'; 13 | 14 | void main() { 15 | group('/index.html', () { 16 | test('body is correct', () async { 17 | await _testFileContents('index.html'); 18 | }); 19 | 20 | test('mimeType is text/html', () async { 21 | final response = await _requestFile('index.html'); 22 | expect(response.mimeType, 'text/html'); 23 | }); 24 | 25 | group('/favicon.ico', () { 26 | test('body is correct', () async { 27 | await _testFileContents('favicon.ico'); 28 | }); 29 | 30 | test('mimeType is text/html', () async { 31 | final response = await _requestFile('favicon.ico'); 32 | expect(response.mimeType, 'image/x-icon'); 33 | }); 34 | }); 35 | }); 36 | 37 | group('/dart.png', () { 38 | test('body is correct', () async { 39 | await _testFileContents('dart.png'); 40 | }); 41 | 42 | test('mimeType is image/png', () async { 43 | final response = await _requestFile('dart.png'); 44 | expect(response.mimeType, 'image/png'); 45 | }); 46 | }); 47 | } 48 | 49 | Future _requestFile(String filename) { 50 | final uri = Uri.parse('http://localhost/$filename'); 51 | 52 | return _request(Request('GET', uri)); 53 | } 54 | 55 | Future _testFileContents(String filename) async { 56 | final filePath = p.join(_samplePath, filename); 57 | final file = File(filePath); 58 | final fileContents = file.readAsBytesSync(); 59 | final fileStat = file.statSync(); 60 | 61 | final response = await _requestFile(filename); 62 | expect(response.contentLength, fileStat.size); 63 | expect(response.lastModified, atSameTimeToSecond(fileStat.modified.toUtc())); 64 | await _expectCompletesWithBytes(response, fileContents); 65 | } 66 | 67 | Future _expectCompletesWithBytes( 68 | Response response, List expectedBytes) async { 69 | final bytes = await response.read().toList(); 70 | final flatBytes = bytes.expand((e) => e); 71 | expect(flatBytes, orderedEquals(expectedBytes)); 72 | } 73 | 74 | Future _request(Request request) async { 75 | final handler = createStaticHandler(_samplePath); 76 | return await handler(request); 77 | } 78 | 79 | String get _samplePath { 80 | final sampleDir = p.join(p.current, 'example', 'files'); 81 | assert(FileSystemEntity.isDirectorySync(sampleDir)); 82 | return sampleDir; 83 | } 84 | -------------------------------------------------------------------------------- /test/symbolic_link_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'dart:io'; 6 | 7 | import 'package:path/path.dart' as p; 8 | import 'package:shelf_static/shelf_static.dart'; 9 | import 'package:test/test.dart'; 10 | import 'package:test_descriptor/test_descriptor.dart' as d; 11 | 12 | import 'test_util.dart'; 13 | 14 | const _skipSymlinksOnWindows = { 15 | 'windows': Skip('Skip tests for sym-linked files on Windows'), 16 | }; 17 | 18 | void main() { 19 | setUp(() async { 20 | await d.dir('originals', [ 21 | d.file('index.html', ''), 22 | ]).create(); 23 | 24 | await d.dir('alt_root').create(); 25 | 26 | final originalsDir = p.join(d.sandbox, 'originals'); 27 | final originalsIndex = p.join(originalsDir, 'index.html'); 28 | 29 | Link(p.join(d.sandbox, 'link_index.html')).createSync(originalsIndex); 30 | 31 | Link(p.join(d.sandbox, 'link_dir')).createSync(originalsDir); 32 | 33 | Link(p.join(d.sandbox, 'alt_root', 'link_index.html')) 34 | .createSync(originalsIndex); 35 | 36 | Link(p.join(d.sandbox, 'alt_root', 'link_dir')).createSync(originalsDir); 37 | }); 38 | 39 | group('access outside of root disabled', () { 40 | test('access real file', () async { 41 | final handler = createStaticHandler(d.sandbox); 42 | 43 | final response = await makeRequest(handler, '/originals/index.html'); 44 | expect(response.statusCode, HttpStatus.ok); 45 | expect(response.contentLength, 13); 46 | expect(response.readAsString(), completion('')); 47 | }); 48 | 49 | group('links under root dir', () { 50 | test( 51 | 'access sym linked file in real dir', 52 | () async { 53 | final handler = createStaticHandler(d.sandbox); 54 | 55 | final response = await makeRequest(handler, '/link_index.html'); 56 | expect(response.statusCode, HttpStatus.ok); 57 | expect(response.contentLength, 13); 58 | expect(response.readAsString(), completion('')); 59 | }, 60 | onPlatform: _skipSymlinksOnWindows, 61 | ); 62 | 63 | test('access file in sym linked dir', () async { 64 | final handler = createStaticHandler(d.sandbox); 65 | 66 | final response = await makeRequest(handler, '/link_dir/index.html'); 67 | expect(response.statusCode, HttpStatus.ok); 68 | expect(response.contentLength, 13); 69 | expect(response.readAsString(), completion('')); 70 | }); 71 | }); 72 | 73 | group('links not under root dir', () { 74 | test('access sym linked file in real dir', () async { 75 | final handler = createStaticHandler(p.join(d.sandbox, 'alt_root')); 76 | 77 | final response = await makeRequest(handler, '/link_index.html'); 78 | expect(response.statusCode, HttpStatus.notFound); 79 | }); 80 | 81 | test('access file in sym linked dir', () async { 82 | final handler = createStaticHandler(p.join(d.sandbox, 'alt_root')); 83 | 84 | final response = await makeRequest(handler, '/link_dir/index.html'); 85 | expect(response.statusCode, HttpStatus.notFound); 86 | }); 87 | }); 88 | }); 89 | 90 | group('access outside of root enabled', () { 91 | test('access real file', () async { 92 | final handler = 93 | createStaticHandler(d.sandbox, serveFilesOutsidePath: true); 94 | 95 | final response = await makeRequest(handler, '/originals/index.html'); 96 | expect(response.statusCode, HttpStatus.ok); 97 | expect(response.contentLength, 13); 98 | expect(response.readAsString(), completion('')); 99 | }); 100 | 101 | group('links under root dir', () { 102 | test( 103 | 'access sym linked file in real dir', 104 | () async { 105 | final handler = 106 | createStaticHandler(d.sandbox, serveFilesOutsidePath: true); 107 | 108 | final response = await makeRequest(handler, '/link_index.html'); 109 | expect(response.statusCode, HttpStatus.ok); 110 | expect(response.contentLength, 13); 111 | expect(response.readAsString(), completion('')); 112 | }, 113 | onPlatform: _skipSymlinksOnWindows, 114 | ); 115 | 116 | test('access file in sym linked dir', () async { 117 | final handler = 118 | createStaticHandler(d.sandbox, serveFilesOutsidePath: true); 119 | 120 | final response = await makeRequest(handler, '/link_dir/index.html'); 121 | expect(response.statusCode, HttpStatus.ok); 122 | expect(response.contentLength, 13); 123 | expect(response.readAsString(), completion('')); 124 | }); 125 | }); 126 | 127 | group('links not under root dir', () { 128 | test( 129 | 'access sym linked file in real dir', 130 | () async { 131 | final handler = createStaticHandler(p.join(d.sandbox, 'alt_root'), 132 | serveFilesOutsidePath: true); 133 | 134 | final response = await makeRequest(handler, '/link_index.html'); 135 | expect(response.statusCode, HttpStatus.ok); 136 | expect(response.contentLength, 13); 137 | expect(response.readAsString(), completion('')); 138 | }, 139 | onPlatform: _skipSymlinksOnWindows, 140 | ); 141 | 142 | test('access file in sym linked dir', () async { 143 | final handler = createStaticHandler(p.join(d.sandbox, 'alt_root'), 144 | serveFilesOutsidePath: true); 145 | 146 | final response = await makeRequest(handler, '/link_dir/index.html'); 147 | expect(response.statusCode, HttpStatus.ok); 148 | expect(response.contentLength, 13); 149 | expect(response.readAsString(), completion('')); 150 | }); 151 | }); 152 | }); 153 | } 154 | -------------------------------------------------------------------------------- /test/test_util.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'package:path/path.dart' as p; 6 | import 'package:shelf/shelf.dart'; 7 | import 'package:shelf_static/src/util.dart'; 8 | import 'package:test/test.dart'; 9 | 10 | final p.Context _ctx = p.url; 11 | 12 | /// Makes a simple GET request to [handler] and returns the result. 13 | Future makeRequest( 14 | Handler handler, 15 | String path, { 16 | String? handlerPath, 17 | Map? headers, 18 | String method = 'GET', 19 | }) async { 20 | final rootedHandler = _rootHandler(handlerPath, handler); 21 | return rootedHandler(_fromPath(path, headers, method: method)); 22 | } 23 | 24 | Request _fromPath( 25 | String path, 26 | Map? headers, { 27 | required String method, 28 | }) => 29 | Request(method, Uri.parse('http://localhost$path'), headers: headers); 30 | 31 | Handler _rootHandler(String? path, Handler handler) { 32 | if (path == null || path.isEmpty) { 33 | return handler; 34 | } 35 | 36 | return (Request request) { 37 | if (!_ctx.isWithin('/$path', request.requestedUri.path)) { 38 | return Response.notFound('not found'); 39 | } 40 | assert(request.handlerPath == '/'); 41 | 42 | final relativeRequest = request.change(path: path); 43 | 44 | return handler(relativeRequest); 45 | }; 46 | } 47 | 48 | Matcher atSameTimeToSecond(DateTime value) => 49 | _SecondResolutionDateTimeMatcher(value); 50 | 51 | class _SecondResolutionDateTimeMatcher extends Matcher { 52 | final DateTime _target; 53 | 54 | _SecondResolutionDateTimeMatcher(DateTime target) 55 | : _target = toSecondResolution(target); 56 | 57 | @override 58 | bool matches(dynamic item, Map matchState) { 59 | if (item is! DateTime) return false; 60 | 61 | return _datesEqualToSecond(_target, item); 62 | } 63 | 64 | @override 65 | Description describe(Description description) => 66 | description.add('Must be at the same moment as $_target with resolution ' 67 | 'to the second.'); 68 | } 69 | 70 | bool _datesEqualToSecond(DateTime d1, DateTime d2) => 71 | toSecondResolution(d1).isAtSameMomentAs(toSecondResolution(d2)); 72 | --------------------------------------------------------------------------------