about summary refs log tree commit diff stats
path: root/src
diff options
context:
space:
mode:
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] == '[':