Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: better support for cookies #69

Merged
merged 3 commits into from
May 10, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 13 additions & 15 deletions lib/src/common.dart
Original file line number Diff line number Diff line change
@@ -1,37 +1,35 @@
import 'dart:async';
import 'dart:convert';

import 'package:hex/hex.dart';
import 'package:crypto/crypto.dart';
import 'package:quiver/cache.dart';

import 'cookie.dart';

/// A collection of common methods.
class Common {
const Common();

/// The cache containing the cookies semi-persistently.
static MapCache<String, String> cache = MapCache();
static MapCache<String, CookieJar> cache = MapCache();

/// Add / Replace this [Vault] [value] for the specified [key].
/// Add this key/value pair to the [cache].
///
/// * [key]: the key
/// * [value]: the value
static Future<void> storageSet(String key, String value) async {
/// If a key of other is already in this [cache], its value is overwritten.
static Future<void> storageSet(String key, CookieJar value) async {
await cache.set(key, value);
}

/// Returns the stash value for the specified [key]
///
/// * [key]: the key
///
/// Returns a [String]
static Future<String?> storageGet(String key) async {
/// The value for the given [key], or `null` if [key] is not in the [cache].
static Future<CookieJar?> storageGet(String key) async {
return await cache.get(key);
}

/// Removes the mapping for a [key] from this [Vault] if it is present.
///
/// * [key]: key whose mapping is to be removed from the [Vault]
/// Removes [key] and its associated value, if present, from the [cache].
///
/// Returns `true` if the removal of the mapping for the specified [key] was successful.
/// Returns `true` if the key/value pair was successfully removed,
/// `false` otherwise.
static Future<bool> storageRemove(String key) async {
await cache.invalidate(key);
return await cache.get(key) == null;
Expand Down
152 changes: 152 additions & 0 deletions lib/src/cookie.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import 'package:quiver/collection.dart';

/// Error thrown when assigning an invalid key to a [Map].
class KeyError implements Exception {
final String message;

KeyError(this.message);
}

/// Object representation of a cookie.
class Cookie {
final String _name;
final String _value;
static final Map<String, Type> validAttributes = {
"comment": String,
"domain": String,
"expires": String,
"httponly": bool,
"path": String,
"max-age": String,
"secure": bool,
"samesite": String,
};

final _attributes = <String, dynamic>{};

Cookie(this._name, this._value);

/// The name of [this].
String get name => _name;

/// The value of [this].
String get value => _value;

/// Constructs a request header.
String output({String header = "Set-Cookie"}) {
String string = "$header: $name=$value";

_attributes.forEach((key, value) {
if (value.runtimeType == String && value.length == 0) {
return;
}

if (value.runtimeType == bool && value == false) {
return;
}

string += "; $key";
if (value.runtimeType != bool) {
string += "=$value";
}
});

return string;
}

/// Removes [key] and its associated [value], if present, from the attributes.
String remove(String key) {
return _attributes.remove(key);
}

/// The value for the given [key], or `null` if [key] is not
/// in the attributes.
String? operator [](String key) {
return _attributes[key.toLowerCase()];
}

/// Associates the [key] with the given [value].
///
/// Throws a [KeyError] if the key isn't a valid attribute,
/// see [this.validAttributes].
void operator []=(String key, dynamic value) {
if (validAttributes.containsKey(key.toLowerCase())) {
var attributeType = validAttributes[key.toLowerCase()];

switch (attributeType) {
case bool:
case String:
if (value.runtimeType == attributeType) {
_attributes[key.toLowerCase()] = value;
}
return;
default:
}
}
throw KeyError("Input key is not valid: $key.");
}

/// Same as [this.output()].
@override
String toString() {
return output();
}
}

/// A custom [Map] that will store a sort of [List] of unique [Cookie].
class CookieJar extends DelegatingMap<String, Cookie> {
final Map<String, Cookie> _cookies = {};

@override
Map<String, Cookie> get delegate => _cookies;

/// Parses a string of cookies separated by commas into a [CookieJar].
static CookieJar parseCookiesString(String cookiesString) {
cookiesString = cookiesString.trim();

var result = CookieJar();

// Matches commas that separate cookies.
// Commas shall not be between " or ' as it would be some json string.
// We assume that there will be no commas inside keys or values.
RegExp cookiesSplit = RegExp(
r"""(?<!expires=\w{3}|"|')\s*,\s*(?!"|')""",
caseSensitive: false,
);

// Separates the name, value and attributes of one cookie.
// The attributes will still need to be parsed.
RegExp cookieParse = RegExp(
r"^(?<name>[^=]+)=(?<value>[^;]+);?(?<raw_attributes>.*)$",
);

// Matches the key / value pair of an attribute of a cookie.
RegExp attributesParse = RegExp(
r"(?<key>[^;=\s]+)(?:=(?<value>[^=;\n]+))?",
);

for (var rawCookie in cookiesString.split(cookiesSplit)) {
var parsedCookie = cookieParse.firstMatch(rawCookie);
if (parsedCookie != null) {
var name = parsedCookie.namedGroup("name")!;
var value = parsedCookie.namedGroup("value")!;
var rawAttributes = parsedCookie.namedGroup("raw_attributes")!;

var cookie = Cookie(name, value);

for (var parsedAttribute in attributesParse.allMatches(rawAttributes)) {
var attributeKey = parsedAttribute.namedGroup("key")!;
var attributeValue = parsedAttribute.namedGroup("value");

if (Cookie.validAttributes.containsKey(attributeKey.toLowerCase())) {
cookie[attributeKey] = attributeValue ?? true;
}
}

result[name] = cookie;
}
}

return result;
}
}
73 changes: 26 additions & 47 deletions lib/src/requests.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:http/io_client.dart' as io_client;

import 'common.dart';
import 'event.dart';
import 'cookie.dart';

enum RequestBodyEncoding { JSON, FormURLEncoded, PlainText }
enum HttpMethod { GET, PUT, PATCH, POST, DELETE, HEAD }
Expand Down Expand Up @@ -65,75 +66,53 @@ class Requests {
static const int DEFAULT_TIMEOUT_SECONDS = 10;
static const RequestBodyEncoding DEFAULT_BODY_ENCODING =
RequestBodyEncoding.FormURLEncoded;
static final Set _cookiesKeysToIgnore = {
'samesite',
'path',
'domain',
'max-age',
'expires',
'secure',
'httponly'
};

static Map<String, String> extractResponseCookies(responseHeaders) {
var cookies = <String, String>{};
for (var key in responseHeaders.keys) {
if (Common.equalsIgnoreCase(key, 'set-cookie')) {
var cookie = responseHeaders[key].trim();

var separators = [',', ';', ' '];
var lastSeparator = -1, endSeparator = 0;
cookie.split('').asMap().forEach((i, char) {
if (separators.contains(char)) {
lastSeparator = i;
}

if(char == '=' && i > endSeparator) {
endSeparator = cookie.indexOf(';', i) > 0 ? cookie.indexOf(';', i) : cookie.length;
var value = cookie.substring(i+1, endSeparator);
var key = cookie.substring(lastSeparator+1, i).trim();

if (_cookiesKeysToIgnore.contains(key.toLowerCase())) {
endSeparator = 0;
} else {
cookies[key] = value;
}
}
});
break;
}

/// Gets the cookies of a [Response.headers] in the form of a [CookieJar].
static CookieJar extractResponseCookies(Map<String, String> responseHeaders) {
var result = CookieJar();
var keys = responseHeaders.keys.map((e) => e.toLowerCase());

if (keys.contains("set-cookie")) {
var cookies = responseHeaders["set-cookie"]!;
result = CookieJar.parseCookiesString(cookies);
}

return cookies;
return result;
}

static Future<Map<String, String>> _constructRequestHeaders(
String hostname, Map<String, String>? customHeaders) async {
var cookies = await getStoredCookies(hostname);
var cookie = cookies.keys.map((key) => '$key=${cookies[key]}').join('; ');
var requestHeaders = <String, String>{};

var cookies = (await getStoredCookies(hostname)).values;
var cookie = cookies.map((e) => '${e.name}=${e.value}').join("; ");

requestHeaders['cookie'] = cookie;

if (customHeaders != null) {
requestHeaders.addAll(customHeaders);
}

return requestHeaders;
}

static Future<Map<String, String>> getStoredCookies(String hostname) async {
/// Get the [CookieJar] for the given [hostname], or an empty [CookieJar]
/// if the [hostname] is not in the cache.
static Future<CookieJar> getStoredCookies(String hostname) async {
var hostnameHash = Common.hashStringSHA256(hostname);
var cookiesJson = await Common.storageGet('cookies-$hostnameHash');
var cookies = Common.fromJson(cookiesJson);
var cookies = await Common.storageGet('cookies-$hostnameHash');

return cookies != null ? Map.from(cookies) : <String, String>{};
return cookies ?? CookieJar();
}

/// Associates the [hostname] with the given [cookies] into the cache.
static Future<void> setStoredCookies(
String hostname, Map<String, String> cookies) async {
String hostname, CookieJar cookies) async {
var hostnameHash = Common.hashStringSHA256(hostname);
var cookiesJson = Common.toJson(cookies);
await Common.storageSet('cookies-$hostnameHash', cookiesJson);
await Common.storageSet('cookies-$hostnameHash', cookies);
}

/// Removes [hostname] and its associated value, if present, from the cache.
static Future clearStoredCookies(String hostname) async {
var hostnameHash = Common.hashStringSHA256(hostname);
await Common.storageRemove('cookies-$hostnameHash');
Expand Down
Loading