diff --git a/CHANGELOG.md b/CHANGELOG.md index d9ae450..bc5431b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,13 @@ -## 4.2.0-wip +## 4.2.0 - Added support for `https`. -- Add clickable link to serve output. -- Update minimum Dart SDK to `3.10.0`. - Support multiple `--headers` flags and more robust header value parsing. For example: `--headers="header1=value1;header2=value2"` or `--headers="header1=value1" --headers="header2=value2"` +- Add `--list-files` flag to show a directory listing when no `index.html` is + present. +- Add clickable link to serve output. +- Require `sdk: ^3.10.0`. ## 4.1.0 diff --git a/README.md b/README.md index 66390f6..4efeb48 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ See the Dart documentation of [SecurityContext.usePrivateKey](https://api.dart.d ```console $ dhttpd --help --p, --port= The port to listen on. +-p, --port= The port to listen on. Provide `0` to use a random port. (defaults to "8080") --path= The path to serve. If not set, the current directory is used. --headers= HTTP headers to apply to each response. Can be used multiple times. Format: header=value;header2=value @@ -64,6 +64,7 @@ $ dhttpd --help --sslkey= The key of the SSL certificate to use. Also requires sslcert --sslkeypassword= The password for the key of the SSL certificate to use. -h, --help Displays the help. +-l, --list-files List the files in the directory if no index.html is present. ``` [path]: https://dart.dev/tools/pub/cmd/pub-global#running-a-script-from-your-path diff --git a/bin/dhttpd.dart b/bin/dhttpd.dart index ec62685..ba6820a 100644 --- a/bin/dhttpd.dart +++ b/bin/dhttpd.dart @@ -2,13 +2,14 @@ import 'dart:io'; import 'package:dhttpd/dhttpd.dart'; import 'package:dhttpd/src/options.dart'; +import 'package:dhttpd/src/utils.dart'; Future main(List args) async { final Options options; final Map headers; try { options = parseOptions(args); - headers = _parseKeyValuePairs(options.headers); + headers = parseKeyValuePairs(options.headers); } on FormatException catch (e) { stderr.writeln(e.message); print(usage); @@ -29,39 +30,8 @@ Future main(List args) async { sslCert: options.sslcert, sslKey: options.sslkey, sslPassword: options.sslkeypassword, + listFiles: options.listFiles, ); - print('Server started at ${httpd.urlBase}.'); -} - -Map _parseKeyValuePairs(List headerStrings) { - final headers = {}; - for (var headerString in headerStrings) { - for (var pair in headerString.split(';')) { - final trimmedPair = pair.trim(); - if (trimmedPair.isEmpty) { - continue; - } - - final index = trimmedPair.indexOf('='); - if (index == -1) { - throw FormatException( - 'Invalid header segment: "$trimmedPair". Expected "key=value".\n' - 'For values with semicolons, use a separate --headers flag ' - 'per header.', - ); - } - - final key = trimmedPair.substring(0, index).trim(); - if (key.isEmpty) { - throw FormatException( - 'Invalid header: "$trimmedPair". Key cannot be empty.', - ); - } - - final value = trimmedPair.substring(index + 1).trim(); - headers[key] = value; - } - } - return headers; + print('Serving ${httpd.path} at ${httpd.urlBase}'); } diff --git a/lib/dhttpd.dart b/lib/dhttpd.dart index a69179c..4447cb7 100644 --- a/lib/dhttpd.dart +++ b/lib/dhttpd.dart @@ -7,7 +7,7 @@ import 'package:shelf_static/shelf_static.dart'; import 'src/options.dart'; -class Dhttpd { +final class Dhttpd { final HttpServer _server; final String path; final SecurityContext? _securityContext; @@ -41,6 +41,7 @@ class Dhttpd { String? sslCert, String? sslKey, String? sslPassword, + bool listFiles = false, }) async { path ??= Directory.current.path; @@ -54,7 +55,13 @@ class Dhttpd { final pipeline = const Pipeline() .addMiddleware(logRequests()) .addMiddleware(_headersMiddleware(headers)) - .addHandler(createStaticHandler(path, defaultDocument: 'index.html')); + .addHandler( + createStaticHandler( + path, + defaultDocument: 'index.html', + listDirectories: listFiles, + ), + ); final server = await io.serve( pipeline, diff --git a/lib/src/options.dart b/lib/src/options.dart index f24ae4e..3fe00b9 100644 --- a/lib/src/options.dart +++ b/lib/src/options.dart @@ -13,7 +13,7 @@ class Options { abbr: 'p', valueHelp: 'port', defaultsTo: defaultPort, - help: 'The port to listen on.', + help: 'The port to listen on. Provide `0` to use a random port.', ) final int port; @@ -62,6 +62,13 @@ class Options { @CliOption(abbr: 'h', negatable: false, help: 'Displays the help.') final bool help; + @CliOption( + abbr: 'l', + negatable: false, + help: 'List the files in the directory if no index.html is present.', + ) + final bool listFiles; + Options({ required this.port, this.path, @@ -71,5 +78,6 @@ class Options { this.sslkey, this.sslkeypassword, required this.help, + this.listFiles = false, }); } diff --git a/lib/src/options.g.dart b/lib/src/options.g.dart index 7f93984..37792d6 100644 --- a/lib/src/options.g.dart +++ b/lib/src/options.g.dart @@ -27,13 +27,14 @@ Options _$parseOptionsResult(ArgResults result) => Options( sslkey: result['sslkey'] as String?, sslkeypassword: result['sslkeypassword'] as String?, help: result['help'] as bool, + listFiles: result['list-files'] as bool, ); ArgParser _$populateOptionsParser(ArgParser parser) => parser ..addOption( 'port', abbr: 'p', - help: 'The port to listen on.', + help: 'The port to listen on. Provide `0` to use a random port.', valueHelp: 'port', defaultsTo: '8080', ) @@ -69,7 +70,13 @@ ArgParser _$populateOptionsParser(ArgParser parser) => parser help: 'The password for the key of the SSL certificate to use.', valueHelp: 'sslkeypassword', ) - ..addFlag('help', abbr: 'h', help: 'Displays the help.', negatable: false); + ..addFlag('help', abbr: 'h', help: 'Displays the help.', negatable: false) + ..addFlag( + 'list-files', + abbr: 'l', + help: 'List the files in the directory if no index.html is present.', + negatable: false, + ); final _$parserForOptions = _$populateOptionsParser(ArgParser()); diff --git a/lib/src/utils.dart b/lib/src/utils.dart new file mode 100644 index 0000000..cd33ea9 --- /dev/null +++ b/lib/src/utils.dart @@ -0,0 +1,30 @@ +/// Parses string header segments like `Accept=text/html;X-Custom=Value` into a map. +Map parseKeyValuePairs(List headerStrings) { + final headers = {}; + for (var headerString in headerStrings) { + for (var pair in headerString.split(';')) { + final trimmedPair = pair.trim(); + if (trimmedPair.isEmpty) { + continue; + } + + final index = trimmedPair.indexOf('='); + if (index == -1) { + throw FormatException(''' +Invalid header segment: "$trimmedPair". Expected "key=value". +For values with semicolons, use a separate --headers flag per header.'''); + } + + final key = trimmedPair.substring(0, index).trim(); + if (key.isEmpty) { + throw FormatException( + 'Invalid header: "$trimmedPair". Key cannot be empty.', + ); + } + + final value = trimmedPair.substring(index + 1).trim(); + headers[key] = value; + } + } + return headers; +} diff --git a/pubspec.yaml b/pubspec.yaml index 4df722a..38b059d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: dhttpd -version: 4.2.0-wip +version: 4.2.0 description: A static HTTP file server for easy local hosting of a directory. repository: https://github.com/kevmoo/dhttpd diff --git a/test/command_test.dart b/test/command_test.dart index faeb012..a71af2c 100644 --- a/test/command_test.dart +++ b/test/command_test.dart @@ -9,10 +9,10 @@ import 'package:test_descriptor/test_descriptor.dart' as d; import 'package:test_process/test_process.dart'; void main() { - test('--help', () => _readmeCheck(['--help'])); - test('--port=8000', _outputCheck); - test('custom headers', _headersCheck); - test('invalid headers', _invalidHeadersCheck); + test('prints help', () => _readmeCheck(['--help'])); + test('serves on specified port', _outputCheck); + test('handles custom headers', _headersCheck); + test('rejects invalid headers', _invalidHeadersCheck); } Future _readmeCheck(List args) async { @@ -23,13 +23,18 @@ Future _readmeCheck(List args) async { final readme = File('README.md'); final command = ['dhttpd', ...args].join(' '); - final expected = '```console\n\$ $command\n$output\n```'; + final expected = + ''' +```console +\$ $command +$output +```'''; printOnFailure(expected); expect(expected, r'''```console $ dhttpd --help --p, --port= The port to listen on. +-p, --port= The port to listen on. Provide `0` to use a random port. (defaults to "8080") --path= The path to serve. If not set, the current directory is used. --headers= HTTP headers to apply to each response. Can be used multiple times. Format: header=value;header2=value @@ -39,6 +44,7 @@ $ dhttpd --help --sslkey= The key of the SSL certificate to use. Also requires sslcert --sslkeypassword= The password for the key of the SSL certificate to use. -h, --help Displays the help. +-l, --list-files List the files in the directory if no index.html is present. ```'''); expect(readme.readAsStringSync(), contains(expected)); @@ -61,7 +67,7 @@ Future _headersCheck() async { ' X-Spaced-Key = spaced-value ', ]); final line = await process.stdout.next; - expect(line, 'Server started at http://localhost:8001.'); + expect(line, 'Serving ${d.sandbox} at http://localhost:8001'); final response = await http.get(Uri.parse('http://localhost:8001')); expect(response.statusCode, 200); @@ -96,7 +102,7 @@ Future _outputCheck() async { final process = await _runApp(['--port=8000', '--path', d.sandbox]); final line = await process.stdout.next; - expect(line, 'Server started at http://localhost:8000.'); + expect(line, 'Serving ${d.sandbox} at http://localhost:8000'); final response = await http.get(Uri.parse('http://localhost:8000')); expect(response.statusCode, 200); diff --git a/test/dhttpd_test.dart b/test/dhttpd_test.dart index 096bbe0..72b09d2 100644 --- a/test/dhttpd_test.dart +++ b/test/dhttpd_test.dart @@ -44,6 +44,21 @@ void main() { expect(response.headers['x-test-header'], 'TestValue'); }); + test('list files', () async { + await d.dir('somelistdir', [d.file('file.txt', 'Content')]).create(); + + server = await Dhttpd.start( + path: '${d.sandbox}/somelistdir', + port: 0, + listFiles: true, + ); + + final response = await http.get(Uri.parse(server.urlBase)); + + expect(response.statusCode, HttpStatus.ok); + expect(response.body, contains('file.txt')); + }); + test('404 handling', () async { server = await Dhttpd.start(path: d.sandbox, port: 0); diff --git a/test/utils_test.dart b/test/utils_test.dart new file mode 100644 index 0000000..0552c22 --- /dev/null +++ b/test/utils_test.dart @@ -0,0 +1,79 @@ +import 'package:dhttpd/src/utils.dart'; +import 'package:test/test.dart'; + +void main() { + group('parseKeyValuePairs', () { + test('parses a single key-value pair', () { + expect(parseKeyValuePairs(['Key=Value']), equals({'Key': 'Value'})); + }); + + test('parses multiple pairs separated by semicolons', () { + expect( + parseKeyValuePairs(['Key1=Value1;Key2=Value2']), + equals({'Key1': 'Value1', 'Key2': 'Value2'}), + ); + }); + + test('parses multiple header strings', () { + expect( + parseKeyValuePairs(['Key1=Value1', 'Key2=Value2']), + equals({'Key1': 'Value1', 'Key2': 'Value2'}), + ); + }); + + test('trims whitespace around keys and values', () { + expect( + parseKeyValuePairs([' Key1 = Value1 ; Key2=Value2 ']), + equals({'Key1': 'Value1', 'Key2': 'Value2'}), + ); + }); + + test('ignores empty segments', () { + expect( + parseKeyValuePairs(['Key=Value;;;Key2=Value2']), + equals({'Key': 'Value', 'Key2': 'Value2'}), + ); + }); + + test('allows empty values', () { + expect(parseKeyValuePairs(['Key=']), equals({'Key': ''})); + expect( + parseKeyValuePairs(['Key= ; Key2=Value']), + equals({'Key': '', 'Key2': 'Value'}), + ); + }); + + test('handles values with equals signs', () { + expect( + parseKeyValuePairs(['Key=Value=With=Equals']), + equals({'Key': 'Value=With=Equals'}), + ); + }); + + test('throws FormatException if a segment lacks an equals sign', () { + expect( + () => parseKeyValuePairs(['KeyOnly']), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Expected "key=value"'), + ), + ), + ); + }); + + test('throws FormatException if key is empty', () { + expect( + () => parseKeyValuePairs(['=Value']), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Key cannot be empty'), + ), + ), + ); + }); + }); +}