#lang racket/base

(require racket/contract
         racket/class ; for cookie-jar interface & class
         racket/list
         racket/match
         (only-in racket/bytes bytes-join) ; for building the Cookie: header
         srfi/19
         "common.rkt"
         ;web-server/http/request-structs
         ; The above is commented out because, although it'd be clean to reuse
         ; header structs, I don't want to create a dependency on the
         ; web-server-lib package. I'm leaving it in as comments, in case
         ; net/head acquires a similar facility at some point.
         net/url ; used in path matching
         (only-in racket/date date->seconds)
         (only-in racket/string string-join string-trim string-split)
         (only-in srfi/13 string-index-right)
         )

(struct ua-cookie [name value domain path
                        expiration-time creation-time [access-time #:mutable]
                        persistent? host-only? secure-only? http-only?]
  #:transparent)

(provide (contract-out
          (struct ua-cookie ([name            cookie-name?]
                             [value           cookie-value?]
                             [domain          domain-value?]
                             [path            path/extension-value?]
                             [expiration-time integer?]
                             [creation-time   (and/c integer? positive?)]
                             [access-time     (and/c integer? positive?)]
                             [persistent?     boolean?]
                             [host-only?      boolean?]
                             [secure-only?    boolean?]
                             [http-only?      boolean?]))

          [extract-and-save-cookies!
           (->* ((or/c (listof (cons/c bytes? bytes?))
                       (listof bytes?))
                 url?)
                ((-> bytes? string?))
               void?)]
          [save-cookie! (->* (ua-cookie?) (boolean?) void?)]
          [cookie-header (->* (url?)
                              ((-> string? bytes?)
                               #:filter-with (-> ua-cookie? boolean?))
                              (or/c bytes? #f))]

          [cookie-expired? (->* (ua-cookie?) ((and/c integer? positive?))
                                boolean?)]
          [current-cookie-jar (parameter/c (is-a?/c cookie-jar<%>))]
          [list-cookie-jar% 
           (class/c [save-cookies! (->*m ((listof ua-cookie?)) (boolean?) void?)]
                    [save-cookie!  (->*m (ua-cookie?)          (boolean?) void?)]
                    [cookies-matching
                     (->*m (url?) (boolean?) (listof ua-cookie?))])]

          [extract-cookies
           (->* ((or/c (listof (cons/c bytes? bytes?))
                       (listof bytes?))
                 ;(listof (or/c header? (cons/c bytes? bytes?)))
                 url?)
                ((-> bytes? string?))
                (listof ua-cookie?))]
          [parse-cookie (->* (bytes? url?) ((-> bytes? string?)) (or/c ua-cookie? #f))]

          [default-path (-> url? string?)]

          [min-cookie-seconds (and/c integer? negative?)]
          [max-cookie-seconds (and/c integer? positive?)]
          [parse-date    (-> string? (or/c date? #f))]
          )
         cookie-jar<%>
         )

;;;;;;;;;;;;;;;;;;;;; Storing Cookies ;;;;;;;;;;;;;;;;;;;;;

;; for saving all cookies from a Set-Cookie header
(define (extract-and-save-cookies! headers url [decode bytes->string/utf-8])
  (send (current-cookie-jar)
        save-cookies! (extract-cookies headers url decode)
        (and (member (url-scheme url) '("http" "https")) #t)))

;; ua-cookie? [boolean?] -> void?
;; for saving a single cookie (already parsed), received via an HTTP API
;; iff via-http? is #t.
(define (save-cookie! c [via-http? #t])
  (send (current-cookie-jar) save-cookie! c via-http?))

;; url? (-> string? bytes?) #:filter-with [ua-cookie? -> boolean?] -> bytes?
;; for producing a header from the cookie jar, for requests to given url.
;; NOTE: this produces only the VALUE portion of the header, not the ``Cookie:''
;; part; I'm not sure if users will prefer to use web-server/http/request-structs
;; or net/head to construct their headers, or manually construct them to feed
;; to http-sendrecv and friends.
(define (cookie-header url
                       [encode string->bytes/utf-8]
                       #:filter-with [ok? (lambda (x) #t)])
  (define (make-cookie-pair c)
    (bytes-append (encode (ua-cookie-name c))
                  #"=" (encode (ua-cookie-value c))))
  (define cookie-pairs
    (for/list ([c (in-list
                   (filter ok?
                           (send (current-cookie-jar) cookies-matching url)))])
      (make-cookie-pair c)))
  (and (not (null? cookie-pairs)) (bytes-join cookie-pairs #"; ")))

;;;; The cookie jar:

(define cookie-jar<%>
  (interface ()
    [save-cookie!  (->*m (ua-cookie?)          (boolean?) void?)]
    [save-cookies! (->*m ((listof ua-cookie?)) (boolean?) void?)]
    [cookies-matching (->m url? (listof ua-cookie?))]))

;; ua-cookie [Int+] -> Boolean
(define (cookie-expired? cookie [current-time (current-seconds)])
  (> current-time (ua-cookie-expiration-time cookie)))

;; Represents the cookie jar as a list of cookies, sorted in ascending order
;; by length of path, with ties broken by later-ctime-first.
(define list-cookie-jar%
  (class* object% (cookie-jar<%>)
    (super-new)
    (field [cookies '()])

    (define/public (save-cookie! c [via-http? #t])
      (set! cookies (insert c cookies via-http?)))

    (define/public (save-cookies! cs [via-http? #t])
      (for ([c cs]) (save-cookie! c via-http?)))

    ;; insert : ua-cookie? (listof ua-cookie?) [boolean?] -> (listof ua-cookie?)
    ;; Inserts new-cookie into the given list, maintaining sort order, unless
    ;; it was received via a non-HTTP API (as indicated by via-http?) and should
    ;; be ignored per section 5.3 of RFC6265.
    (define (insert new-cookie jar via-http?)
      (match-define (ua-cookie name _ dom path _ ctime _ _ _ _ http-only?)
        new-cookie)
      (if (and http-only? (not via-http?))   ; ignore -- see Sec 5.3.10
          jar
          (let insert-into ([jar jar]) ; != Binks
            (cond
              [(null? jar) (if (cookie-ok? new-cookie) (list new-cookie) '())]
              [else
               (match-define (ua-cookie name2 _ dom2 path2 _ ctime2 _ _ _ _ ho2?)
                 (car jar))
               (cond
                 [(and (string=? name name2) (string=? dom dom2)
                       (string=? path path2)) ; Replace this cookie.
                  (filter cookie-ok?
                          (if (and ho2? (not via-http?))
                              jar  ; ignore new cookie -- see Sec 5.3.11.2.
                              (cons (struct-copy ua-cookie new-cookie
                                                 [creation-time ctime2])
                                    (cdr jar))))]
                 [(let ([plen  (string-length path)]
                        [plen2 (string-length path2)])
                    (or (< plen plen2) (and (= plen plen2) (> ctime ctime2))))
                  ;; Shorter path, or eq path and later ctime, comes first.
                  (filter cookie-ok? (cons new-cookie jar))]
                 [(cookie-ok? (car jar))
                  (cons (car jar) (insert-into (cdr jar)))]
                 [else (insert-into (cdr jar))])]))))

    (define (cookie-ok? c) (not (cookie-expired? c)))

    (define/public (cookies-matching url
                                     [secure? (equal? (url-scheme url) "https")])
      (define host (url-host url))
      (define (match? cookie)
        (and (domain-match? (ua-cookie-domain cookie) host)
             (path-match? (ua-cookie-path cookie) url)
             (or secure? (not (ua-cookie-secure-only? cookie)))))
      ;; Produce the cookies in reverse order (ie, desc by path length):
      (for/fold ([cs '()]) ([c (in-list cookies)])
        (if (match? c) (cons c cs) cs)))
    ))

;; The cookie jar that will be used for saving new cookies, and for choosing
;; cookies to send to the server.
(define current-cookie-jar (make-parameter (new list-cookie-jar%)))

;;;;;;;;;;;;;;;;;;;;; Reading the Set-Cookie header ;;;;;;;;;;;;;;;;;;;;;

;; given a list of all the headers received in a response,
;; produce a list of cookies corresponding to all the Set-Cookie headers
;; present.
(define (extract-cookies headers url [decode bytes->string/utf-8])
  (define (set-cookie? x) (string-ci=? (decode x) "set-cookie"))
  (define (header->maybe-cookie hdr)
    (match hdr
      [(cons (? set-cookie?) value) value]
      [(? bytes?)
       (define pair/f (regexp-match #rx"^(?i:set-cookie): *(.*)$" hdr))
       (and pair/f (cadr pair/f))]
      ;[(header (? set-cookie?) value) value]
      [_ #f]))
  (filter (lambda (x) x)
          (for/list ([header-value (filter-map header->maybe-cookie headers)])
            (parse-cookie header-value url decode))))

;; parse-cookie : bytes? url? [(bytes? -> string?)] -> (Option ua-cookie?)
;; Given the value from a Set-Cookie: header, produce a ua-cookie, or #f
;; if the byte-string doesn't contain an adequately well-formed cookie.
(define (parse-cookie set-cookie-bytes url [decode bytes->string/utf-8])
  (let/ec esc
    (define (ignore-this-Set-Cookie) (esc #f))
    (define now (current-seconds))
    
    (match-define (list-rest nvpair unparsed-attributes)
      (string-split (decode set-cookie-bytes) ";"))
    
    (define-values (name value)
      (match (regexp-match nvpair-regexp nvpair)
        [(list all "" v) (ignore-this-Set-Cookie)]
        [(list all n v)  (values n v)]
        [#f (ignore-this-Set-Cookie)]))

    ;;; parsing the unparsed-attributes
    (define-values (domain-attribute path expires max-age secure? http-only?)
      (parse-cookie-attributes unparsed-attributes url))   

    (define-values (host-only? domain)
      (let ([request-host (url-host url)])
        (cond
          [domain-attribute
           (when (or (string=? domain-attribute "")
                     (not (domain-match? domain-attribute request-host)))
             (ignore-this-Set-Cookie))
           (values #f domain-attribute)]
          [else
           (values #t request-host)])))
    (define-values (persistent? expiry-time)
      (cond [max-age (values #t (if (positive? max-age)
                                    (+ now max-age)
                                    min-cookie-seconds))]
            [expires (values #t (max min-cookie-seconds
                                     (min max-cookie-seconds
                                          (date->seconds expires))))]
            [else    (values #f max-cookie-seconds)]))

    (ua-cookie name value
               ;; TODO: allow UA to "reject public suffixes", sec 5.3
               domain
               (or path (default-path url))
               expiry-time now now
               persistent? host-only? secure? http-only?)))

;; parse-cookie-attributes :
;;   bytes? url? -> (values (Option string?) (Option string?)
;;                          (Option Nat) (Option Nat)
;;                           Bool Bool)
(define (parse-cookie-attributes unparsed-attributes url)
  (for/fold ([domain #f] [path #f] [expires #f] [max-age #f]
             [secure? #f] [http-only? #f])
            ([cookie-av unparsed-attributes])
    (cond
      [(equal? cookie-av "") ; skip blank a/v pairs
       (values domain path expires max-age secure? http-only?)]
      [else
       (define-values (name value)
         (match (regexp-match nvpair-regexp cookie-av)
           [(list all n v) (values n v)]
           [#f             (values (string-trim cookie-av) "")]))
       (case (string-downcase name)
         [("expires")
          (values domain path (parse-date value) max-age secure? http-only?)]
         [("max-age")
          (values domain path expires
                  (if (regexp-match #px"^-?\\d+$" value)
                      (string->number value)
                      max-age)
                  secure? http-only?)]
         [("domain")
          (values (cond
                    [(string=? value "") domain] ; don't set domain now
                    [(char=? (string-ref value 0) #\.)
                     (string-downcase (substring value 1))]
                     [else (string-downcase value)])
                  path expires max-age secure? http-only?)]
         [("path")
          (values domain
                  (if (or (string=? value "")
                          (not (char=? (string-ref value 0) #\/)))
                      path ; skip setting path this iteration
                      value)
                  expires max-age secure? http-only?)]
         [("secure")
          (values domain path expires max-age #t http-only?)]
         [("httponly")
          (values domain path expires max-age secure? #t)]
         [else
          (values domain path expires max-age secure? http-only?)])])))

;; Regexp for matching an equals-sign-delimited name-value pair,
;; and trimming it of whitespace:
(define nvpair-regexp #px"^\\s*(.*?)\\s*=\\s*(.*)\\s*$")

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; Dates ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

;;;; Constant defs for date parsing ;;;;

;; Greatest and least dates this cookie library accepts:
(define max-cookie-seconds (- (expt 2 32) 1))
(define min-cookie-seconds (- max-cookie-seconds))

;; Characters used as delimiters between parts of a date string
;; used in the "Expires" attribute.
(define (range+ a b) (cons b (range a b)))
(define delimiter `(#x09 ,@(range+ #x20 #x2F)
                         ,@(range+ #x3B #x40)
                         ,@(range+ #x5B #x60)
                         ,@(range+ #x7B #x7E)))

(define month-names
  `("jan" "feb" "mar" "apr" "may" "jun" "jul" "aug" "sep" "oct" "nov" "dec"))

;; string? -> date?
;; As specified in section 5.1.1 of RFC6265.
(define (parse-date str)
  (let/ec escape
    (define (fail) (escape #f))

    (define tokens
      (let ()
        (define-values (acc current)
          (for/fold ([acc null] [current null])
                    ([ch (in-string str)])
            (if (memv (char->integer ch) delimiter)
                (values (cons (list->string (reverse current)) acc) null)
                (values acc (cons ch current)))))
        (reverse (if (null? current)
                     acc
                     (cons (list->string (reverse current)) acc)))))

    ;; String -> (Option (List Int[0,23] Int[0,59] Int[0,59]))
    (define (parse-time str)
      (match (regexp-match #px"^(\\d\\d?):(\\d\\d?):(\\d\\d?)\\D*$" str)
        [(list _ hs ms ss)
         (define-values (h m s)
           (apply values (map string->number (list hs ms ss))))
         (if (and (<= h 23) (<= m 59) (<= s 59))
             (list h m s)
             (fail))] ; malformed time
        [_ #f]))

    (define (parse-day str) ; String -> (Option Int[1,31])
      (match (regexp-match #px"^(\\d\\d?)\\D*$" str)
        [(list _ day) (string->number day)]
        [_ #f]))

    (define (parse-year str) ; String -> (Option Int[>= 1601])
      (match (regexp-match #px"^(\\d\\d\\d?\\d?)\\D*$" str)
        [(list _ year/s)
         (define year (string->number year/s))
         (cond [(<= 70 year 99) (+ year 1900)]
               [(<= 0 year 69)  (+ year 2000)]
               [(< year 1601)   (fail)]
               [else            year])]
        [_ #f]))

    (define (parse-month str) ; String -> Int[1,12]
      (cond
        [(>= (string-length str) 3)
         (define prefix (string-downcase (substring str 0 3)))
         (for/or ([m (in-list month-names)]
                  [n (in-naturals)])
           (if (string=? m prefix) (add1 n) #f))]
        [else #f]))

    (define-values (time day month year)
      (for/fold ([time #f] [day #f] [month #f] [year #f])
                ([token (in-list tokens)])
        (cond
          [(and (not time) (parse-time token))
           => (λ (time)  (values time day month year))]
          [(and (not day) (parse-day token))
           => (λ (day)   (values time day month year))]
          [(and (not month) (parse-month token))
           => (λ (month) (values time day month year))]
          [(and (not year) (parse-year token))
           => (λ (year)  (values time day month year))]
          [else (values time day month year)])))

    (if time
       (let-values ([(hour minute second) (apply values time)])
         ;; Last check: fail if day is not OK for given month:
         (and day month year
              (<= day (case month
                        [(1 3 5 7 8 10 12)  31]
                        [(2)                29]
                        [(4 6 9 11)         30]
                        [else           (fail)]))
              (date second minute hour day month
                    year
                    0 0 #f 0)))
       #f)))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; Domains ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

;; String String -> Boolean
;; As specified in section 5.1.3: "A string domain-matches a given
;; domain string if at least one of the following conditions hold..."
;; domain is the "domain string", and host is the string being tested.
(define (domain-match? domain host)
  (define diff (- (string-length host) (string-length domain)))
  (or (string=? (string-downcase domain) (string-downcase host))
      (and (diff . >= . 0)
           (string=? domain (substring host diff))
           (or (= diff 0) (char=? (string-ref host (sub1 diff)) #\.))
           (not (regexp-match #px"\\.\\d\\d?\\d?$" host)))))

;;;; As spec'd in section 5.1.4:

;; url? -> string?
;; compute the default-path of a cookie, for use in creating the ua-cookie struct
;; when parsing a Set-Cookie header.
(define (default-path url)
  (define uri-path
    (string-append "/" (string-join (map path/param-path (url-path url)) "/")))
  (if (or (= (string-length uri-path) 0)
          (not (char=? (string-ref uri-path 0) #\/)))
      "/"
      (let ([last-slash-pos (string-index-right uri-path #\/)])
        (if (= last-slash-pos 0) ; uri-path contains only one slash
            "/"
            (substring uri-path 0 last-slash-pos)))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; Paths ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

;; path-match? : String (U URL String) -> Boolean
;; Does the URL's (typically the one to which the UA is sending the request)
;; request-path path-match the given cookie-path?
(define (path-match? cookie-path url)
  (define (url-full-path url)
    (cond
      [(url? url)
       (string-append "/"
                      (string-join (map path/param-path (url-path url)) "/"))]
      [else (url-full-path (string->url url))]))
  (define request-path
    (cond
      [(string? url) url]
      [(url? url)    (url-full-path url)]))
  (define cookie-len (string-length cookie-path))
  (define request-path-len (string-length request-path))
  
  (and (<= cookie-len request-path-len)
       (string=? (substring request-path 0 cookie-len) cookie-path)
       (or (char=? (string-ref cookie-path (sub1 cookie-len)) #\/)
           (and (< cookie-len request-path-len)
                (char=? (string-ref request-path cookie-len) #\/)))))


