diff --git a/CHANGELOG.md b/CHANGELOG.md index 6106f5c..bacba8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ - Added support for `https`. - Update minimum Dart SDK to `3.2.0`. +- Added `--headersfile` option to parse file-specific response headers from a file. +- Changed `--headers` option to support additional characters. ## 4.1.0 @@ -30,7 +32,7 @@ ## 3.0.0 * Set Dart SDK constraint to '>=2.0.0-dev.48.0 <3.0.0'. -* Removed top-level fields `DEFAULT_PORT` and `DEFAULT_HOST` from library. +* Removed top-level fields `DEFAULT_PORT` and `DEFAULT_HOST` from library. ## 2.0.0 diff --git a/README.md b/README.md index 3db8e90..b996182 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,44 @@ Server HTTPS started on port 8080 See the Dart documentation of [SecurityContext.usePrivateKey](https://api.dart.dev/stable/3.3.3/dart-io/SecurityContext/usePrivateKeyBytes.html) for more details. +### Headers + +It is possible to pass custom HTTP headers to the server. For simple use cases, pass headers with the `--headers` option, for example: + +```console +$ dhttpd --headers="header=value;header2=value" +``` + +For more complex scenarios, you can pass a file with HTTP header rules with the `--headersfile` option. The accepted formats are JSON files with the following structure: + +```json +"headers": [ { + "source": "**/*.@(eot|otf|ttf|ttc|woff|font.css)", + "headers": [ { + "key": "Access-Control-Allow-Origin", + "value": "*" + } ] +} ] +``` + +And plain text files with the following structure: + +``` +# This is a comment +/* + Cross-Origin-Embedder-Policy: credentialless + Cross-Origin-Opener-Policy: same-origin + +/secure/page + X-Frame-Options: DENY + X-Content-Type-Options: nosniff + Referrer-Policy: no-referrer + +/static/* + Access-Control-Allow-Origin: * + X-Robots-Tag: nosnippet +``` + ## Configure ```console @@ -58,6 +96,7 @@ $ dhttpd --help (defaults to "8080") --path= The path to serve. If not set, the current directory is used. --headers= HTTP headers to apply to each response. header=value;header2=value + --headersfile= File with HTTP header rules to apply to each response. --host= The hostname to listen on. (defaults to "localhost") --sslcert= The SSL certificate to use. Also requires sslkey diff --git a/analysis_options.yaml b/analysis_options.yaml index bd60bb4..981dc73 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -11,6 +11,7 @@ linter: rules: - avoid_private_typedef_functions - avoid_redundant_argument_values + - avoid_slow_async_io - avoid_unused_constructor_parameters - avoid_void_async - cancel_subscriptions diff --git a/bin/dhttpd.dart b/bin/dhttpd.dart index 8a4cf34..486b4eb 100644 --- a/bin/dhttpd.dart +++ b/bin/dhttpd.dart @@ -1,6 +1,8 @@ import 'dart:io'; import 'package:dhttpd/dhttpd.dart'; +import 'package:dhttpd/src/headers.dart'; +import 'package:dhttpd/src/headers_parser.dart'; import 'package:dhttpd/src/options.dart'; Future main(List args) async { @@ -22,8 +24,7 @@ Future main(List args) async { final httpd = await Dhttpd.start( path: options.path, port: options.port, - headers: - options.headers != null ? _parseKeyValuePairs(options.headers!) : null, + headers: _parseHeaders(options.headers, options.headersfile), address: options.host, sslCert: options.sslcert, sslKey: options.sslkey, @@ -33,9 +34,9 @@ Future main(List args) async { print('Server HTTP${httpd.isSSL ? 'S' : ''} started on port ${options.port}'); } -Map _parseKeyValuePairs(String str) => { - for (var match in _regex.allMatches(str)) - match.group(1)!: match.group(2)!, - }; - -final _regex = RegExp(r'([\w-]+)=([\w-]+)(;|$)'); +HeaderRuleSet _parseHeaders(String? headers, String? headersfile) { + final rules = []; + if (headers != null) rules.add(HeadersParser.parseString(headers)); + if (headersfile != null) rules.addAll(HeadersParser.parseFile(headersfile)); + return HeaderRuleSet(rules); +} diff --git a/lib/dhttpd.dart b/lib/dhttpd.dart index 12df784..3c8a010 100644 --- a/lib/dhttpd.dart +++ b/lib/dhttpd.dart @@ -5,6 +5,7 @@ import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart' as io; import 'package:shelf_static/shelf_static.dart'; +import 'src/headers.dart'; import 'src/options.dart'; class Dhttpd { @@ -36,7 +37,7 @@ class Dhttpd { String? path, int port = defaultPort, Object address = defaultHost, - Map? headers, + HeaderRuleSet? headers, String? sslCert, String? sslKey, String? sslPassword, @@ -63,14 +64,13 @@ class Dhttpd { Future destroy() => _server.close(); } -Middleware _headersMiddleware(Map? headers) => +Middleware _headersMiddleware(HeaderRuleSet? headers) => (Handler innerHandler) => (Request request) async { final response = await innerHandler(request); final responseHeaders = Map.from(response.headers); - if (headers != null) { - for (var entry in headers.entries) { - responseHeaders[entry.key] = entry.value; - } + final customHeaders = headers?.forFile(request.requestedUri.path); + if (customHeaders != null) { + responseHeaders.addAll(customHeaders.asMap()); } return response.change(headers: responseHeaders); }; diff --git a/lib/src/headers.dart b/lib/src/headers.dart new file mode 100644 index 0000000..b6334ee --- /dev/null +++ b/lib/src/headers.dart @@ -0,0 +1,55 @@ +import 'package:glob/glob.dart'; + +class HttpHeaders { + final Map _headers = {}; + + /// Stores a new header with the given [name] and [value]. If a header with + /// the same [name] already exists (case-insensitive), the new [value] is + /// concatenated with a comma to the existing header. + void add({required String name, required String value}) { + _headers.update( + name.toLowerCase(), + (existing) => '$existing, $value', + ifAbsent: () => value, + ); + } + + void addAll(HttpHeaders other) { + for (final entry in other._headers.entries) { + add(name: entry.key, value: entry.value); + } + } + + Map asMap() => _headers; +} + +/// A set of [headers] associated to a URL pattern. +class HeaderRule { + final HttpHeaders headers; + final Glob urlPattern; + + HeaderRule({ + required this.headers, + String urlPattern = '/**', // Match all URLs by default. + }) : urlPattern = Glob(urlPattern); + + /// Checks if a URL request [path] matches the URL pattern. + bool matches(String path) => urlPattern.matches(path); +} + +/// A set of [HeaderRule] with different URL patterns and headers. +class HeaderRuleSet { + final List rules; + + HeaderRuleSet(this.rules); + + /// Returns the headers of url patterns that match the [requestUrl] + HttpHeaders forFile(String requestUrl) { + final headers = HttpHeaders(); + final matchingRules = rules.where((rule) => rule.matches(requestUrl)); + for (final rule in matchingRules) { + headers.addAll(rule.headers); + } + return headers; + } +} diff --git a/lib/src/headers_parser.dart b/lib/src/headers_parser.dart new file mode 100644 index 0000000..a4e7461 --- /dev/null +++ b/lib/src/headers_parser.dart @@ -0,0 +1,140 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:path/path.dart' as p; +import 'headers.dart'; + +class HeadersParser { + static HeaderRule parseString(String str) { + final headers = HttpHeaders(); + for (final match in _regex.allMatches(str)) { + headers.add(name: match.group(1)!, value: match.group(2)!); + } + return HeaderRule(headers: headers); + } + + static List parseFile(String file) { + if (!_isFile(file)) throw ArgumentError('$file is not a file'); + + final ext = p.extension(file); + if (ext == '.json') return _JsonHeadersParser.parse(file); + if (ext == '.txt' || ext == '') return _TxtHeaderParser.parse(file); + throw ArgumentError('Invalid headers file: $file'); + } + + static bool _isFile(String str) => FileSystemEntity.isFileSync(str); + + static final _regex = RegExp(r'([\w-]+)=([^\s;]+)(;|$)'); +} + +/// Parses headers in JSON format. Each rule is a JSON object with a `source` +/// pattern and a list of `headers`. For example: +/// ```json +/// "headers": [ { +/// "source": "/**", +/// "headers": [ { +/// "key": "Access-Control-Allow-Origin", +/// "value": "*" +/// } ] +/// } ] +/// ``` +class _JsonHeadersParser { + static List parse(String file) { + final json = jsonDecode(File(file).readAsStringSync()); + final jsonHeaders = switch (json) { + {'hosting': {'headers': final List headers}} => headers, + {'headers': final List headers} => headers, + _ => throw FormatException('Invalid JSON headers file: $file') + }; + + return [ + for (final jsonRule in jsonHeaders) _parseRule(jsonRule) + ]; + } + + static HeaderRule _parseRule(dynamic jsonRule) => switch (jsonRule) { + { + 'source': final String source, + 'headers': final List headers, + } => + HeaderRule( + headers: _parseHeaders(headers), + urlPattern: _processUrlPattern(source), + ), + _ => throw FormatException('Invalid headers rule:\n$jsonRule') + }; + + static HttpHeaders _parseHeaders(List jsonHeaders) { + final headers = HttpHeaders(); + for (final header in jsonHeaders) { + switch (header) { + case {'key': final String key, 'value': final String value}: + headers.add(name: key, value: value); + default: + throw FormatException('Invalid header:\n$header'); + } + } + return headers; + } + + /// Replaces @(option1|option2|...|optionN) constructs with the corresponding + /// syntax of the Glob package: {option1,option2,...,optionN}. + static String _processUrlPattern(String urlPattern) { + final regex = RegExp(r'@\(([^)]+)\)'); + return urlPattern.replaceAllMapped( + regex, + (match) => '{${match.group(1)!.split('|').join(',')}}', + ); + } +} + +/// Parses headers in plain text format. Headers must be specified in blocks, +/// with each block starting with a URL pattern followed by an indented list +/// of headers. For example: +/// ``` +/// # This is a comment +/// /secure/page +/// X-Frame-Options: DENY +/// X-Content-Type-Options: nosniff +/// Referrer-Policy: no-referrer +/// +/// /static/* +/// Access-Control-Allow-Origin: * +/// X-Robots-Tag: nosnippet +/// ``` +class _TxtHeaderParser { + static final _ruleRegex = RegExp( + r'^([^\s#]+)$(?:\r\n|\r|\n)((?:[ \t]+.+$(?:\r\n|\r|\n)?)+)', + multiLine: true, + ); + static final _headerRegex = RegExp( + r'^[ \t]+([^#\s][\w-]+): (.+)$', + multiLine: true, + ); + + static List parse(String file) { + final content = File(file).readAsStringSync(); + return _parseRules(content); + } + + static List _parseRules(String str) => [ + for (final match in _ruleRegex.allMatches(str)) + HeaderRule( + headers: _parseHeaders(match.group(2)!), + urlPattern: _processPattern(match.group(1)!), + ), + ]; + + static HttpHeaders _parseHeaders(String str) { + final headers = HttpHeaders(); + for (final match in _headerRegex.allMatches(str)) { + headers.add(name: match.group(1)!, value: match.group(2)!); + } + return headers; + } + + /// Replaces the wildcard character `*` with `**` to match any number of + /// characters, including '/'. This is the behavior of Cloudflare Pages + /// and Netlify. + static String _processPattern(String pattern) => + pattern.replaceAll('*', '**'); +} diff --git a/lib/src/options.dart b/lib/src/options.dart index acf6154..0229a7c 100644 --- a/lib/src/options.dart +++ b/lib/src/options.dart @@ -28,6 +28,12 @@ class Options { 'HTTP headers to apply to each response. header=value;header2=value') final String? headers; + @CliOption( + valueHelp: 'headersfile', + help: + 'File with HTTP header rules to apply to each response.') + final String? headersfile; + @CliOption( defaultsTo: defaultHost, valueHelp: 'host', @@ -56,6 +62,7 @@ class Options { required this.port, this.path, this.headers, + this.headersfile, required this.host, this.sslcert, this.sslkey, diff --git a/lib/src/options.g.dart b/lib/src/options.g.dart index 7b96675..5cf429b 100644 --- a/lib/src/options.g.dart +++ b/lib/src/options.g.dart @@ -24,6 +24,7 @@ Options _$parseOptionsResult(ArgResults result) => Options( ), path: result['path'] as String?, headers: result['headers'] as String?, + headersfile: result['headersfile'] as String?, host: result['host'] as String, sslcert: result['sslcert'] as String?, sslkey: result['sslkey'] as String?, @@ -49,6 +50,11 @@ ArgParser _$populateOptionsParser(ArgParser parser) => parser help: 'HTTP headers to apply to each response. header=value;header2=value', valueHelp: 'headers', ) + ..addOption( + 'headersfile', + help: 'File with HTTP header rules to apply to each response.', + valueHelp: 'headersfile', + ) ..addOption( 'host', help: 'The hostname to listen on.', diff --git a/pubspec.yaml b/pubspec.yaml index daf4e67..f8c1f9f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,6 +10,8 @@ environment: dependencies: args: ^2.0.0 build_cli_annotations: ^2.0.0 + glob: ^2.1.0 + path: ^1.8.0 shelf: ^1.0.0 shelf_static: ^1.0.0 @@ -18,7 +20,6 @@ dev_dependencies: build_runner: ^2.0.0 build_verify: ^3.0.0 dart_flutter_team_lints: ^3.0.0 - path: ^1.8.0 test: ^1.16.6 test_process: ^2.0.0 diff --git a/sample/_headers b/sample/_headers new file mode 100644 index 0000000..b83add1 --- /dev/null +++ b/sample/_headers @@ -0,0 +1,14 @@ +# This is a comment +/* + Cross-Origin-Embedder-Policy: credentialless + Cross-Origin-Opener-Policy: same-origin + Cache-Control: max-age=0 + +/secure/page + X-Frame-Options: DENY + X-Content-Type-Options: nosniff + Referrer-Policy: no-referrer + +/public/*.html + Access-Control-Allow-Origin: * + cache-control: must-revalidate diff --git a/sample/headers.json b/sample/headers.json new file mode 100644 index 0000000..5c7da1d --- /dev/null +++ b/sample/headers.json @@ -0,0 +1,53 @@ +{ + "hosting": { + "headers": [ + { + "source": "/**", + "headers": [ + { + "key": "Cross-Origin-Embedder-Policy", + "value": "credentialless" + }, + { + "key": "Cross-Origin-Opener-Policy", + "value": "same-origin" + }, + { + "key": "Cache-Control", + "value": "max-age=0" + } + ] + }, + { + "source": "/secure/page", + "headers": [ + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Referrer-Policy", + "value": "no-referrer" + } + ] + }, + { + "source": "/public/page.@(html|css)", + "headers": [ + { + "key": "Access-Control-Allow-Origin", + "value": "*" + }, + { + "key": "cache-control", + "value": "must-revalidate" + } + ] + } + ] + } +} diff --git a/test/headers_test.dart b/test/headers_test.dart new file mode 100644 index 0000000..fcafd56 --- /dev/null +++ b/test/headers_test.dart @@ -0,0 +1,80 @@ +import 'package:dhttpd/src/headers.dart'; +import 'package:dhttpd/src/headers_parser.dart'; +import 'package:test/test.dart'; + +void main() { + const examplePath = '/public/page.html'; + const exampleTextFile = 'sample/_headers'; + const exampleJsonFile = 'sample/headers.json'; + + group('Parsing headers passed as a string', () { + test('(empty)', () { + final headerRule = HeadersParser.parseString(''); + final headers = HeaderRuleSet([headerRule]); + expect( + headers.forFile(examplePath).asMap(), + isEmpty, + ); + }); + + test('(one header)', () { + final headerRule = HeadersParser.parseString('Content-Type=text/plain'); + final headers = HeaderRuleSet([headerRule]); + expect( + headers.forFile(examplePath).asMap(), + {'content-type': 'text/plain'}, + ); + }); + + test('(two headers)', () { + final headerRule = HeadersParser.parseString( + 'Content-Type=text/plain;content-length=42'); + final headers = HeaderRuleSet([headerRule]); + expect( + headers.forFile(examplePath).asMap(), + { + 'content-type': 'text/plain', + 'content-length': '42', + }, + ); + }); + + test('(with bad formatting)', () { + final headerRule = HeadersParser.parseString( + 'Content-Type=text/plain;Content-Length: 42'); + final headers = HeaderRuleSet([headerRule]); + expect( + headers.forFile(examplePath).asMap(), + {'content-type': 'text/plain'}, + ); + }); + }); + + test('Parsing headers from a plain/text file', () { + final headerRules = HeadersParser.parseFile(exampleTextFile); + final headers = HeaderRuleSet(headerRules); + expect( + headers.forFile(examplePath).asMap(), + { + 'cross-origin-embedder-policy': 'credentialless', + 'cross-origin-opener-policy': 'same-origin', + 'access-control-allow-origin': '*', + 'cache-control': 'max-age=0, must-revalidate', + }, + ); + }); + + test('Parsing headers from a JSON file', () { + final headerRules = HeadersParser.parseFile(exampleJsonFile); + final headers = HeaderRuleSet(headerRules); + expect( + headers.forFile(examplePath).asMap(), + { + 'cross-origin-embedder-policy': 'credentialless', + 'cross-origin-opener-policy': 'same-origin', + 'access-control-allow-origin': '*', + 'cache-control': 'max-age=0, must-revalidate', + }, + ); + }); +} diff --git a/test/readme_test.dart b/test/readme_test.dart index ef2180c..def2da2 100644 --- a/test/readme_test.dart +++ b/test/readme_test.dart @@ -27,6 +27,7 @@ $ dhttpd --help (defaults to "8080") --path= The path to serve. If not set, the current directory is used. --headers= HTTP headers to apply to each response. header=value;header2=value + --headersfile= File with HTTP header rules to apply to each response. --host= The hostname to listen on. (defaults to "localhost") --sslcert= The SSL certificate to use. Also requires sslkey