
Cookie.pudu
Pudu151 lines5.8 KB
1/** @Domain.Cookie.Module — cookies read from responses and matched to requests */2module PuduLangHttpClient.Domain.Cookie34import Std.Json as Json5import Std.Math as Math6import Std.Option as Option7import Std.Text as Text8import PuduLangHttpClient.Domain.Date as Date910/** @Domain.Cookie.Stored — one cookie as a jar keeps it */11export type Stored = {12 name: Str,13 value: Str,14 domain: Str,15 hostOnly: Bool,16 path: Str,17 secure: Bool,18 httpOnly: Bool,19 expires: Option[Int],20 created: Int21} derives Json.Encode, Json.Decode222324const PATH_WEIGHT: Int = 10000000000000002526272829export fn parse(header: Str, host: Str, path: Str, now: Int) -> Option[Stored] {30 let pieces = header.split(";")31 let pair = pieces[0]32 let equals = pair.indexOf("=")33 if equals < 1 { return None }34 let name = pair.slice(0, equals).trim()35 if name.isEmpty() { return None }36 var cookie = Stored {37 name: name,38 value: unquoted(pair.drop(equals + 1).trim()),39 domain: host.toLower(),40 hostOnly: true,41 path: defaultPath(path),42 secure: false,43 httpOnly: false,44 expires: None,45 created: now46 }47 var maxAge: Option[Int] = None48 var expires: Option[Int] = None49 for piece in pieces.slice(1, pieces.length()) {50 let (named, given) = Text.breakOn(piece, "=")51 let key = named.trim().toLower()52 let value = Text.stripPrefix(given, "=").trim()53 if key == "domain" && !value.isEmpty() {54 let domain = Text.stripPrefix(value, ".").toLower()55 if !domainMatches(host.toLower(), domain) { return None }56 cookie = Stored{..cookie, domain: domain, hostOnly: false}57 } else if key == "path" && value.startsWith("/") {58 cookie = Stored{..cookie, path: value}59 } else if key == "secure" {60 cookie = Stored{..cookie, secure: true}61 } else if key == "httponly" {62 cookie = Stored{..cookie, httpOnly: true}63 } else if key == "max-age" {64 if let Some(seconds) = value.toInt() { maxAge = Some(Math.max(seconds, 0) * 1000) }65 } else if key == "expires" {66 expires = Date.parse(value)67 }68 }69 let ending = match maxAge {70 case Some(millis) => Some(now + millis)71 case None => expires72 }73 Some(Stored{..cookie, expires: ending})74}75767778export fn domainMatches(host: Str, domain: Str) -> Bool {79 host == domain || (host.endsWith("." + domain) && !isAddress(host))80}818283export fn pathMatches(requestPath: Str, cookiePath: Str) -> Bool {84 requestPath == cookiePath85 || (requestPath.startsWith(cookiePath) && (cookiePath.endsWith("/") || requestPath.drop(cookiePath.length()).startsWith("/")))86}878889export fn expired(cookie: &Stored, now: Int) -> Bool {90 match cookie.expires {91 case Some(at) => at <= now92 case None => false93 }94}95969798export fn applies(cookie: &Stored, host: Str, path: Str, secure: Bool, now: Int) -> Bool {99 let lower = host.toLower()100 let hostFits = if cookie.hostOnly { lower == cookie.domain } else { domainMatches(lower, cookie.domain) }101 hostFits && pathMatches(if path.isEmpty() { "/" } else { path }, cookie.path) && (secure || !cookie.secure) && !expired(cookie, now)102}103104105106export fn store(jar: &Array[Stored], cookie: &Stored, now: Int) -> Array[Stored] {107 let previous = jar.filter(|held: Stored| same(&held, cookie))108 let others = jar.filter(|held: Stored| !same(&held, cookie) && !expired(&held, now))109 if expired(cookie, now) { return others }110 let created = match previous {111 case [first, ..] => first.created112 case _ => cookie.created113 }114 others.push(Stored{..*cookie, created: created})115}116117118export fn render(cookies: &Array[Stored]) -> Str {119 let ordered = cookies.sortBy(|left: Stored, right: Stored| rank(&left) < rank(&right))120 ordered.map(|cookie: Stored| cookie.name + "=" + cookie.value).join("; ")121}122123124export fn defaultPath(path: Str) -> Str {125 if !path.startsWith("/") { return "/" }126 let (front, _) = Text.breakOnEnd(path, "/")127 if front.length() <= 1 { "/" } else { Text.dropEnd(front, 1) }128}129130131132fn rank(cookie: &Stored) -> Int { cookie.created - cookie.path.length() * PATH_WEIGHT }133134135fn same(left: &Stored, right: &Stored) -> Bool {136 left.name == right.name && left.domain == right.domain && left.path == right.path137}138139140fn isAddress(host: Str) -> Bool {141 host.contains(":") || Text.allChars(host, |c: Char| c == '.' || (c >= '0' && c <= '9'))142}143144145fn unquoted(value: Str) -> Str {146 if value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"") { value.slice(1, value.length() - 1) } else { value }147}148149150export fn isPersistent(cookie: &Stored) -> Bool { Option.isSome(&cookie.expires) }151