import std/strutils import std/times import io/urlfilter import js/error import js/javascript import js/regex import types/url import types/opt import utils/twtstr type Cookie* = ref object created: int64 # unix time name {.jsget.}: string value {.jsget.}: string expires {.jsget.}: int64 # unix time secure {.jsget.}: bool httponly {.jsget.}: bool samesite {.jsget.}: bool domain {.jsget.}: string path {.jsget.}: string CookieJar* = ref object filter*: URLFilter cookies*: seq[Cookie] jsDestructor(Cookie) proc parseCookieDate(val: string): Option[DateTime] = # cookie-date const Delimiters = {'\t', ' '..'/', ';'..'@', '['..'`', '{'..'~'} const NonDigit = AllChars - AsciiDigit var foundTime = false var foundDayOfMonth = false var foundMonth = false var foundYear = false # date-token-list var time: array[3, int] var dayOfMonth: int var month: int var year: int for dateToken in val.split(Delimiters): if dateToken == "": continue # *delimiter if not foundTime: block timeBlock: # test for time let hmsTime = dateToken.until(NonDigit - {':'}) var i = 0 for timeField in hmsTime.split(':'): if i > 2: break timeBlock # too many time fields # 1*2DIGIT if timeField.len != 1 and timeField.len != 2: break timeBlock var timeFields: array[3, int] for c in timeField: if c notin AsciiDigit: break timeBlock timeFields[i] *= 10 timeFields[i] += c.decValue time = timeFields inc i if i != 3: break timeBlock foundTime = true continue if not foundDayOfMonth: block dayOfMonthBlock: # test for day-of-month let digits = dateToken.until(NonDigit) if digits.len != 1 and digits.len != 2: break dayOfMonthBlock var n = 0 for c in digits: if c notin AsciiDigit: break dayOfMonthBlock n *= 10 n += c.decValue dayOfMonth = n foundDayOfMonth = true continue if not foundMonth: block monthBlock: # test for month if dateToken.len < 3: break monthBlock case dateToken.substr(0, 2).toLowerAscii() of "jan": month = 1 of "feb": month = 2 of "mar": month = 3 of "apr": month = 4 of "may": month = 5 of "jun": month = 6 of "jul": month = 7 of "aug": month = 8 of "sep": month = 9 of "oct": month = 10 of "nov": month = 11 of "dec": month = 12 else: break monthBlock foundMonth = true continue if not foundYear: block yearBlock: # test for year let digits = dateToken.until(NonDigit) if digits.len != 2 and digits.len != 4: break yearBlock var n = 0 for c in digits: if c notin AsciiDigit: break yearBlock n *= 10 n += c.decValue year = n foundYear = true continue if not (foundDayOfMonth and foundMonth and foundYear and foundTime): return none(DateTime) if dayOfMonth notin 0..31: return none(DateTime) if year < 1601: return none(DateTime) if time[0] > 23: return none(DateTime) if time[1] > 59: return none(DateTime) if time[2] > 59: return none(DateTime) var dateTime = dateTime(year, Month(month), MonthdayRange(dayOfMonth), HourRange(time[0]), MinuteRange(time[1]), SecondRange(time[2])) return some(dateTime) # For debugging proc `$`*(cookiejar: CookieJar): string = result &= $cookiejar.filter result &= "\n" for cookie in cookiejar.cookies: result &= "Cookie " result &= $cookie[] result &= "\n" # https://www.rfc-editor.org/rfc/rfc6265#section-5.1.4 func defaultCookiePath(url: URL): string = let path = ($url.path).beforeLast('/') if path == "" or path[0] != '/': return "/" return path func cookiePathMatches(cookiePath, requestPath: string): bool = if requestPath.startsWith(cookiePath): if requestPath.len == cookiePath.len: return true if cookiePath[^1] == '/': return true if requestPath.len > cookiePath.len and requestPath[cookiePath.len] == '/': return true return false # I have no clue if this is actually compliant, because the spec is worded # so badly. # Either way, this implementation is needed for compatibility. # (Here is this part of the spec in its full glory: # A string domain-matches a given domain string if at least one of the # following conditions hold: # o The domain string and the string are identical. (Note that both # the domain string and the string will have been canonicalized to # lower case at this point.) # o All of the following conditions hold: # * The domain string is a suffix of the string. # * The last character of the string that is not included in the # domain string is a %x2E (".") character. (???) # * The string is a host name (i.e., not an IP address).) func cookieDomainMatches(cookieDomain: string, url: URL): bool = let host = url.host if host == cookieDomain: return true if url.isIP(): return false let cookieDomain = if cookieDomain.len > 0 and cookieDomain[0] == '.': cookieDomain.substr(1) else: cookieDomain return host.endsWith(cookieDomain) proc add*(cookieJar: CookieJar, cookie: Cookie) = var i = -1 for j in 0 ..< cookieJar.cookies.len: let old = cookieJar.cookies[j] if old.name == cookie.name and old.domain == cookie.domain and old.path == cookie.path: i = j break if i != -1: let old = cookieJar.cookies[i] cookie.created = old.created cookieJar.cookies.del(i) cookieJar.cookies.add(cookie) proc add*(cookiejar: CookieJar, cookies: seq[Cookie]) = for cookie in cookies: cookiejar.add(cookie) # https://www.rfc-editor.org/rfc/rfc6265#section-5.4 proc serialize*(cookiejar: CookieJar, url: URL): string = if not cookiejar.filter.match(url): return "" # fail let t = now().toTime().toUnix() #TODO sort for i in countdown(cookiejar.cookies.high, 0): let cookie = cookiejar.cookies[i] if cookie.expires != -1 and cookie.expires <= t: cookiejar.cookies.delete(i) continue if cookie.secure and url.scheme != "https": continue if not cookiePathMatches(cookie.path, $url.path): continue if not cookieDomainMatches(cookie.domain, url): continue if result != "": result &= "; " result &= cookie.name result &= "=" result &= cookie.value proc newCookie*(str: string, url: URL = nil): JSResult[Cookie] {.jsctor.} = let cookie = Cookie( expires: -1, created: now().toTime().toUnix() ) var first = true var haspath = false var hasdomain = false for part in str.split(';'): if first: cookie.name = part.until('=') cookie.value = part.after('=') first = false continue let part = part.strip(leading = true, trailing = false, AsciiWhitespace) var n = 0 for i in 0..part.high: if part[i] == '=': n = i break if n == 0: continue let key = part.substr(0, n - 1) let val = part.substr(n + 1) case key.toLowerAscii() of "expires": let date = parseCookieDate(val) if date.isSome: cookie.expires = date.get.toTime().toUnix() of "max-age": let x = parseInt64(val) if x.isSome: cookie.expires = cookie.created + x.get of "secure": cookie.secure = true of "httponly": cookie.httponly = true of "samesite": cookie.samesite = true of "path": if val != "" and val[0] == '/': haspath = true cookie.path = val of "domain": if url == nil or cookieDomainMatches(val, url): cookie.domain = val hasdomain = true else: return err(newTypeError("Domains do not match")) if not hasdomain: if url != nil: cookie.domain = url.host if not haspath: if url == nil: cookie.path = "/" else: cookie.path = defaultCookiePath(url) return ok(cookie) proc newCookieJar*(location: URL, allowhosts: seq[Regex]): CookieJar = return CookieJar( filter: newURLFilter( scheme = some(location.scheme), allowhost = some(location.host), allowhosts = allowhosts ) ) proc addCookieModule*(ctx: JSContext) = ctx.registerType(Cookie)