add: adding ports from the symfony project

This commit is contained in:
Patrick Stewart 2024-06-21 14:49:49 -07:00
parent 72bfd6a4f0
commit 5b1016cb54
6 changed files with 2061 additions and 0 deletions

87
design.draft Normal file
View file

@ -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

View file

@ -0,0 +1,255 @@
/*
* This file is part of the VieoFabric package.
*
* (c) Patrick Stewart <patrick@example.com>
*
* 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<String> RESERVED_CHARS_FROM = ['=', ',', ';', ' ', "\t", "\r", "\n", "\v", "\f"];
static const List<String> 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 = <String, Object?>{
'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;
}
}

View file

@ -0,0 +1,204 @@
import 'dart:collection';
/// HeaderBag is a container for HTTP headers.
///
/// Author: Fabien Potencier <fabien@symfony.com>
/// This file is part of the Symfony package.
class HeaderBag extends IterableBase<MapEntry<String, List<String?>>> {
static const String upper = '_ABCDEFGHIJKLMNOPQRSTUVWXYZ';
static const String lower = '-abcdefghijklmnopqrstuvwxyz';
/// A map to store the headers.
final Map<String, List<String?>> _headers = {};
/// A map to store cache control directives.
final Map<String, dynamic> _cacheControl = {};
/// Constructor for HeaderBag
HeaderBag([Map<String, List<String?>> 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<String, List<String?>>.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<String, List<String?>> all([String? key]) {
if (key != null) {
return {key.toLowerCase(): _headers[key.toLowerCase()] ?? []};
}
return _headers;
}
/// Returns the parameter keys.
///
/// @return A list of keys.
List<String> keys() {
return _headers.keys.toList();
}
/// Replaces the current HTTP headers by a new set.
void replace(Map<String, List<String?>> headers) {
_headers.clear();
add(headers);
}
/// Adds new headers to the current HTTP headers set.
void add(Map<String, List<String?>> 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<String?> valueList;
if (values is List) {
valueList = List<String?>.from(values);
if (replace || !_headers.containsKey(key)) {
_headers[key] = valueList;
} else {
_headers[key] = List<String?>.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<MapEntry<String, List<String?>>> 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<String, dynamic>.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<String, dynamic> _parseCacheControl(String header) {
var parts = header.split(',').map((e) => e.split('=')).toList();
var map = <String, dynamic>{};
for (var part in parts) {
map[part[0].trim()] = part.length > 1 ? part[1].trim() : true;
}
return map;
}
}

View file

@ -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<List<String>> Nested array with as many levels as there are characters in
/// separators
static List<List<String>> 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]+
)+
(?<!\\s)
|
# separator
\\s*
(?<separator>[$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<String, dynamic> combine(List<List<String>> parts) {
final assoc = <String, dynamic>{};
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<String, dynamic> assoc, String separator) {
final parts = <String>[];
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<String, dynamic> parseQuery(String query, [bool ignoreBrackets = false, String separator = '&']) {
final result = <String, dynamic>{};
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<List<String>> _groupParts(List<RegExpMatch> matches, String separators, [bool first = true]) {
final separator = separators[0];
separators = separators.substring(1);
var i = 0;
if (separators.isEmpty && !first) {
final parts = <String>[''];
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 = <List<String>>[];
final partMatches = <int, List<RegExpMatch>>{};
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;
}
}

View file

@ -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<int, String> 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<String, bool> 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<String, List<String>> 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<String, List<String?>>? 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<String, dynamic> 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<String> 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<String> 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');
}
}
}
}

View file

@ -0,0 +1,267 @@
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <fabien@symfony.com>
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<String, String> computedCacheControl = {};
Map<String, Map<String, Map<String, Cookie>>> cookies = {};
Map<String, String> headerNames = {};
/// Constructor for the ResponseHeaderBag class.
ResponseHeaderBag([Map<String, List<String?>>? 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<String, List<String?>> allPreserveCase() {
final headers = <String, List<String?>>{};
super.all().forEach((name, value) {
headers[headerNames[name] ?? name] = value;
});
return headers;
}
/// Returns the headers with original capitalizations, excluding cookies.
Map<String, List<String?>> 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<String, List<String?>>? 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<String, List<String?>> 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<String>.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<Cookie>
///
/// @throws ArgumentError When the format is invalid
List<Cookie> 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 = <Cookie>[];
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<String, String> parseCacheControl(String header) {
final directives = <String, String>{};
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(', ');
}
}