Skip to content

Commit

Permalink
feat: better support for cookies (#69)
Browse files Browse the repository at this point in the history
  • Loading branch information
sehnryr authored May 10, 2022
1 parent d6b4c53 commit 2fe09df
Show file tree
Hide file tree
Showing 4 changed files with 253 additions and 72 deletions.
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

0 comments on commit 2fe09df

Please sign in to comment.