From 5b1016cb5431c87f139f7b5ecfa40547453f4a32 Mon Sep 17 00:00:00 2001 From: Patrick Stewart Date: Fri, 21 Jun 2024 14:49:49 -0700 Subject: [PATCH] add: adding ports from the symfony project --- design.draft | 87 ++ packages/http/lib/src/foundation/cookie.dart | 255 +++++ .../http/lib/src/foundation/header_bag.dart | 204 ++++ .../http/lib/src/foundation/header_utils.dart | 264 +++++ .../http/lib/src/foundation/response.dart | 984 ++++++++++++++++++ .../src/foundation/response_header_bag.dart | 267 +++++ 6 files changed, 2061 insertions(+) create mode 100644 design.draft create mode 100644 packages/http/lib/src/foundation/cookie.dart create mode 100644 packages/http/lib/src/foundation/header_bag.dart create mode 100644 packages/http/lib/src/foundation/header_utils.dart create mode 100644 packages/http/lib/src/foundation/response.dart create mode 100644 packages/http/lib/src/foundation/response_header_bag.dart diff --git a/design.draft b/design.draft new file mode 100644 index 0000000..8a27c07 --- /dev/null +++ b/design.draft @@ -0,0 +1,87 @@ +fabric-platform/ +├── .devbox/ +│ ├ +├── .github/ +│ ├ +├── .vscode/ +│ ├ +├── config/ +│ ├ +├── docs/ +│ ├ +├── example/ +│ ├ +├── packages/ +│ ├── auth/ +│ ├── base/ +│ ├── broadcasting/ +│ ├── bus/ +│ ├── cache/ +│ ├── collections/ +│ ├── conditionable/ +│ ├── config/ +│ ├── console/ +│ │ ├── cli/ +│ ├── container/ +│ ├── contracts/ +│ ├── cookie/ +│ ├── database/ +│ │ ├── orm/ +│ ├── encryption/ +│ ├── events/ +│ ├── extensions/ +│ │ ├── kafka/ +│ │ ├── mongodb/ +│ │ ├── redis/ +│ │ ├── blockchain/ +│ │ └── other extensions/ +│ ├── filesystem/ +│ ├── foundation/ +│ ├── hashing/ +│ ├── http/ +│ ├── log/ +│ ├── macroable/ +│ ├── mail/ +│ ├── notifications/ +│ ├── pagination/ +│ ├── pipeline/ +│ ├── process/ +│ ├── queue/ +│ ├── routing/ +│ ├── rtc/ +│ │ ├── websocket/ +│ │ └── gRPC/ +│ ├── session/ +│ ├── support/ +│ ├── testing/ +│ ├── translation/ +│ ├── validation/ +│ ├── view/ +│ ├ +├── scripts/ +│ ├ +├── spike/ +│ ├ +├── stubs/ +│ ├ +├── test/ +│ ├ +├── tool/ +│ ├ +│ +├── .editorconfig +├── .gitattributes +├── .gitignore +├── .publishable +├── .toolversion +├── analysis_options.yaml +├── AUTHORS.md +├── CHANGELOG.md +├── CODE_OF_CONDUCT.md +├── CONTRIBUTING.md +├── LICENSE +├── melos.yaml +├── pubspec.yaml +├── README.md +├── SECURITY.md +└── VFP_VERSION diff --git a/packages/http/lib/src/foundation/cookie.dart b/packages/http/lib/src/foundation/cookie.dart new file mode 100644 index 0000000..f932429 --- /dev/null +++ b/packages/http/lib/src/foundation/cookie.dart @@ -0,0 +1,255 @@ +/* + * This file is part of the VieoFabric package. + * + * (c) Patrick Stewart + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'header_utils.dart'; +import 'dart:math'; + +/// Represents a cookie. +class Cookie { + static const String SAMESITE_NONE = 'none'; + static const String SAMESITE_LAX = 'lax'; + static const String SAMESITE_STRICT = 'strict'; + + late String name; + String? value; + String? domain; + late int expire; + late String path; + bool? secure; + late bool httpOnly; + + late bool raw; + String? sameSite; + late bool partitioned; + late bool secureDefault; + + static const String RESERVED_CHARS_LIST = "=,; \t\r\n\v\f"; + static const List RESERVED_CHARS_FROM = ['=', ',', ';', ' ', "\t", "\r", "\n", "\v", "\f"]; + static const List RESERVED_CHARS_TO = ['%3D', '%2C', '%3B', '%20', '%09', '%0D', '%0A', '%0B', '%0C']; + + /// Creates cookie from raw header string. +static Cookie fromString(String cookie, {bool decode = false}) { + final data = { + 'expires': 0, + 'path': '/', + 'domain': null, + 'secure': false, + 'httponly': false, + 'raw': !decode, + 'samesite': null, + 'partitioned': false, + }; + + final parts = HeaderUtils.split(cookie, ';='); + final part = parts.removeAt(0); + + final name = decode ? Uri.decodeComponent(part[0]) : part[0]; + final value = part.length > 1 ? (decode ? Uri.decodeComponent(part[1]) : part[1]) : null; + + data.addAll(HeaderUtils.combine(parts)); + data['expires'] = _expiresTimestamp(data['expires']); + + if (data.containsKey('max-age') && data['max-age'] != null && data['expires'] != null) { + if ((data['max-age'] as int) > 0 || (data['expires'] as int) > DateTime.now().millisecondsSinceEpoch) { + data['expires'] = DateTime.now().millisecondsSinceEpoch + (data['max-age'] as int); + } + } + + return Cookie._internal( + name, + value, + data['expires'] as int, + data['path'] as String, + data['domain'] as String?, + data['secure'] as bool, + data['httponly'] as bool, + data['raw'] as bool, + data['samesite'] as String?, + data['partitioned'] as bool, + ); +} + + /// Factory constructor for creating a new cookie. + factory Cookie.create(String name, {String? value, dynamic expire = 0, String? path = '/', String? domain, bool? secure, bool httpOnly = true, bool raw = false, String? sameSite = SAMESITE_LAX, bool partitioned = false}) { + return Cookie._internal(name, value, expire, path, domain, secure, httpOnly, raw, sameSite, partitioned); + } + + Cookie._internal(this.name, this.value, dynamic expire, String? path, this.domain, this.secure, this.httpOnly, this.raw, this.sameSite, this.partitioned) { + if (raw && name.contains(RegExp(r'[' + RESERVED_CHARS_LIST + r']'))) { + throw ArgumentError('The cookie name "$name" contains invalid characters.'); + } + + if (name.isEmpty) { + throw ArgumentError('The cookie name cannot be empty.'); + } + + this.expire = _expiresTimestamp(expire); + this.path = path ?? '/'; + this.secureDefault = false; + } + + /// Creates a cookie copy with a new value. + Cookie withValue(String? value) { + return Cookie._internal(name, value, expire, path, domain, secure, httpOnly, raw, sameSite, partitioned); + } + + /// Creates a cookie copy with a new domain that the cookie is available to. + Cookie withDomain(String? domain) { + return Cookie._internal(name, value, expire, path, domain, secure, httpOnly, raw, sameSite, partitioned); + } + + /// Creates a cookie copy with a new time the cookie expires. + Cookie withExpires(dynamic expire) { + return Cookie._internal(name, value, _expiresTimestamp(expire), path, domain, secure, httpOnly, raw, sameSite, partitioned); + } + + /// Converts expires formats to a unix timestamp. + static int _expiresTimestamp(dynamic expire) { + if (expire is DateTime) { + return expire.millisecondsSinceEpoch ~/ 1000; + } else if (expire is int) { + return expire; + } else if (expire is String) { + return DateTime.parse(expire).millisecondsSinceEpoch ~/ 1000; + } else { + throw ArgumentError('The cookie expiration time is not valid.'); + } + } + + /// Creates a cookie copy with a new path on the server in which the cookie will be available on. + Cookie withPath(String path) { + return Cookie._internal(name, value, expire, path.isEmpty ? '/' : path, domain, secure, httpOnly, raw, sameSite, partitioned); + } + + /// Creates a cookie copy that only be transmitted over a secure HTTPS connection from the client. + Cookie withSecure(bool secure) { + return Cookie._internal(name, value, expire, path, domain, secure, httpOnly, raw, sameSite, partitioned); + } + + /// Creates a cookie copy that be accessible only through the HTTP protocol. + Cookie withHttpOnly(bool httpOnly) { + return Cookie._internal(name, value, expire, path, domain, secure, httpOnly, raw, sameSite, partitioned); + } + + /// Creates a cookie copy that uses no url encoding. + Cookie withRaw(bool raw) { + if (raw && name.contains(RegExp(r'[' + RESERVED_CHARS_LIST + r']'))) { + throw ArgumentError('The cookie name "$name" contains invalid characters.'); + } + return Cookie._internal(name, value, expire, path, domain, secure, httpOnly, raw, sameSite, partitioned); + } + + /// Creates a cookie copy with SameSite attribute. + Cookie withSameSite(String? sameSite) { + final validSameSite = [SAMESITE_LAX, SAMESITE_STRICT, SAMESITE_NONE, null]; + if (!validSameSite.contains(sameSite?.toLowerCase())) { + throw ArgumentError('The "sameSite" parameter value is not valid.'); + } + return Cookie._internal(name, value, expire, path, domain, secure, httpOnly, raw, sameSite?.toLowerCase(), partitioned); + } + + /// Creates a cookie copy that is tied to the top-level site in cross-site context. + Cookie withPartitioned(bool partitioned) { + return Cookie._internal(name, value, expire, path, domain, secure, httpOnly, raw, sameSite, partitioned); + } + + /// Returns the cookie as a string. + @override + String toString() { + final buffer = StringBuffer(); + + if (raw) { + buffer.write(name); + } else { + buffer.write(Uri.encodeComponent(name)); + } + + buffer.write('='); + + if (value == null || value!.isEmpty) { + buffer.write('deleted; expires=${DateTime.fromMillisecondsSinceEpoch(DateTime.now().millisecondsSinceEpoch - 31536001).toUtc().toIso8601String()}; Max-Age=0'); + } else { + buffer.write(raw ? value : Uri.encodeComponent(value!)); + + if (expire != 0) { + buffer.write('; expires=${DateTime.fromMillisecondsSinceEpoch(expire * 1000).toUtc().toIso8601String()}; Max-Age=${getMaxAge()}'); + } + } + + if (path.isNotEmpty) { + buffer.write('; path=$path'); + } + + if (domain != null && domain!.isNotEmpty) { + buffer.write('; domain=$domain'); + } + + if (isSecure()) { + buffer.write('; secure'); + } + + if (httpOnly) { + buffer.write('; httponly'); + } + + if (sameSite != null) { + buffer.write('; samesite=$sameSite'); + } + + if (partitioned) { + buffer.write('; partitioned'); + } + + return buffer.toString(); + } + + /// Gets the name of the cookie. + String getName() => name; + + /// Gets the value of the cookie. + String? getValue() => value; + + /// Gets the domain that the cookie is available to. + String? getDomain() => domain; + + /// Gets the time the cookie expires. + int getExpiresTime() => expire; + + /// Gets the max-age attribute. + int getMaxAge() { + final maxAge = expire - (DateTime.now().millisecondsSinceEpoch ~/ 1000); + return max(0, maxAge); + } + + /// Gets the path on the server in which the cookie will be available on. + String getPath() => path; + + /// Checks whether the cookie should only be transmitted over a secure HTTPS connection from the client. + bool isSecure() => secure ?? secureDefault; + + /// Checks whether the cookie will be made accessible only through the HTTP protocol. + bool isHttpOnly() => httpOnly; + + /// Whether this cookie is about to be cleared. + bool isCleared() => expire != 0 && expire < (DateTime.now().millisecondsSinceEpoch ~/ 1000); + + /// Checks if the cookie value should be sent with no url encoding. + bool isRaw() => raw; + + /// Checks whether the cookie should be tied to the top-level site in cross-site context. + bool isPartitioned() => partitioned; + + /// Gets the SameSite attribute of the cookie. + String? getSameSite() => sameSite; + + /// Sets the default value of the "secure" flag when it is set to null. + void setSecureDefault(bool defaultSecure) { + secureDefault = defaultSecure; + } +} diff --git a/packages/http/lib/src/foundation/header_bag.dart b/packages/http/lib/src/foundation/header_bag.dart new file mode 100644 index 0000000..2ebbafc --- /dev/null +++ b/packages/http/lib/src/foundation/header_bag.dart @@ -0,0 +1,204 @@ +import 'dart:collection'; + +/// HeaderBag is a container for HTTP headers. +/// +/// Author: Fabien Potencier +/// This file is part of the Symfony package. +class HeaderBag extends IterableBase>> { + static const String upper = '_ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + static const String lower = '-abcdefghijklmnopqrstuvwxyz'; + + /// A map to store the headers. + final Map> _headers = {}; + + /// A map to store cache control directives. + final Map _cacheControl = {}; + + /// Constructor for HeaderBag + HeaderBag([Map> headers = const {}]) { + headers.forEach((key, values) { + set(key, values); + }); + } + + /// Returns the headers as a string. + @override + String toString() { + if (_headers.isEmpty) { + return ''; + } + + var sortedHeaders = SplayTreeMap>.from(_headers); + var max = sortedHeaders.keys.map((k) => k.length).reduce((a, b) => a > b ? a : b) + 1; + var content = StringBuffer(); + + for (var entry in sortedHeaders.entries) { + var name = entry.key.replaceAllMapped(RegExp(r'-([a-z])'), (match) => '-${match.group(1)!.toUpperCase()}'); + for (var value in entry.value) { + content.write('${name.padRight(max)}: $value\r\n'); + } + } + + return content.toString(); + } + + /// Returns the headers. + /// + /// @param key The name of the headers to return or null to get them all + /// + /// @return A map of headers. + Map> all([String? key]) { + if (key != null) { + return {key.toLowerCase(): _headers[key.toLowerCase()] ?? []}; + } + return _headers; + } + + /// Returns the parameter keys. + /// + /// @return A list of keys. + List keys() { + return _headers.keys.toList(); + } + + /// Replaces the current HTTP headers by a new set. + void replace(Map> headers) { + _headers.clear(); + add(headers); + } + + /// Adds new headers to the current HTTP headers set. + void add(Map> headers) { + headers.forEach((key, values) { + set(key, values); + }); + } + + /// Returns the first header by name or the default one. + String? get(String key, [String? defaultValue]) { + var headers = all(key)[key.toLowerCase()]; + if (headers == null || headers.isEmpty) { + return defaultValue; + } + return headers[0]; + } + + /// Sets a header by name. + /// + /// @param values The value or an array of values + /// @param replace Whether to replace the actual value or not (true by default) + void set(String key, dynamic values, [bool replace = true]) { + key = key.toLowerCase(); + List valueList; + + if (values is List) { + valueList = List.from(values); + if (replace || !_headers.containsKey(key)) { + _headers[key] = valueList; + } else { + _headers[key] = List.from(_headers[key]!)..addAll(valueList); + } + } else { + if (replace || !_headers.containsKey(key)) { + _headers[key] = [values]; + } else { + _headers[key]!.add(values); + } + } + + if (key == 'cache-control') { + _cacheControl.addAll(_parseCacheControl(_headers[key]!.join(', '))); + } + } + + /// Returns true if the HTTP header is defined. + bool hasHeader(String key) { + return _headers.containsKey(key.toLowerCase()); + } + + /// Returns true if the given HTTP header contains the given value. + bool containsHeaderValue(String key, String value) { + return _headers[key.toLowerCase()]?.contains(value) ?? false; + } + + /// Removes a header. + void remove(String key) { + key = key.toLowerCase(); + _headers.remove(key); + if (key == 'cache-control') { + _cacheControl.clear(); + } + } + + /// Returns the HTTP header value converted to a date. + /// + /// Throws an exception when the HTTP header is not parseable. + DateTime? getDate(String key, [DateTime? defaultValue]) { + var value = get(key); + if (value == null) { + return defaultValue; + } + + try { + return DateTime.parse(value); + } catch (e) { + throw Exception('The "$key" HTTP header is not parseable ($value).'); + } + } + + /// Adds a custom Cache-Control directive. + void addCacheControlDirective(String key, [dynamic value = true]) { + _cacheControl[key] = value; + set('Cache-Control', getCacheControlHeader()); + } + + /// Returns true if the Cache-Control directive is defined. + bool hasCacheControlDirective(String key) { + return _cacheControl.containsKey(key); + } + + /// Returns a Cache-Control directive value by name. + dynamic getCacheControlDirective(String key) { + return _cacheControl[key]; + } + + /// Removes a Cache-Control directive. + void removeCacheControlDirective(String key) { + _cacheControl.remove(key); + set('Cache-Control', getCacheControlHeader()); + } + + /// Returns an iterator for headers. + /// + /// @return An iterator of MapEntry. + @override + Iterator>> get iterator { + return _headers.entries.iterator; + } + + /// Returns the number of headers. + @override + int get length { + return _headers.length; + } + + /// Generates the Cache-Control header value. + /// + /// @return A string representation of the Cache-Control header. + String getCacheControlHeader() { + var sortedCacheControl = SplayTreeMap.from(_cacheControl); + return sortedCacheControl.entries.map((e) => '${e.key}=${e.value}').join(', '); + } + + /// Parses a Cache-Control HTTP header. + /// + /// @return A map of Cache-Control directives. + Map _parseCacheControl(String header) { + var parts = header.split(',').map((e) => e.split('=')).toList(); + var map = {}; + for (var part in parts) { + map[part[0].trim()] = part.length > 1 ? part[1].trim() : true; + } + return map; + } +} diff --git a/packages/http/lib/src/foundation/header_utils.dart b/packages/http/lib/src/foundation/header_utils.dart new file mode 100644 index 0000000..2bec390 --- /dev/null +++ b/packages/http/lib/src/foundation/header_utils.dart @@ -0,0 +1,264 @@ +import 'dart:convert'; + +class HeaderUtils { + static const String DISPOSITION_ATTACHMENT = 'attachment'; + static const String DISPOSITION_INLINE = 'inline'; + + // This class should not be instantiated. + HeaderUtils._(); + + /// Splits an HTTP header by one or more separators. + /// + /// Example: + /// + /// HeaderUtils.split('da, en-gb;q=0.8', ',;') + /// // => [['da'], ['en-gb', 'q=0.8']] + /// + /// @param String separators List of characters to split on, ordered by + /// precedence, e.g. ',', ';=', or ',;=' + /// + /// @return List> Nested array with as many levels as there are characters in + /// separators + static List> split(String header, String separators) { + if (separators.isEmpty) { + throw ArgumentError('At least one separator must be specified.'); + } + + final quotedSeparators = RegExp.escape(separators); + + final pattern = ''' + (?!\\s) + (?: + # quoted-string + "(?:[^"\\\\]|\\\\.)*(?:"|\\\\|)" + | + # token + [^"$quotedSeparators]+ + )+ + (?[$quotedSeparators]) + \\s* + '''; + + final matches = RegExp(pattern, multiLine: true, dotAll: true, caseSensitive: false) + .allMatches(header.trim()) + .toList(); + + return _groupParts(matches, separators); + } + + /// Combines an array of arrays into one associative array. + /// + /// Each of the nested arrays should have one or two elements. The first + /// value will be used as the keys in the associative array, and the second + /// will be used as the values, or true if the nested array only contains one + /// element. Array keys are lowercased. + /// + /// Example: + /// + /// HeaderUtils.combine([['foo', 'abc'], ['bar']]) + /// // => {'foo': 'abc', 'bar': true} + static Map combine(List> parts) { + final assoc = {}; + for (var part in parts) { + final name = part[0].toLowerCase(); + final value = part.length > 1 ? part[1] : true; + assoc[name] = value; + } + return assoc; + } + + /// Joins an associative array into a string for use in an HTTP header. + /// + /// The key and value of each entry are joined with '=', and all entries + /// are joined with the specified separator and an additional space (for + /// readability). Values are quoted if necessary. + /// + /// Example: + /// + /// HeaderUtils.headerToString({'foo': 'abc', 'bar': true, 'baz': 'a b c'}, ',') + /// // => 'foo=abc, bar, baz="a b c"' + static String headerToString(Map assoc, String separator) { + final parts = []; + assoc.forEach((name, value) { + if (value == true) { + parts.add(name); + } else { + parts.add('$name=${quote(value.toString())}'); + } + }); + return parts.join('$separator '); + } + + /// Encodes a string as a quoted string, if necessary. + /// + /// If a string contains characters not allowed by the "token" construct in + /// the HTTP specification, it is backslash-escaped and enclosed in quotes + /// to match the "quoted-string" construct. + static String quote(String? s) { + if (s == null) { + throw ArgumentError('Input string cannot be null'); + } + + if (s.isEmpty) { + return '""'; + } + + final isQuotingAllowed = _isQuotingAllowed(s); + + if (!isQuotingAllowed) { + return '"${s.replaceAll('"', '\\"')}"'; + } + + return s; +} + +static bool _isQuotingAllowed(String s) { + final pattern = RegExp('^[a-zA-Z0-9!#\$%&\'*+\\-\\.^_`|~]+\$'); + return pattern.hasMatch(s); +} + + /// Decodes a quoted string. + /// + /// If passed an unquoted string that matches the "token" construct (as + /// defined in the HTTP specification), it is passed through verbatim. + static String unquote(String s) { + return s.replaceAllMapped(RegExp(r'\\(.)|\"'), (match) => match[1] ?? ''); + } + + /// Generates an HTTP Content-Disposition field-value. + /// + /// @param String disposition One of "inline" or "attachment" + /// @param String filename A unicode string + /// @param String filenameFallback A string containing only ASCII characters that + /// is semantically equivalent to filename. If the filename is already ASCII, + /// it can be omitted, or just copied from filename + /// + /// @throws ArgumentError + /// + /// @see RFC 6266 + static String makeDisposition(String disposition, String filename, [String filenameFallback = '']) { + if (![DISPOSITION_ATTACHMENT, DISPOSITION_INLINE].contains(disposition)) { + throw ArgumentError('The disposition must be either "$DISPOSITION_ATTACHMENT" or "$DISPOSITION_INLINE".'); + } + + filenameFallback = filenameFallback.isEmpty ? filename : filenameFallback; + + if (!RegExp(r'^[\x20-\x7e]*$').hasMatch(filenameFallback)) { + throw ArgumentError('The filename fallback must only contain ASCII characters.'); + } + + if (filenameFallback.contains('%')) { + throw ArgumentError('The filename fallback cannot contain the "%" character.'); + } + + if (filename.contains('/') || filename.contains('\\') || filenameFallback.contains('/') || filenameFallback.contains('\\')) { + throw ArgumentError('The filename and the fallback cannot contain the "/" and "\\" characters.'); + } + + final params = {'filename': filenameFallback}; + if (filename != filenameFallback) { + params['filename*'] = "utf-8''${Uri.encodeComponent(filename)}"; + } + + return '$disposition; ${headerToString(params, ';')}'; + } + +/// Like parse_str(), but preserves dots in variable names. +static Map parseQuery(String query, [bool ignoreBrackets = false, String separator = '&']) { + final result = {}; + + if (ignoreBrackets) { + for (var item in query.split(separator)) { + var parts = item.split('='); + result[parts[0]] = Uri.decodeComponent(parts[1]); + } + return result; + } + + for (var v in query.split(separator)) { + var i = v.indexOf('0'); + if (i != -1) { + v = v.substring(0, i); + } + + i = v.indexOf('='); + String k; + if (i == -1) { + k = Uri.decodeComponent(v); + v = ''; + } else { + k = Uri.decodeComponent(v.substring(0, i)); + v = v.substring(i + 1); + } + + i = k.indexOf('0'); + if (i != -1) { + k = k.substring(0, i); + } + + k = k.trimLeft(); + + i = k.indexOf('['); + if (i == -1) { + result[utf8.decode(base64.decode(k))] = Uri.decodeComponent(v); + } else { + result['${utf8.decode(base64.decode(k.substring(0, i)))}[${Uri.decodeComponent(k.substring(i + 1))}]'] = Uri.decodeComponent(v); + } + } + + return result; +} + + + static List> _groupParts(List matches, String separators, [bool first = true]) { + final separator = separators[0]; + separators = separators.substring(1); + var i = 0; + + if (separators.isEmpty && !first) { + final parts = ['']; + + for (var match in matches) { + if (i == 0 && match.namedGroup('separator') != null) { + i = 1; + parts.add(''); + } else { + parts[i] += unquote(match[0]!); + } + } + + return [parts]; + } + + final parts = >[]; + final partMatches = >{}; + + for (var match in matches) { + if (match.namedGroup('separator') == separator) { + i++; + } else { + partMatches.putIfAbsent(i, () => []).add(match); + } + } + + for (var subMatches in partMatches.values) { + if (separators.isEmpty) { + final unquoted = unquote(subMatches[0][0]!); + if (unquoted.isNotEmpty) { + parts.add([unquoted]); + } + } else { + final groupedParts = _groupParts(subMatches, separators, false); + if (groupedParts.isNotEmpty) { + parts.add(groupedParts.expand((element) => element).toList()); + } + } + } + + return parts; + } +} diff --git a/packages/http/lib/src/foundation/response.dart b/packages/http/lib/src/foundation/response.dart new file mode 100644 index 0000000..043db94 --- /dev/null +++ b/packages/http/lib/src/foundation/response.dart @@ -0,0 +1,984 @@ +import 'dart:io'; +import 'response_header_bag.dart'; + +class Response { + // HTTP status codes + static const int HTTP_CONTINUE = 100; + static const int HTTP_SWITCHING_PROTOCOLS = 101; + static const int HTTP_PROCESSING = 102; // RFC2518 + static const int HTTP_EARLY_HINTS = 103; // RFC8297 + static const int HTTP_OK = 200; + static const int HTTP_CREATED = 201; + static const int HTTP_ACCEPTED = 202; + static const int HTTP_NON_AUTHORITATIVE_INFORMATION = 203; + static const int HTTP_NO_CONTENT = 204; + static const int HTTP_RESET_CONTENT = 205; + static const int HTTP_PARTIAL_CONTENT = 206; + static const int HTTP_MULTI_STATUS = 207; // RFC4918 + static const int HTTP_ALREADY_REPORTED = 208; // RFC5842 + static const int HTTP_IM_USED = 226; // RFC3229 + static const int HTTP_MULTIPLE_CHOICES = 300; + static const int HTTP_MOVED_PERMANENTLY = 301; + static const int HTTP_FOUND = 302; + static const int HTTP_SEE_OTHER = 303; + static const int HTTP_NOT_MODIFIED = 304; + static const int HTTP_USE_PROXY = 305; + static const int HTTP_RESERVED = 306; + static const int HTTP_TEMPORARY_REDIRECT = 307; + static const int HTTP_PERMANENTLY_REDIRECT = 308; // RFC7238 + static const int HTTP_BAD_REQUEST = 400; + static const int HTTP_UNAUTHORIZED = 401; + static const int HTTP_PAYMENT_REQUIRED = 402; + static const int HTTP_FORBIDDEN = 403; + static const int HTTP_NOT_FOUND = 404; + static const int HTTP_METHOD_NOT_ALLOWED = 405; + static const int HTTP_NOT_ACCEPTABLE = 406; + static const int HTTP_PROXY_AUTHENTICATION_REQUIRED = 407; + static const int HTTP_REQUEST_TIMEOUT = 408; + static const int HTTP_CONFLICT = 409; + static const int HTTP_GONE = 410; + static const int HTTP_LENGTH_REQUIRED = 411; + static const int HTTP_PRECONDITION_FAILED = 412; + static const int HTTP_REQUEST_ENTITY_TOO_LARGE = 413; + static const int HTTP_REQUEST_URI_TOO_LONG = 414; + static const int HTTP_UNSUPPORTED_MEDIA_TYPE = 415; + static const int HTTP_REQUESTED_RANGE_NOT_SATISFIABLE = 416; + static const int HTTP_EXPECTATION_FAILED = 417; + static const int HTTP_I_AM_A_TEAPOT = 418; // RFC2324 + static const int HTTP_MISDIRECTED_REQUEST = 421; // RFC7540 + static const int HTTP_UNPROCESSABLE_ENTITY = 422; // RFC4918 + static const int HTTP_LOCKED = 423; // RFC4918 + static const int HTTP_FAILED_DEPENDENCY = 424; // RFC4918 + static const int HTTP_TOO_EARLY = 425; // RFC-ietf-httpbis-replay-04 + static const int HTTP_UPGRADE_REQUIRED = 426; // RFC2817 + static const int HTTP_PRECONDITION_REQUIRED = 428; // RFC6585 + static const int HTTP_TOO_MANY_REQUESTS = 429; // RFC6585 + static const int HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE = 431; // RFC6585 + static const int HTTP_UNAVAILABLE_FOR_LEGAL_REASONS = 451; // RFC7725 + static const int HTTP_INTERNAL_SERVER_ERROR = 500; + static const int HTTP_NOT_IMPLEMENTED = 501; + static const int HTTP_BAD_GATEWAY = 502; + static const int HTTP_SERVICE_UNAVAILABLE = 503; + static const int HTTP_GATEWAY_TIMEOUT = 504; + static const int HTTP_VERSION_NOT_SUPPORTED = 505; + static const int HTTP_VARIANT_ALSO_NEGOTIATES_EXPERIMENTAL = 506; // RFC2295 + static const int HTTP_INSUFFICIENT_STORAGE = 507; // RFC4918 + static const int HTTP_LOOP_DETECTED = 508; // RFC5842 + static const int HTTP_NOT_EXTENDED = 510; // RFC2774 + static const int HTTP_NETWORK_AUTHENTICATION_REQUIRED = 511; // RFC6585 + + /// Status codes translation table. + /// + /// The list of codes is complete according to the + /// {@link https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml Hypertext Transfer Protocol (HTTP) Status Code Registry} + /// (last updated 2021-10-01). + /// + /// Unless otherwise noted, the status code is defined in RFC2616. + static const Map statusTexts = { + 100: 'Continue', + 101: 'Switching Protocols', + 102: 'Processing', // RFC2518 + 103: 'Early Hints', + 200: 'OK', + 201: 'Created', + 202: 'Accepted', + 203: 'Non-Authoritative Information', + 204: 'No Content', + 205: 'Reset Content', + 206: 'Partial Content', + 207: 'Multi-Status', // RFC4918 + 208: 'Already Reported', // RFC5842 + 226: 'IM Used', // RFC3229 + 300: 'Multiple Choices', + 301: 'Moved Permanently', + 302: 'Found', + 303: 'See Other', + 304: 'Not Modified', + 305: 'Use Proxy', + 307: 'Temporary Redirect', + 308: 'Permanent Redirect', // RFC7238 + 400: 'Bad Request', + 401: 'Unauthorized', + 402: 'Payment Required', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 406: 'Not Acceptable', + 407: 'Proxy Authentication Required', + 408: 'Request Timeout', + 409: 'Conflict', + 410: 'Gone', + 411: 'Length Required', + 412: 'Precondition Failed', + 413: 'Content Too Large', // RFC-ietf-httpbis-semantics + 414: 'URI Too Long', + 415: 'Unsupported Media Type', + 416: 'Range Not Satisfiable', + 417: 'Expectation Failed', + 418: "I'm a teapot", // RFC2324 + 421: 'Misdirected Request', // RFC7540 + 422: 'Unprocessable Content', // RFC-ietf-httpbis-semantics + 423: 'Locked', // RFC4918 + 424: 'Failed Dependency', // RFC4918 + 425: 'Too Early', // RFC-ietf-httpbis-replay-04 + 426: 'Upgrade Required', // RFC2817 + 428: 'Precondition Required', // RFC6585 + 429: 'Too Many Requests', // RFC6585 + 431: 'Request Header Fields Too Large', // RFC6585 + 451: 'Unavailable For Legal Reasons', // RFC7725 + 500: 'Internal Server Error', + 501: 'Not Implemented', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + 505: 'HTTP Version Not Supported', + 506: 'Variant Also Negotiates', // RFC2295 + 507: 'Insufficient Storage', // RFC4918 + 508: 'Loop Detected', // RFC5842 + 510: 'Not Extended', // RFC2774 + 511: 'Network Authentication Required', // RFC6585 + }; + + /// HTTP response cache control directives + /// + /// @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control + static const Map httpResponseCacheControlDirectives = { + 'must_revalidate': false, + 'no_cache': false, + 'no_store': false, + 'no_transform': false, + 'public': false, + 'private': false, + 'proxy_revalidate': false, + 'max_age': true, + 's_maxage': true, + 'stale_if_error': true, // RFC5861 + 'stale_while_revalidate': true, // RFC5861 + 'immutable': false, + 'last_modified': true, + 'etag': true, + }; + + ResponseHeaderBag headers; + String content; + String version; + int statusCode; + String statusText; + String? charset; + + Map> sentHeaders = {}; + + /// @param int $status The HTTP status code (200 "OK" by default) + /// + /// @throws \InvalidArgumentException When the HTTP status code is not valid + Response({String? content = '', int status = HTTP_OK, Map>? headers}) + : headers = ResponseHeaderBag(headers ?? {}), + content = content ?? '', + version = '1.0', + statusCode = status, + statusText = statusTexts[status] ?? 'unknown status'; + + /// Returns the Response as an HTTP string. + /// + /// The string representation of the Response is the same as the + /// one that will be sent to the client only if the prepare() method + /// has been called before. + /// + /// @see prepare() + @override + String toString() { + return 'HTTP/$version $statusCode $statusText\r\n$headers\r\n$content'; + } + + /// Clones the current Response instance. + Response clone() { + return Response(content: content, status: statusCode, headers: headers.all()); + } + + /// Prepares the Response before it is sent to the client. + /// + /// This method tweaks the Response to ensure that it is + /// compliant with RFC 2616. Most of the changes are based on + /// the Request that is "associated" with this Response. + /// + /// @return $this + void prepare(HttpRequest request) { + if (isInformational() || isEmpty()) { + content = ''; + headers.remove('Content-Type'); + headers.remove('Content-Length'); + } else { + if (!headers.containsKey('Content-Type')) { + headers.set('Content-Type', 'text/html; charset=${charset ?? 'UTF-8'}'); + } else if (headers.value('Content-Type')!.startsWith('text/') && !headers.value('Content-Type')!.contains('charset')) { + headers.set('Content-Type', '${headers.value('Content-Type')}; charset=${charset ?? 'UTF-8'}'); + } + + if (headers.containsKey('Transfer-Encoding')) { + headers.remove('Content-Length'); + } + + if (request.method == 'HEAD') { + var length = headers.value('Content-Length'); + content = ''; + if (length != null) { + headers.set('Content-Length', length); + } + } + } + + if (request.protocolVersion != 'HTTP/1.0') { + version = '1.1'; + } + + if (version == '1.0' && headers.value('Cache-Control')!.contains('no-cache')) { + headers.set('pragma', 'no-cache'); + headers.set('expires', '-1'); + } + + ensureIEOverSSLCompatibility(request); + + if (request.uri.scheme == 'https') { + headers.all('Set-Cookie')['set-cookie']?.forEach((cookie) { + if (cookie.contains('; Secure')) return; + headers.set('Set-Cookie', '$cookie; Secure', false); + }); + } + } + + /// Sends HTTP headers. + /// + /// @param positive-int|null $statusCode The status code to use, override the statusCode property if set and not null + /// + /// @return $this + void sendHeaders([int? statusCode]) { + if (HttpHeaders.headersSent) { + return; + } + + var informationalResponse = statusCode != null && statusCode >= 100 && statusCode < 200; + + headers.allPreserveCaseWithoutCookies().forEach((name, values) { + var previousValues = sentHeaders[name]; + if (previousValues != null && previousValues == values) { + return; + } + + var replace = name.toLowerCase() == 'content-type'; + if (previousValues != null && !previousValues.every(values.contains)) { + HttpHeaders.removeHeader(name); + previousValues = null; + } + + var newValues = previousValues == null ? values : values.where((v) => !previousValues!.contains(v)).toList(); + + for (var value in newValues) { + HttpHeaders.setHeader(name, value, replace: replace, statusCode: statusCode ?? this.statusCode); + } + + if (informationalResponse) { + sentHeaders[name] = values; + } + }); + + if (informationalResponse) { + return; + } + + statusCode ??= this.statusCode; + HttpHeaders.setHeader('Status', '$version $statusCode $statusText', replace: true, statusCode: statusCode); + } + + /// Sends content for the current web response. + /// + /// @return $this + void sendContent() { + print(content); + } + + /// Sends HTTP headers and content. + /// + /// @param bool $flush Whether output buffers should be flushed + /// + /// @return $this + void send([bool flush = true]) { + sendHeaders(); + sendContent(); + + if (flush) { + stdout.flush(); + } + } + + /// Sets the response content. + /// + /// @return $this + void setContent(String? content) { + this.content = content ?? ''; + } + + /// Gets the current response content. + String getContent() { + return content; + } + + /// Sets the HTTP protocol version (1.0 or 1.1). + /// + /// @return $this + /// + /// @final + void setProtocolVersion(String version) { + this.version = version; + } + + /// Gets the HTTP protocol version. + /// + /// @final + String getProtocolVersion() { + return version; + } + + /// Sets the response status code. + /// + /// If the status text is null it will be automatically populated for the known + /// status codes and left empty otherwise. + /// + /// @return $this + /// + /// @throws \InvalidArgumentException When the HTTP status code is not valid + /// + /// @final + void setStatusCode(int code, [String? text]) { + statusCode = code; + if (isInvalid()) { + throw ArgumentError('The HTTP status code "$code" is not valid.'); + } + + statusText = text ?? statusTexts[code] ?? 'unknown status'; + } + + /// Retrieves the status code for the current web response. + /// + /// @final + int getStatusCode() { + return statusCode; + } + + /// Sets the response charset. + /// + /// @return $this + /// + /// @final + void setCharset(String charset) { + this.charset = charset; + } + + /// Retrieves the response charset. + /// + /// @final + String? getCharset() { + return charset; + } + + /// Returns true if the response may safely be kept in a shared (surrogate) cache. + /// + /// Responses marked "private" with an explicit Cache-Control directive are + /// considered uncacheable. + /// + /// Responses with neither a freshness lifetime (Expires, max-age) nor cache + /// validator (Last-Modified, ETag) are considered uncacheable because there is + /// no way to tell when or how to remove them from the cache. + /// + /// Note that RFC 7231 and RFC 7234 possibly allow for a more permissive implementation, + /// for example "status codes that are defined as cacheable by default [...] + /// can be reused by a cache with heuristic expiration unless otherwise indicated" + /// (https://tools.ietf.org/html/rfc7231#section-6.1) + /// + /// @final + bool isCacheable() { + if (![200, 203, 300, 301, 302, 404, 410].contains(statusCode)) { + return false; + } + + if (headers.hasCacheControlDirective('no-store') || headers.hasCacheControlDirective('private')) { + return false; + } + + return isValidateable() || isFresh(); + } + + /// Returns true if the response is "fresh". + /// + /// Fresh responses may be served from cache without any interaction with the + /// origin. A response is considered fresh when it includes a Cache-Control/max-age + /// indicator or Expires header and the calculated age is less than the freshness lifetime. + /// + /// @final + bool isFresh() { + return getTtl() > 0; + } + + /// Returns true if the response includes headers that can be used to validate + /// the response with the origin server using a conditional GET request. + /// + /// @final + bool isValidateable() { + return headers.value('Last-Modified') != null || headers.value('ETag') != null; + } + + /// Marks the response as "private". + /// + /// It makes the response ineligible for serving other clients. + /// + /// @return $this + /// + /// @final + void setPrivate() { + headers.remove('Cache-Control'); + headers.set('Cache-Control', 'private'); + } + + /// Marks the response as "public". + /// + /// It makes the response eligible for serving other clients. + /// + /// @return $this + /// + /// @final + void setPublic() { + headers.remove('Cache-Control'); + headers.set('Cache-Control', 'public'); + } + + /// Marks the response as "immutable". + /// + /// @return $this + /// + /// @final + void setImmutable(bool immutable) { + if (immutable) { + headers.set('Cache-Control', 'immutable'); + } else { + headers.remove('Cache-Control'); + } + } + + /// Returns true if the response is marked as "immutable". + /// + /// @final + bool isImmutable() { + return headers.hasCacheControlDirective('immutable'); + } + + /// Returns true if the response must be revalidated by shared caches once it has become stale. + /// + /// This method indicates that the response must not be served stale by a + /// cache in any circumstance without first revalidating with the origin. + /// When present, the TTL of the response should not be overridden to be + /// greater than the value provided by the origin. + /// + /// @final + bool mustRevalidate() { + return headers.hasCacheControlDirective('must-revalidate') || headers.hasCacheControlDirective('proxy-revalidate'); + } + + /// Returns the Date header as a DateTime instance. + /// + /// @throws \RuntimeException When the header is not parseable + /// + /// @final + DateTime? getDate() { + var date = headers.value('Date'); + if (date == null) return null; + return HttpDate.parse(date); + } + + /// Sets the Date header. + /// + /// @return $this + /// + /// @final + void setDate(DateTime date) { + headers.set('Date', HttpDate.format(date.toUtc())); + } + + /// Returns the age of the response in seconds. + /// + /// @final + int getAge() { + var age = headers.value('Age'); + if (age != null) return int.parse(age); + var date = getDate(); + if (date == null) return 0; + return DateTime.now().toUtc().difference(date).inSeconds; + } + + /// Marks the response stale by setting the Age header to be equal to the maximum age of the response. + /// + /// @return $this + void expire() { + if (isFresh()) { + headers.set('Age', getMaxAge().toString()); + headers.remove('Expires'); + } + } + + /// Returns the value of the Expires header as a DateTime instance. + /// + /// @final + DateTime? getExpires() { + var expires = headers.value('Expires'); + if (expires == null) return null; + try { + return HttpDate.parse(expires); + } catch (_) { + return DateTime.now().subtract(Duration(days: 2)); + } + } + + /// Sets the Expires HTTP header with a DateTime instance. + /// + /// Passing null as value will remove the header. + /// + /// @return $this + /// + /// @final + void setExpires(DateTime? date) { + if (date == null) { + headers.remove('Expires'); + } else { + headers.set('Expires', HttpDate.format(date.toUtc())); + } + } + + /// Returns the number of seconds after the time specified in the response's Date + /// header when the response should no longer be considered fresh. + /// + /// First, it checks for a s-maxage directive, then a max-age directive, and then it falls + /// back on an expires header. It returns null when no maximum age can be established. + /// + /// @final + int? getMaxAge() { + if (headers.hasCacheControlDirective('s-maxage')) { + return int.parse(headers.getCacheControlDirective('s-maxage')); + } + + if (headers.hasCacheControlDirective('max-age')) { + return int.parse(headers.getCacheControlDirective('max-age')); + } + + var expires = getExpires(); + if (expires != null) { + return DateTime.now().difference(expires).inSeconds; + } + + return null; + } + + /// Sets the number of seconds after which the response should no longer be considered fresh. + /// + /// This method sets the Cache-Control max-age directive. + /// + /// @return $this + /// + /// @final + void setMaxAge(int value) { + headers.set('Cache-Control', 'max-age=$value'); + } + + /// Sets the number of seconds after which the response should no longer be returned by shared caches when backend is down. + /// + /// This method sets the Cache-Control stale-if-error directive. + /// + /// @return $this + /// + /// @final + void setStaleIfError(int value) { + headers.set('Cache-Control', 'stale-if-error=$value'); + } + + /// Sets the number of seconds after which the response should no longer return stale content by shared caches. + /// + /// This method sets the Cache-Control stale-while-revalidate directive. + /// + /// @return $this + /// + /// @final + void setStaleWhileRevalidate(int value) { + headers.set('Cache-Control', 'stale-while-revalidate=$value'); + } + + /// Sets the number of seconds after which the response should no longer be considered fresh by shared caches. + /// + /// This method sets the Cache-Control s-maxage directive. + /// + /// @return $this + /// + /// @final + void setSharedMaxAge(int value) { + setPublic(); + headers.set('Cache-Control', 's-maxage=$value'); + } + + /// Returns the response's time-to-live in seconds. + /// + /// It returns null when no freshness information is present in the response. + /// + /// When the response's TTL is 0, the response may not be served from cache without first + /// revalidating with the origin. + /// + /// @final + int? getTtl() { + var maxAge = getMaxAge(); + return maxAge != null ? maxAge - getAge() : null; + } + + /// Sets the response's time-to-live for shared caches in seconds. + /// + /// This method adjusts the Cache-Control/s-maxage directive. + /// + /// @return $this + /// + /// @final + void setTtl(int seconds) { + setSharedMaxAge(getAge() + seconds); + } + + /// Sets the response's time-to-live for private/client caches in seconds. + /// + /// This method adjusts the Cache-Control/max-age directive. + /// + /// @return $this + /// + /// @final + void setClientTtl(int seconds) { + setMaxAge(getAge() + seconds); + } + + /// Returns the Last-Modified HTTP header as a DateTime instance. + /// + /// @throws \RuntimeException When the HTTP header is not parseable + /// + /// @final + DateTime? getLastModified() { + var lastModified = headers.value('Last-Modified'); + if (lastModified == null) return null; + return HttpDate.parse(lastModified); + } + + /// Sets the Last-Modified HTTP header with a DateTime instance. + /// + /// Passing null as value will remove the header. + /// + /// @return $this + /// + /// @final + void setLastModified(DateTime? date) { + if (date == null) { + headers.remove('Last-Modified'); + } else { + headers.set('Last-Modified', HttpDate.format(date.toUtc())); + } + } + + /// Returns the literal value of the ETag HTTP header. + /// + /// @final + String? getEtag() { + return headers.value('ETag'); + } + + /// Sets the ETag value. + /// + /// @param string|null $etag The ETag unique identifier or null to remove the header + /// @param bool $weak Whether you want a weak ETag or not + /// + /// @return $this + /// + /// @final + void setEtag(String? etag, {bool weak = false}) { + if (etag == null) { + headers.remove('ETag'); + } else { + if (!etag.startsWith('"')) { + etag = '"$etag"'; + } + headers.set('ETag', '${weak ? 'W/' : ''}$etag'); + } + } + + /// Sets the response's cache headers (validation and/or expiration). + /// + /// Available options are: must_revalidate, no_cache, no_store, no_transform, public, private, proxy_revalidate, max_age, s_maxage, immutable, last_modified and etag. + /// + /// @return $this + /// + /// @throws \InvalidArgumentException + /// + /// @final + void setCache(Map options) { + var diff = options.keys.toSet().difference(httpResponseCacheControlDirectives.keys.toSet()); + if (diff.isNotEmpty) { + throw ArgumentError('Response does not support the following options: "${diff.join(', ')}".'); + } + + if (options.containsKey('etag')) { + setEtag(options['etag']); + } + + if (options.containsKey('last_modified')) { + setLastModified(options['last_modified']); + } + + if (options.containsKey('max_age')) { + setMaxAge(options['max_age']); + } + + if (options.containsKey('s_maxage')) { + setSharedMaxAge(options['s_maxage']); + } + + if (options.containsKey('stale_while_revalidate')) { + setStaleWhileRevalidate(options['stale_while_revalidate']); + } + + if (options.containsKey('stale_if_error')) { + setStaleIfError(options['stale_if_error']); + } + + httpResponseCacheControlDirectives.forEach((directive, hasValue) { + if (!hasValue && options.containsKey(directive)) { + if (options[directive]) { + headers.set('Cache-Control', directive.replaceAll('_', '-')); + } else { + headers.remove(directive.replaceAll('_', '-')); + } + } + }); + + if (options.containsKey('public')) { + if (options['public']) { + setPublic(); + } else { + setPrivate(); + } + } + + if (options.containsKey('private')) { + if (options['private']) { + setPrivate(); + } else { + setPublic(); + } + } + } + + /// Modifies the response so that it conforms to the rules defined for a 304 status code. + /// + /// This sets the status, removes the body, and discards any headers + /// that MUST NOT be included in 304 responses. + /// + /// @return $this + void setNotModified() { + setStatusCode(304); + content = ''; + + ['Allow', 'Content-Encoding', 'Content-Language', 'Content-Length', 'Content-MD5', 'Content-Type', 'Last-Modified'].forEach(headers.remove); + } + + /// Returns true if the response includes a Vary header. + /// + /// @final + bool hasVary() { + return headers.value('Vary') != null; + } + + /// Returns an array of header names given in the Vary header. + /// + /// @final + List getVary() { + var vary = headers.all('Vary')['Vary']; + if (vary == null) return []; + return vary.expand((v) => v.split(RegExp(r'[\s,]+'))).toList(); + } + + /// Sets the Vary header. + /// + /// @param bool $replace Whether to replace the actual value or not (true by default) + /// + /// @return $this + /// + /// @final + void setVary(List headers, [bool replace = true]) { + this.headers.set('Vary', headers.join(', '), replace: replace); + } + + /// Determines if the Response validators (ETag, Last-Modified) match + /// a conditional value specified in the Request. + /// + /// If the Response is not modified, it sets the status code to 304 and + /// removes the actual content by calling the setNotModified() method. + /// + /// @final + bool isNotModified(HttpRequest request) { + if (!['GET', 'HEAD'].contains(request.method)) { + return false; + } + + var notModified = false; + var lastModified = headers.value('Last-Modified'); + var modifiedSince = request.headers.value(HttpHeaders.ifModifiedSinceHeader); + + var ifNoneMatchEtags = request.headers[HttpHeaders.ifNoneMatchHeader]; + var etag = getEtag(); + + if (ifNoneMatchEtags != null && etag != null) { + if (etag.startsWith('W/')) { + etag = etag.substring(2); + } + + for (var ifNoneMatchEtag in ifNoneMatchEtags) { + if (ifNoneMatchEtag.startsWith('W/')) { + ifNoneMatchEtag = ifNoneMatchEtag.substring(2); + } + + if (ifNoneMatchEtag == etag || ifNoneMatchEtag == '*') { + notModified = true; + break; + } + } + } else if (modifiedSince != null && lastModified != null) { + notModified = DateTime.parse(modifiedSince).isAfter(DateTime.parse(lastModified)); + } + + if (notModified) { + setNotModified(); + } + + return notModified; + } + + /// Is response invalid? + /// + /// @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html + /// + /// @final + bool isInvalid() { + return statusCode < 100 || statusCode >= 600; + } + + /// Is response informative? + /// + /// @final + bool isInformational() { + return statusCode >= 100 && statusCode < 200; + } + + /// Is response successful? + /// + /// @final + bool isSuccessful() { + return statusCode >= 200 && statusCode < 300; + } + + /// Is the response a redirect? + /// + /// @final + bool isRedirection() { + return statusCode >= 300 && statusCode < 400; + } + + /// Is there a client error? + /// + /// @final + bool isClientError() { + return statusCode >= 400 && statusCode < 500; + } + + /// Was there a server side error? + /// + /// @final + bool isServerError() { + return statusCode >= 500 && statusCode < 600; + } + + /// Is the response OK? + /// + /// @final + bool isOk() { + return statusCode == HTTP_OK; + } + + /// Is the response forbidden? + /// + /// @final + bool isForbidden() { + return statusCode == HTTP_FORBIDDEN; + } + + /// Is the response a not found error? + /// + /// @final + bool isNotFound() { + return statusCode == HTTP_NOT_FOUND; + } + + /// Is the response a redirect of some form? + /// + /// @final + bool isRedirect([String? location]) { + return [HTTP_CREATED, HTTP_MOVED_PERMANENTLY, HTTP_FOUND, HTTP_SEE_OTHER, HTTP_TEMPORARY_REDIRECT, HTTP_PERMANENTLY_REDIRECT].contains(statusCode) && + (location == null || location == headers.value('Location')); + } + + /// Is the response empty? + /// + /// @final + bool isEmpty() { + return [HTTP_NO_CONTENT, HTTP_NOT_MODIFIED].contains(statusCode); + } + + /// Cleans or flushes output buffers up to target level. + /// + /// Resulting level can be greater than target level if a non-removable buffer has been encountered. + /// + /// @final + static void closeOutputBuffers(int targetLevel, bool flush) { + while (stdout.hasTerminal && stdout.terminalOutputMode != null && targetLevel > 0) { + if (flush) { + stdout.flush(); + } else { + stdout.clear(); + } + targetLevel--; + } + } + + /// Marks a response as safe according to RFC8674. + /// + /// @see https://tools.ietf.org/html/rfc8674 + void setContentSafe(bool safe) { + if (safe) { + headers.set('Preference-Applied', 'safe'); + } else if (headers.value('Preference-Applied') == 'safe') { + headers.remove('Preference-Applied'); + } + setVary(['Prefer'], false); + } + + /// Checks if we need to remove Cache-Control for SSL encrypted downloads when using IE < 9. + /// + /// @see http://support.microsoft.com/kb/323308 + /// + /// @final + void ensureIEOverSSLCompatibility(HttpRequest request) { + if (headers.value('Content-Disposition')?.contains('attachment') == true && + request.headers.value(HttpHeaders.userAgentHeader)?.contains(RegExp(r'MSIE (\d+)')) == true && + request.uri.scheme == 'https') { + var match = RegExp(r'MSIE (\d+)').firstMatch(request.headers.value(HttpHeaders.userAgentHeader) ?? ''); + if (match != null && int.parse(match.group(1)!) < 9) { + headers.remove('Cache-Control'); + } + } + } +} diff --git a/packages/http/lib/src/foundation/response_header_bag.dart b/packages/http/lib/src/foundation/response_header_bag.dart new file mode 100644 index 0000000..dae707f --- /dev/null +++ b/packages/http/lib/src/foundation/response_header_bag.dart @@ -0,0 +1,267 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'header_bag.dart'; +import 'cookie.dart'; +import 'header_utils.dart'; + +/// ResponseHeaderBag is a container for Response HTTP headers. +/// +/// Author: Fabien Potencier +class ResponseHeaderBag extends HeaderBag { + static const String COOKIES_FLAT = 'flat'; + static const String COOKIES_ARRAY = 'array'; + + static const String DISPOSITION_ATTACHMENT = 'attachment'; + static const String DISPOSITION_INLINE = 'inline'; + + Map computedCacheControl = {}; + Map>> cookies = {}; + Map headerNames = {}; + + /// Constructor for the ResponseHeaderBag class. + ResponseHeaderBag([Map>? headers]) : super(headers ?? {}) { + if (!headers!.containsKey('cache-control')) { + set('Cache-Control', ''); + } + + // RFC2616 - 14.18 says all Responses need to have a Date + if (!headers.containsKey('date')) { + initDate(); + } + } + + /// Returns the headers, with original capitalizations. + Map> allPreserveCase() { + final headers = >{}; + super.all().forEach((name, value) { + headers[headerNames[name] ?? name] = value; + }); + return headers; + } + + /// Returns the headers with original capitalizations, excluding cookies. + Map> allPreserveCaseWithoutCookies() { + final headers = allPreserveCase(); + if (headerNames.containsKey('set-cookie')) { + headers.remove(headerNames['set-cookie']); + } + return headers; + } + + /// Replaces the current headers with new headers. + @override + void replace([Map>? headers]) { + headerNames = {}; + super.replace(headers ?? {}); + if (!headers!.containsKey('cache-control')) { + set('Cache-Control', ''); + } + if (!headers.containsKey('date')) { + initDate(); + } + } + + /// Returns all headers, optionally filtered by a key. + @override + Map> all([String? key]) { + final headers = super.all(); + if (key != null) { + final uniqueKey = key.toLowerCase(); + if (uniqueKey != 'set-cookie') { + return {uniqueKey: headers[uniqueKey] ?? []}; + } else { + return {'set-cookie': cookies.values.expand((path) => path.values.expand((cookie) => cookie.values)).map((cookie) => cookie.toString()).toList()}; + } + } + + for (var path in cookies.values) { + for (var cookie in path.values) { + headers['set-cookie'] ??= []; + headers['set-cookie']!.add(cookie.toString()); + } + } + return headers; + } + + /// Sets a header value. + @override + void set(String key, dynamic values, [bool replace = true]) { + final uniqueKey = key.toLowerCase(); + if (uniqueKey == 'set-cookie') { + if (replace) { + cookies = {}; + } + for (var cookie in List.from(values)) { + setCookie(Cookie.fromString(cookie)); + } + headerNames[uniqueKey] = key; + return; + } + headerNames[uniqueKey] = key; + super.set(key, values, replace); + + // Ensure the cache-control header has sensible defaults + if (['cache-control', 'etag', 'last-modified', 'expires'].contains(uniqueKey)) { + final computedValue = computeCacheControlValue(); + super.set('Cache-Control', computedValue, replace); + headerNames['cache-control'] = 'Cache-Control'; + computedCacheControl = parseCacheControl(computedValue); + } + } + + /// Removes a header. + @override + void remove(String key) { + final uniqueKey = key.toLowerCase(); + headerNames.remove(uniqueKey); + if (uniqueKey == 'set-cookie') { + cookies = {}; + return; + } + super.remove(key); + if (uniqueKey == 'cache-control') { + computedCacheControl = {}; + } + if (uniqueKey == 'date') { + initDate(); + } + } + + /// Checks if the cache-control directive exists. + @override + bool hasCacheControlDirective(String key) { + return computedCacheControl.containsKey(key); + } + + /// Gets the value of a cache-control directive. + @override + dynamic getCacheControlDirective(String key) { + return computedCacheControl[key]; + } + + /// Sets a cookie. + void setCookie(Cookie cookie) { + cookies.putIfAbsent(cookie.domain ?? '', () => {}) + .putIfAbsent(cookie.path, () => {})[cookie.name] = cookie; + headerNames['set-cookie'] = 'Set-Cookie'; +} + + /// Removes a cookie from the array, but does not unset it in the browser. + void removeCookie(String name, [String? path = '/', String? domain]) { + path ??= '/'; + final domainCookies = cookies[domain] ?? {}; + final pathCookies = domainCookies[path] ?? {}; + pathCookies.remove(name); + + if (pathCookies.isEmpty) { + domainCookies.remove(path); + if (domainCookies.isEmpty) { + cookies.remove(domain); + } + } + + if (cookies.isEmpty) { + headerNames.remove('set-cookie'); + } + } + + /// Returns an array with all cookies. + /// + /// @return List + /// + /// @throws ArgumentError When the format is invalid + List getCookies([String format = COOKIES_FLAT]) { + if (!([COOKIES_FLAT, COOKIES_ARRAY].contains(format))) { + throw ArgumentError('Format "$format" invalid (${[COOKIES_FLAT, COOKIES_ARRAY].join(', ')}).'); + } + + if (format == COOKIES_ARRAY) { + return cookies.values.expand((path) => path.values.expand((cookie) => cookie.values)).toList(); + } + + final flattenedCookies = []; + for (var path in cookies.values) { + for (var domainCookies in path.values) { + for (var cookie in domainCookies.values) { + flattenedCookies.add(cookie); + } + } + } + return flattenedCookies; +} + + /// Clears a cookie in the browser. + /// + /// @param bool partitioned + void clearCookie(String name, + [String? path = '/', String? domain, bool secure = false, bool httpOnly = true, String? sameSite, bool partitioned = false]) { + final cookieString = '$name=null; Expires=${DateTime.fromMillisecondsSinceEpoch(1).toUtc().toIso8601String()}; Path=${path ?? "/"}; Domain=$domain${secure ? "; Secure" : ""}${httpOnly ? "; HttpOnly" : ""}${sameSite != null ? "; SameSite=$sameSite" : ""}${partitioned ? "; Partitioned" : ""}'; + setCookie(Cookie.fromString(cookieString)); + } + + /// Makes a disposition header. + /// + /// @see HeaderUtils::makeDisposition() + String makeDisposition(String disposition, String filename, [String filenameFallback = '']) { + return HeaderUtils.makeDisposition(disposition, filename, filenameFallback); + } + + /// Returns the calculated value of the cache-control header. + /// + /// This considers several other headers and calculates or modifies the + /// cache-control header to a sensible, conservative value. + String computeCacheControlValue() { + if (computedCacheControl.isEmpty) { + if (hasHeader('Last-Modified') || hasHeader('Expires')) { + return 'private, must-revalidate'; // allows for heuristic expiration (RFC 7234 Section 4.2.2) in the case of "Last-Modified" + } + + // Conservative by default + return 'no-cache, private'; + } + + final header = getCacheControlHeader(); + if (computedCacheControl.containsKey('public') || computedCacheControl.containsKey('private')) { + return header; + } + + // Public if s-maxage is defined, private otherwise + if (!computedCacheControl.containsKey('s-maxage')) { + return '$header, private'; + } + + return header; + } + + /// Parses the cache-control header value into a map. + Map parseCacheControl(String header) { + final directives = {}; + for (var directive in header.split(',')) { + final parts = directive.trim().split('='); + directives[parts[0]] = parts.length > 1 ? parts[1] : ''; + } + return directives; + } + + /// Initializes the Date header to the current date and time. + void initDate() { + set('Date', DateTime.now().toUtc().toIso8601String()); + } + + /// Checks if a header exists. + bool containsKey(String key) { + return super.all().containsKey(key.toLowerCase()); + } + + /// Gets the value of a header. + String? value(String key) { + return super.all()[key.toLowerCase()]?.join(', '); + } +}