about summary refs log tree commit diff stats
path: root/src
diff options
context:
space:
mode:
authorbptato <nincsnevem662@gmail.com>2024-12-29 18:27:37 +0100
committerbptato <nincsnevem662@gmail.com>2024-12-29 18:43:22 +0100
commitaa2e19429c2e5ed42a21470c36635f6543b129fb (patch)
treeff45d52ec207db6d868ed368258d04ebd1e25aed /src
parente60c82ec37b69ce82f40de5f66f079a9a0aee6be (diff)
downloadchawan-aa2e19429c2e5ed42a21470c36635f6543b129fb.tar.gz
cookie: add persistent cookies, misc refactoring/fixes
Mostly compatible with other browsers/tools that follow the
Netscape/curl format.

Cookie jars are represented by prepending "jar@" to the host part, but
*only* if the target jar is different than the domain.  Hopefully, other
software at least does not choke on this convention.  (At least curl
seems to simply ignore the entries.)

Also, I've moved cookies.nim to config so that code for local files
parsed at startup remains in one place.
Diffstat (limited to 'src')
-rw-r--r--src/config/config.nim25
-rw-r--r--src/config/cookie.nim435
-rw-r--r--src/local/container.nim5
-rw-r--r--src/local/pager.nim57
-rw-r--r--src/server/buffer.nim1
-rw-r--r--src/server/loader.nim2
-rw-r--r--src/server/loaderiface.nim2
-rw-r--r--src/types/cookie.nim253
-rw-r--r--src/types/url.nim4
9 files changed, 501 insertions, 283 deletions
diff --git a/src/config/config.nim b/src/config/config.nim
index 690014b5..734a3a04 100644
--- a/src/config/config.nim
+++ b/src/config/config.nim
@@ -5,6 +5,7 @@ import std/tables
 
 import chagashi/charset
 import config/chapath
+import config/cookie
 import config/mailcap
 import config/mimetypes
 import config/toml
@@ -21,7 +22,6 @@ import monoucha/tojs
 import server/headers
 import types/cell
 import types/color
-import types/cookie
 import types/jscolor
 import types/opt
 import types/url
@@ -56,11 +56,16 @@ type
     frtData = "data"
     frtMailto = "mailto"
 
+  CookieMode* = enum
+    cmNone = "false"
+    cmReadOnly = "true"
+    cmSave = "save"
+
   SiteConfig* = ref object
     url*: Option[Regex]
     host*: Option[Regex]
     rewrite_url*: Option[JSValueFunction]
-    cookie*: Option[bool]
+    cookie*: Option[CookieMode]
     share_cookie_jar*: Option[string]
     referer_from*: Option[bool]
     scripting*: Option[ScriptingMode]
@@ -113,6 +118,7 @@ type
     bookmark* {.jsgetset.}: ChaPathResolved
     history_file*: ChaPathResolved
     history_size* {.jsgetset.}: int32
+    cookie_file*: ChaPathResolved
     download_dir* {.jsgetset.}: ChaPathResolved
     w3m_cgi_compat* {.jsgetset.}: bool
     copy_cmd* {.jsgetset.}: string
@@ -161,7 +167,7 @@ type
     styling* {.jsgetset.}: bool
     scripting* {.jsgetset.}: ScriptingMode
     images* {.jsgetset.}: bool
-    cookie* {.jsgetset.}: bool
+    cookie* {.jsgetset.}: CookieMode
     referer_from* {.jsgetset.}: bool
     autofocus* {.jsgetset.}: bool
     meta_refresh* {.jsgetset.}: MetaRefresh
@@ -333,6 +339,8 @@ proc parseConfigValue(ctx: var ConfigParser; x: var ColorMode; v: TomlValue;
   k: string)
 proc parseConfigValue(ctx: var ConfigParser; x: var ScriptingMode; v: TomlValue;
   k: string)
+proc parseConfigValue(ctx: var ConfigParser; x: var CookieMode; v: TomlValue;
+  k: string)
 proc parseConfigValue[T](ctx: var ConfigParser; x: var Option[T]; v: TomlValue;
   k: string)
 proc parseConfigValue(ctx: var ConfigParser; x: var ARGBColor; v: TomlValue;
@@ -497,6 +505,17 @@ proc parseConfigValue(ctx: var ConfigParser; x: var ScriptingMode; v: TomlValue;
     raise newException(ValueError, "unknown scripting mode '" & v.s &
       "' for key " & k)
 
+proc parseConfigValue(ctx: var ConfigParser; x: var CookieMode; v: TomlValue;
+    k: string) =
+  typeCheck(v, {tvtString, tvtBoolean}, k)
+  if v.t == tvtBoolean:
+    x = if v.b: cmReadOnly else: cmNone
+  elif v.s == "save":
+    x = cmSave
+  else:
+    raise newException(ValueError, "unknown cookie mode '" & v.s &
+      "' for key " & k)
+
 proc parseConfigValue(ctx: var ConfigParser; x: var ARGBColor; v: TomlValue;
     k: string) =
   typeCheck(v, tvtString, k)
diff --git a/src/config/cookie.nim b/src/config/cookie.nim
new file mode 100644
index 00000000..219f35fe
--- /dev/null
+++ b/src/config/cookie.nim
@@ -0,0 +1,435 @@
+import std/algorithm
+import std/options
+import std/posix
+import std/strutils
+import std/tables
+import std/times
+
+import io/dynstream
+import types/opt
+import types/url
+import utils/twtstr
+
+type
+  Cookie* = ref object
+    name: string
+    value: string
+    expires: int64 # unix time
+    domain: string
+    path: string
+    persist: bool
+    secure: bool
+    httpOnly: bool
+    hostOnly: bool
+    isnew: bool
+    skip: bool
+
+  CookieJar* = ref object
+    cookies*: seq[Cookie]
+    map: Table[string, Cookie] # {host}{path}\t{name}
+
+  CookieJarMap* = ref object
+    mtime: int64
+    jars*: OrderedTable[string, CookieJar]
+
+proc newCookieJarMap*(): CookieJarMap =
+  return CookieJarMap()
+
+proc newCookieJar*(): CookieJar =
+  return CookieJar()
+
+proc parseCookieDate(val: string): Option[int64] =
+  # 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].default
+  var dayOfMonth = 0
+  var month = 0
+  var year = 0
+  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].default
+          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(int64)
+  if dayOfMonth notin 0..31: return none(int64)
+  if year < 1601: return none(int64)
+  if time[0] > 23: return none(int64)
+  if time[1] > 59: return none(int64)
+  if time[2] > 59: return none(int64)
+  let dt = dateTime(year, Month(month), MonthdayRange(dayOfMonth),
+    HourRange(time[0]), MinuteRange(time[1]), SecondRange(time[2]))
+  return some(dt.toTime().toUnix())
+
+# For debugging
+proc `$`*(cookieJar: CookieJar): string =
+  result = ""
+  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 =
+  var path = url.pathname.untilLast('/')
+  if path == "" or path[0] != '/':
+    return "/"
+  return move(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
+
+func cookieDomainMatches(cookieDomain: string; url: URL): bool =
+  if cookieDomain.len == 0:
+    return false
+  let host = url.host
+  if url.isIP():
+    return host == cookieDomain
+  if host.endsWith(cookieDomain) and host.len >= cookieDomain.len:
+    return host.len == cookieDomain.len or
+      host[host.len - cookieDomain.len - 1] == '.'
+  return false
+
+proc add(cookieJar: CookieJar; cookie: Cookie; parseMode = false,
+    persist = true) =
+  let s = cookie.domain & cookie.path & '\t' & cookie.name
+  let old = cookieJar.map.getOrDefault(s)
+  if old != nil:
+    if parseMode and old.isnew:
+      return # do not override newly added cookies
+    if persist or not old.persist:
+      let i = cookieJar.cookies.find(old)
+      cookieJar.cookies.delete(i)
+    else:
+      # we cannot save this cookie, but it must be kept for this session.
+      old.skip = true
+  cookieJar.map[s] = cookie
+  cookieJar.cookies.add(cookie)
+
+# https://www.rfc-editor.org/rfc/rfc6265#section-5.4
+proc serialize*(cookieJar: CookieJar; url: URL): string =
+  var res = ""
+  let t = getTime().toUnix()
+  var expired: seq[int] = @[]
+  for i, cookie in cookieJar.cookies.mypairs:
+    let cookie = cookieJar.cookies[i]
+    if cookie.skip: # "read-only" cookie
+      continue
+    if cookie.expires != -1 and cookie.expires <= t:
+      expired.add(i)
+      continue
+    if cookie.secure and url.scheme != "https":
+      continue
+    if not cookiePathMatches(cookie.path, url.pathname):
+      continue
+    if cookie.hostOnly and cookie.domain != url.host:
+      continue
+    if not cookie.hostOnly and not cookieDomainMatches(cookie.domain, url):
+      continue
+    if res != "":
+      res &= "; "
+    res &= cookie.name
+    res &= "="
+    res &= cookie.value
+  for j in countdown(expired.high, 0):
+    cookieJar.cookies.delete(expired[j])
+  return move(res)
+
+proc parseSetCookie(str: string; t: int64; url: URL; persist: bool):
+    Opt[Cookie] =
+  let cookie = Cookie(
+    expires: -1,
+    hostOnly: true,
+    persist: persist,
+    isnew: true
+  )
+  var first = true
+  var hasPath = false
+  for part in str.split(';'):
+    if first:
+      if '\t' in part:
+        # Drop cookie if it has a tab.
+        # Gecko seems to accept it, but Blink drops it too,
+        # so this should be safe from a compat perspective.
+        continue
+      cookie.name = part.until('=')
+      cookie.value = part.substr(cookie.name.len + 1)
+      first = false
+      continue
+    let part = part.strip(leading = true, trailing = false, AsciiWhitespace)
+    let key = part.untilLower('=')
+    let val = part.substr(key.len + 1)
+    case key
+    of "expires":
+      if cookie.expires == -1:
+        let date = parseCookieDate(val)
+        if date.isSome:
+          cookie.expires = date.get
+    of "max-age":
+      let x = parseInt32(val)
+      if x.get(-1) >= 0:
+        cookie.expires = t + x.get
+    of "secure": cookie.secure = true
+    of "httponly": cookie.httpOnly = true
+    of "path":
+      if val != "" and val[0] == '/' and '\t' notin val:
+        hasPath = true
+        cookie.path = val
+    of "domain":
+      var hostType = htNone
+      var domain = parseHost(val, special = false, hostType)
+      if domain.len > 0 and domain[0] == '.':
+        domain.delete(0..0)
+      if hostType == htNone or not cookieDomainMatches(domain, url):
+        return err()
+      if hostType != htNone:
+        cookie.domain = move(domain)
+        cookie.hostOnly = false
+  if cookie.hostOnly:
+    cookie.domain = url.host
+  if not hasPath:
+    cookie.path = defaultCookiePath(url)
+  if cookie.expires < 0:
+    cookie.persist = false
+  return ok(cookie)
+
+proc setCookie*(cookieJar: CookieJar; header: openArray[string]; url: URL;
+    persist: bool) =
+  let t = getTime().toUnix()
+  var sorted = true
+  for s in header:
+    let cookie = parseSetCookie(s, t, url, persist)
+    if cookie.isSome:
+      cookieJar.add(cookie.get, persist = persist)
+      sorted = false
+  if not sorted:
+    cookieJar.cookies.sort(proc(a, b: Cookie): int =
+      return cmp(a.path.len, b.path.len), order = Descending)
+
+type ParseState = object
+  i: int
+  cookie: Cookie
+  error: bool
+
+proc nextField(state: var ParseState; iq: openArray[char]): string =
+  if state.i >= iq.len or iq[state.i] == '\n':
+    state.error = true
+    return ""
+  var field = iq.until({'\t', '\n'}, state.i)
+  state.i += field.len
+  if state.i < iq.len and iq[state.i] == '\t':
+    inc state.i
+  return move(field)
+
+proc nextBool(state: var ParseState; iq: openArray[char]): bool =
+  let field = state.nextField(iq)
+  if field == "TRUE":
+    return true
+  if field != "FALSE":
+    state.error = true
+  return false
+
+proc nextInt64(state: var ParseState; iq: openArray[char]): int64 =
+  let x = parseInt64(state.nextField(iq))
+  if x.isNone:
+    state.error = true
+    return 0
+  return x.get
+
+proc parse(map: CookieJarMap; iq: openArray[char]; warnings: var seq[string]) =
+  var state = ParseState()
+  var line = 0
+  while state.i < iq.len:
+    var httpOnly = false
+    if iq[state.i] == '\n':
+      inc state.i
+      continue
+    if iq[state.i] == '#':
+      inc state.i
+      let first = iq.until({'_', '\n'}, state.i)
+      state.i += first.len
+      if first != "HttpOnly":
+        while state.i < iq.len and iq[state.i] != '\n':
+          inc state.i
+        inc state.i
+        inc line
+        continue
+      inc state.i
+      httpOnly = true
+    state.error = false
+    let cookie = Cookie(httpOnly: httpOnly, persist: true)
+    var domain = state.nextField(iq)
+    var cookieJar: CookieJar = nil
+    if (let j = domain.find('@'); j != -1):
+      cookie.domain = domain.substr(j + 1)
+      if cookie.domain[0] == '.':
+        cookie.domain.delete(0..0)
+      domain.setLen(j)
+    else:
+      if domain[0] == '.':
+        domain.delete(0..0)
+      cookie.domain = domain
+    cookieJar = map.jars.getOrDefault(domain)
+    if cookieJar == nil:
+      cookieJar = CookieJar()
+      map.jars[domain] = cookieJar
+    cookie.hostOnly = not state.nextBool(iq)
+    cookie.path = state.nextField(iq)
+    cookie.secure = state.nextBool(iq)
+    cookie.expires = state.nextInt64(iq)
+    cookie.name = state.nextField(iq)
+    cookie.value = state.nextField(iq)
+    if not state.error:
+      cookieJar.add(cookie, parseMode = true)
+    else:
+      warnings.add("skipped invalid cookie line " & $line)
+    inc state.i
+    inc line
+
+# Consumes `ps'.
+proc parse*(map: CookieJarMap; ps: PosixStream; mtime: int64;
+    warnings: var seq[string]) =
+  try:
+    let src = ps.recvAllOrMmap()
+    map.parse(src.toOpenArray(), warnings)
+    deallocMem(src)
+    map.mtime = mtime
+  except IOError:
+    discard
+  finally:
+    ps.sclose()
+
+proc c_rename(oldname, newname: cstring): cint {.importc: "rename",
+  header: "<stdio.h>".}
+
+proc write*(map: CookieJarMap; file: string): bool =
+  let ps = newPosixStream(file)
+  if ps != nil:
+    var stats: Stat
+    if fstat(ps.fd, stats) != -1 and S_ISREG(stats.st_mode):
+      if int64(stats.st_mtime) > map.mtime:
+        var dummy: seq[string] = @[]
+        map.parse(ps, int64(stats.st_mtime), dummy)
+      else:
+        ps.sclose()
+    else:
+      ps.sclose()
+  elif map.jars.len == 0:
+    return true
+  let tmp = file & '~'
+  block write:
+    let ps = newPosixStream(tmp, O_WRONLY or O_CREAT, 0o600)
+    var i = 0
+    let time = getTime().toUnix()
+    try:
+      if ps != nil:
+        var buf = """
+# Netscape HTTP Cookie file
+# Autogenerated by Chawan.  Manually added cookies are normally
+# preserved, but comments will be lost.
+
+"""
+        for name, jar in map.jars:
+          for cookie in jar.cookies:
+            if cookie.expires <= time or not cookie.persist:
+              continue # session cookie
+            if buf.len >= 4096: # flush
+              ps.sendDataLoop(buf)
+              buf.setLen(0)
+            if cookie.httpOnly:
+              buf &= "#HttpOnly_"
+            if cookie.domain != name:
+              buf &= name & "@"
+            if not cookie.hostOnly:
+              buf &= '.'
+            buf &= cookie.domain & '\t'
+            const BoolMap = [false: "FALSE", true: "TRUE"]
+            buf &= BoolMap[not cookie.hostOnly] & '\t' # flipped intentionally
+            buf &= cookie.path & '\t'
+            buf &= BoolMap[cookie.secure] & '\t'
+            buf &= $cookie.expires & '\t'
+            buf &= cookie.name & '\t'
+            buf &= cookie.value & '\n'
+            inc i
+        if buf.len > 0:
+          ps.sendDataLoop(buf)
+    except IOError:
+      return false
+    finally:
+      ps.sclose()
+    if i == 0:
+      discard unlink(cstring(tmp))
+      discard unlink(cstring(file))
+      return true
+    return c_rename(cstring(tmp), file) == 0
diff --git a/src/local/container.nim b/src/local/container.nim
index 36d6f91d..a64fade0 100644
--- a/src/local/container.nim
+++ b/src/local/container.nim
@@ -6,6 +6,7 @@ import std/tables
 
 import chagashi/charset
 import config/config
+import config/cookie
 import config/mimetypes
 import css/render
 import io/dynstream
@@ -23,7 +24,6 @@ import types/bitmap
 import types/blob
 import types/cell
 import types/color
-import types/cookie
 import types/opt
 import types/referrer
 import types/url
@@ -1518,7 +1518,8 @@ proc applyResponse*(container: Container; response: Response;
   # accept cookies
   let cookieJar = container.loaderConfig.cookieJar
   if cookieJar != nil and "Set-Cookie" in response.headers.table:
-    cookieJar.setCookie(response.headers.table["Set-Cookie"], response.url)
+    cookieJar.setCookie(response.headers.table["Set-Cookie"], response.url,
+      container.config.cookieMode == cmSave)
   # set referrer policy, if any
   let referrerPolicy = response.getReferrerPolicy()
   if container.config.refererFrom:
diff --git a/src/local/pager.nim b/src/local/pager.nim
index 91fc45c4..3ad46381 100644
--- a/src/local/pager.nim
+++ b/src/local/pager.nim
@@ -13,6 +13,7 @@ import chagashi/charset
 import chagashi/decoder
 import config/chapath
 import config/config
+import config/cookie
 import config/history
 import config/mailcap
 import config/mimetypes
@@ -50,7 +51,6 @@ import types/bitmap
 import types/blob
 import types/cell
 import types/color
-import types/cookie
 import types/opt
 import types/url
 import types/winattrs
@@ -144,7 +144,7 @@ type
     config*: Config
     consoleWrapper*: ConsoleWrapper
     container {.jsget: "buffer".}: Container
-    cookiejars: Table[string, CookieJar]
+    cookieJars: CookieJarMap
     display: Surface
     exitCode*: int
     feednext*: bool
@@ -476,7 +476,8 @@ proc newPager*(config: Config; forkserver: ForkServer; ctx: JSContext;
     luctx: LUContext(),
     urandom: urandom,
     exitCode: -1,
-    loader: loader
+    loader: loader,
+    cookieJars: newCookieJarMap()
   )
   pager.timeouts = newTimeoutState(pager.jsctx, evalJSFree, pager)
   JS_SetModuleLoaderFunc(pager.jsrt, normalizeModuleName, clientLoadJSModule,
@@ -487,15 +488,24 @@ proc newPager*(config: Config; forkserver: ForkServer; ctx: JSContext;
     proxy: pager.config.network.proxy,
     filter: newURLFilter(default = true),
   ))
-  let hist = newHistory(pager.config.external.history_size, getTime().toUnix())
-  let ps = newPosixStream(pager.config.external.history_file)
-  if ps != nil:
-    var stat: Stat
-    if fstat(ps.fd, stat) != -1:
-      discard hist.parse(ps, int64(stat.st_mtime))
-    else:
-      ps.sclose()
-  pager.lineHist[lmLocation] = hist
+  block history:
+    let hist = newHistory(pager.config.external.history_size, getTime().toUnix())
+    let ps = newPosixStream(pager.config.external.history_file)
+    if ps != nil:
+      var stat: Stat
+      if fstat(ps.fd, stat) != -1:
+        discard hist.parse(ps, int64(stat.st_mtime))
+      else:
+        ps.sclose()
+    pager.lineHist[lmLocation] = hist
+  block cookie:
+    let ps = newPosixStream(pager.config.external.cookie_file)
+    if ps != nil:
+      var stat: Stat
+      if fstat(ps.fd, stat) != -1:
+        pager.cookieJars.parse(ps, int64(stat.st_mtime), pager.alerts)
+      else:
+        ps.sclose()
   return pager
 
 proc cleanup(pager: Pager) =
@@ -505,6 +515,8 @@ proc cleanup(pager: Pager) =
     let hist = pager.lineHist[lmLocation]
     if not hist.write(pager.config.external.history_file):
       pager.alert("failed to save history")
+    if not pager.cookieJars.write(pager.config.external.cookie_file):
+      pager.alert("failed to save cookies")
     for msg in pager.alerts:
       stderr.write("cha: " & msg & '\n')
     for val in pager.config.cmd.map.values:
@@ -1805,7 +1817,8 @@ proc applySiteconf(pager: Pager; url: URL; charsetOverride: Charset;
     isdump: pager.config.start.headless,
     charsetOverride: charsetOverride,
     protocol: pager.config.protocol,
-    metaRefresh: pager.config.buffer.meta_refresh
+    metaRefresh: pager.config.buffer.meta_refresh,
+    cookieMode: pager.config.buffer.cookie
   )
   loaderConfig = LoaderClientConfig(
     defaultHeaders: newHeaders(pager.config.network.default_headers),
@@ -1818,6 +1831,7 @@ proc applySiteconf(pager: Pager; url: URL; charsetOverride: Charset;
     ),
     insecureSSLNoVerify: false
   )
+  var cookieJarId = url.host
   let surl = $url
   for sc in pager.config.siteconf:
     if sc.url.isSome and not sc.url.get.match(surl):
@@ -1845,14 +1859,9 @@ proc applySiteconf(pager: Pager; url: URL; charsetOverride: Charset;
         ourl = tmpUrl
         return
     if sc.cookie.isSome:
-      if sc.cookie.get:
-        # host/url might have changed by now
-        let jarid = sc.share_cookie_jar.get(url.host)
-        if jarid notin pager.cookiejars:
-          pager.cookiejars[jarid] = newCookieJar(url)
-        loaderConfig.cookieJar = pager.cookiejars[jarid]
-      else:
-        loaderConfig.cookieJar = nil # override
+      res.cookieMode = sc.cookie.get
+    if sc.share_cookie_jar.isSome:
+      cookieJarId = sc.share_cookie_jar.get
     if sc.scripting.isSome:
       res.scripting = sc.scripting.get
     if sc.referer_from.isSome:
@@ -1881,6 +1890,12 @@ proc applySiteconf(pager: Pager; url: URL; charsetOverride: Charset;
   if res.images:
     res.imageTypes = pager.config.external.mime_types.image
   res.userAgent = loaderConfig.defaultHeaders.getOrDefault("User-Agent")
+  if res.cookieMode != cmNone:
+    var cookieJar = pager.cookieJars.jars.getOrDefault(cookieJarId)
+    if cookieJar == nil:
+      cookieJar = newCookieJar()
+      pager.cookieJars.jars[cookieJarId] = cookieJar
+    loaderConfig.cookieJar = cookieJar
   return res
 
 # Load request in a new buffer.
diff --git a/src/server/buffer.nim b/src/server/buffer.nim
index ea6a0d94..017b0965 100644
--- a/src/server/buffer.nim
+++ b/src/server/buffer.nim
@@ -138,6 +138,7 @@ type
     protocol*: Table[string, ProtocolConfig]
     imageTypes*: Table[string, string]
     userAgent*: string
+    cookieMode*: CookieMode
 
   GetValueProc = proc(iface: BufferInterface; promise: EmptyPromise) {.nimcall.}
 
diff --git a/src/server/loader.nim b/src/server/loader.nim
index 93ecc1bb..b5e0ee91 100644
--- a/src/server/loader.nim
+++ b/src/server/loader.nim
@@ -27,6 +27,7 @@ import std/posix
 import std/strutils
 import std/tables
 
+import config/cookie
 import config/urimethodmap
 import io/bufreader
 import io/bufwriter
@@ -39,7 +40,6 @@ import server/headers
 import server/loaderiface
 import server/request
 import server/urlfilter
-import types/cookie
 import types/formdata
 import types/opt
 import types/referrer
diff --git a/src/server/loaderiface.nim b/src/server/loaderiface.nim
index faa9b021..d76ee14f 100644
--- a/src/server/loaderiface.nim
+++ b/src/server/loaderiface.nim
@@ -5,6 +5,7 @@
 
 import std/tables
 
+import config/cookie
 import io/bufreader
 import io/bufwriter
 import io/dynstream
@@ -15,7 +16,6 @@ import server/headers
 import server/request
 import server/response
 import server/urlfilter
-import types/cookie
 import types/opt
 import types/referrer
 import types/url
diff --git a/src/types/cookie.nim b/src/types/cookie.nim
deleted file mode 100644
index ee287004..00000000
--- a/src/types/cookie.nim
+++ /dev/null
@@ -1,253 +0,0 @@
-import std/options
-import std/strutils
-import std/times
-
-import types/opt
-import types/url
-import utils/twtstr
-
-type
-  Cookie* = ref object
-    name: string
-    value: string
-    expires: int64 # unix time
-    secure: bool
-    httponly: bool
-    hostOnly: bool
-    domain: string
-    path: string
-
-  CookieJar* = ref object
-    domain: string
-    cookies*: seq[Cookie]
-
-proc parseCookieDate(val: string): Option[int64] =
-  # 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].default
-  var dayOfMonth = 0
-  var month = 0
-  var year = 0
-  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].default
-          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(int64)
-  if dayOfMonth notin 0..31: return none(int64)
-  if year < 1601: return none(int64)
-  if time[0] > 23: return none(int64)
-  if time[1] > 59: return none(int64)
-  if time[2] > 59: return none(int64)
-  let dt = dateTime(year, Month(month), MonthdayRange(dayOfMonth),
-    HourRange(time[0]), MinuteRange(time[1]), SecondRange(time[2]))
-  return some(dt.toTime().toUnix())
-
-# For debugging
-proc `$`*(cookieJar: CookieJar): string =
-  result = $cookieJar.domain
-  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.pathname.untilLast('/')
-  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 =
-  if cookieDomain.len == 0:
-    return false
-  let host = url.host
-  if host == cookieDomain:
-    return true
-  if url.isIP():
-    return false
-  let cookieDomain = if cookieDomain[0] == '.':
-    cookieDomain.substr(1)
-  else:
-    cookieDomain
-  return host.endsWith(cookieDomain)
-
-proc add(cookieJar: CookieJar; cookie: Cookie) =
-  var i = -1
-  for j, old in cookieJar.cookies.mypairs:
-    if old.name == cookie.name and old.domain == cookie.domain and
-        old.path == cookie.path:
-      i = j
-      break
-  if i != -1:
-    cookieJar.cookies[i] = cookie
-  else:
-    cookieJar.cookies.add(cookie)
-
-# https://www.rfc-editor.org/rfc/rfc6265#section-5.4
-proc serialize*(cookieJar: CookieJar; url: URL): string =
-  var res = ""
-  let t = getTime().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.pathname):
-      continue
-    if cookie.hostOnly and cookie.domain != url.host:
-      continue
-    if not cookie.hostOnly and not cookieDomainMatches(cookie.domain, url):
-      continue
-    if res != "":
-      res &= "; "
-    res &= cookie.name
-    res &= "="
-    res &= cookie.value
-  return res
-
-proc parseCookie(str: string; t: int64; url: URL): Opt[Cookie] =
-  let cookie = Cookie(expires: -1, hostOnly: true)
-  var first = true
-  var hasPath = 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)
-    let n = part.find('=')
-    if n <= 0:
-      continue
-    let key = part.substr(0, n - 1)
-    let val = part.substr(n + 1)
-    case key.toLowerAscii()
-    of "expires":
-      if cookie.expires == -1:
-        let date = parseCookieDate(val)
-        if date.isSome:
-          cookie.expires = date.get
-    of "max-age":
-      let x = parseInt64(val)
-      if x.isSome:
-        cookie.expires = t + x.get
-    of "secure": cookie.secure = true
-    of "httponly": cookie.httponly = true
-    of "path":
-      if val != "" and val[0] == '/':
-        hasPath = true
-        cookie.path = val
-    of "domain":
-      if not cookieDomainMatches(val, url):
-        return err()
-      cookie.domain = val
-      cookie.hostOnly = false
-  if cookie.hostOnly:
-    cookie.domain = url.host
-  if not hasPath:
-    cookie.path = defaultCookiePath(url)
-  return ok(cookie)
-
-proc newCookieJar*(url: URL): CookieJar =
-  return CookieJar(domain: url.host)
-
-proc setCookie*(cookieJar: CookieJar; header: openArray[string]; url: URL) =
-  let t = getTime().toUnix()
-  for s in header:
-    let cookie = parseCookie(s, t, url)
-    if cookie.isSome:
-      cookieJar.add(cookie.get)
diff --git a/src/types/url.nim b/src/types/url.nim
index c2d6e075..aa7d9f23 100644
--- a/src/types/url.nim
+++ b/src/types/url.nim
@@ -25,7 +25,7 @@ type
     usFail, usDone, usSchemeStart, usNoScheme, usFile, usFragment, usAuthority,
     usPath, usQuery, usHost, usHostname, usPort, usPathStart
 
-  HostType = enum
+  HostType* = enum
     htNone, htDomain, htIpv4, htIpv6, htOpaque
 
   URLSearchParams* = ref object
@@ -390,7 +390,7 @@ proc domainToAscii(domain: string; bestrict = false): string =
     return domain.unicodeToAscii(bestrict)
   return domain.toLowerAscii()
 
-proc parseHost(input: string; special: bool; hostType: var HostType): string =
+proc parseHost*(input: string; special: bool; hostType: var HostType): string =
   if input.len == 0:
     return ""
   if input[0] == '[':