diff --git a/src/inc/fetch/cookie-jar.mjs b/src/inc/fetch/cookie-jar.mjs new file mode 100644 index 0000000..34bf275 --- /dev/null +++ b/src/inc/fetch/cookie-jar.mjs @@ -0,0 +1,15 @@ +import Cookie from "./cookie"; + +export default class CookieJar { + constructor() { + this.cookies = new Map(); + } + addCookie(c, fromURL) { + if(typeof c === "string") + c = new Cookie(c, fromURL); + this.cookies.set(c.name, c); + } + forEach(callback) { + this.cookies.forEach(callback); + } +}; diff --git a/src/inc/fetch/cookie.mjs b/src/inc/fetch/cookie.mjs new file mode 100644 index 0000000..8f3b83d --- /dev/null +++ b/src/inc/fetch/cookie.mjs @@ -0,0 +1,109 @@ +import urlParser from "url"; + +const validateHostname = (cookieHostname, requestHostname, subdomains) => { + cookieHostname = cookieHostname.toLowerCase(); + requestHostname = requestHostname.toLowerCase(); + if(requestHostname === cookieHostname || (subdomains && requestHostname.endsWith("." + cookieHostname))) + return true; + return false; +}; + +const validatePath = (cookiePath, requestPath) => { + cookiePath = cookiePath.toLowerCase(); + requestPath = requestPath.toLowerCase(); + if(cookiePath.endsWith("/")) + cookiePath = cookiePath.slice(0, -1); + if(requestPath.endsWith("/")) + requestPath = requestPath.slice(0, -1); + return (requestPath + "/").startsWith(cookiePath + "/"); +}; + +export default class Cookie { + constructor(str, url) { + if(typeof str !== "string") + throw new TypeError("Input not a string"); + + const splitted = str.split("; "); + [this.name, this.value] = splitted[0].split("="); + if(this.value.startsWith("\"") && this.value.endsWith("\"")) + this.value = this.value.slice(1, -1); + + for(let i = 1; i < splitted.length; i++) { + let [k, v] = splitted[i].split("="); + k = k.toLowerCase(); + if(v) { + if(k === "expires") { + if(this.expiry) // max-age has precedence over expires + continue; + if(!/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2}[ -](?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[ -]\d{2,4} \d{2}:\d{2}:\d{2} GMT$/.test(v) + || (this.expiry = new Date(v)) === "Invalid Date") + throw new TypeError("Invalid value for Expires \"" + v + "\"!"); + } + else if(k === "max-age") { + const seconds = parseInt(v); + if(seconds.toString() !== v) + throw new TypeError("Invalid value for Max-Age \"" + v + "\"!"); + this.expiry = new Date(); + this.expiry.setSeconds(this.expiry.getSeconds() + seconds); + } + else if(k === "domain") { + if(v.startsWith(".")) + v = v.substring(1); + this.domain = v; + this.subdomains = true; + } + else if(k === "path") { + this.path = v; + } + else if(k === "samesite") // only relevant for cross site requests, so not for us + continue; + else + throw new TypeError("Invalid key \"" + k + "\" specified!"); + } + else { + if(k === "secure") + this.secure = true; + else if(k === "httponly") // only relevant for browsers + continue; + else + throw new TypeError("Invalid key \"" + k + "\" specified!"); + } + } + if(!this.domain) { + this.domain = urlParser.parse(url).hostname; + this.subdomains = false; + } + if(!this.path) + this.path = "/"; + if(this.name.toLowerCase().startsWith("__secure-") && (!this.secure || !url.toLowerCase().startsWith("https:"))) + throw new TypeError("Cookie has \"__Secure-\" prefix but \"Secure\" isn't set or the cookie is not set via https!"); + if(this.name.toLowerCase().startsWith("__host-") && (!this.secure || !url.toLowerCase().startsWith("https:") || this.domain || this.path !== "/")) + throw new TypeError("Cookie has \"__Host-\" prefix but \"Secure\" isn't set, the cookie is not set via https, \"Domain\" is set or \"Path\" is not equal to \"/\"!"); + } + static fromObject(obj) { + let c = Object.assign(Object.create(this.prototype), obj); + if(c.expiry && typeof c.expiry === "string") + c.expiry = new Date(c.expiry); + return c; + } + serialize() { + return this.name + "=" + this.value; + } + hasExpired() { + return this.expiry && this.expiry < new Date(); + } + isValidForRequest(url) { + if(this.hasExpired()) + return false; + const parsedURL = urlParser.parse(url); + if(parsedURL.protocol !== "http:" && parsedURL.protocol !== "https:") + return false; + if(this.secure && parsedURL.protocol !== "https:") + return false; + if(!validateHostname(this.domain, parsedURL.hostname, this.subdomains)) + return false; + if(!validatePath(this.path, parsedURL.pathname)) + return false; + return true; + } +}; diff --git a/src/inc/fetch.mjs b/src/inc/fetch/fetch.mjs similarity index 95% rename from src/inc/fetch.mjs rename to src/inc/fetch/fetch.mjs index 690fedb..e14f6f1 100644 --- a/src/inc/fetch.mjs +++ b/src/inc/fetch/fetch.mjs @@ -20,10 +20,10 @@ export default (a, options = {}, link = url.parse(a), body = "") => new Promise( if(options.method === "POST") { body = querystring.stringify(options.body); delete options.body; - options.headers = { + options.headers = {...options.headers, ...{ "Content-Type": "application/x-www-form-urlencoded", "Content-Length": Buffer.byteLength(body) - }; + }}; } (link.protocol === "https:"?https:http).request(options, res => resolve({ body: res, diff --git a/src/inc/fetch/index.mjs b/src/inc/fetch/index.mjs new file mode 100644 index 0000000..54b3397 --- /dev/null +++ b/src/inc/fetch/index.mjs @@ -0,0 +1,28 @@ +import fetch from "./fetch"; +import CookieJar from "./cookie-jar"; +import Cookie from "./cookie"; + +const cookieJar = new CookieJar(); + +export default async function cookieFetch(url, options) { + let cookies = ""; + cookieJar.forEach(c => { + if(c.isValidForRequest(url)) + cookies += c.serialize() + "; "; + }); + if(cookies.length !== 0) { + if(!options) { + options = { + headers: {} + }; + } + else if(!options.headers) + options.headers = {}; + options.headers.cookie = cookies.slice(0, -2); + } + const result = await fetch(url, options); + cookies = result.headers["set-cookie"]; + if(cookies) + cookies.forEach(c => cookieJar.addCookie(c, url)); + return result; +}