sourceafButter::HttpResponseHeaders.fan

using web::Cookie
using web::WebUtil

** A wrapper for HTTP response headers with accessors for commonly used headings.
** Accessors return 'null' if the header doesn't exist, or isn't encoded properly.
** 
** @see `https://en.wikipedia.org/wiki/List_of_HTTP_header_fields`
class HttpResponseHeaders {
    private static const Int CR  := '\r'
    private static const Int LF  := '\n'
    private static const Int maxTokenSize := 4096

    ** Rather than this list of keyVals, I could have a Str map whose vals are either Str or Str[],
    ** (like the mailgun API) - but I can't think of any real advantages of this to warrant changing the code!?
    @NoDoc
    const KeyVal[]  keyVals 

    ** it-block ctor.
    new make(|This| in) {
        in(this)
    }
    
    ** Creates 'HttpResponseHeaders' copying over values in the given map. 
    new makeFromMap([Str:Str]? headers := null) {
        this.keyVals = convertMap(headers)
    }

    ** Parses headers from the given InStream. 
    new makeFromInStream(InStream in) {
        this.keyVals = parseHeaders(in)
    }

    ** Tells all caching mechanisms from server to client whether they may cache this object. It is 
    ** measured in seconds.
    ** 
    **   Cache-Control: max-age=3600
    Str? cacheControl {
        get { getFirst("Cache-Control") }
        private set { }
    }

    ** The type of encoding used on the data.
    ** 
    **   Content-Encoding: gzip
    Str? contentEncoding {
        get { getFirst("Content-Encoding") }
        private set { }
    }

    ** Usually used to direct the client to display a 'save as' dialog.
    ** 
    **   Content-Disposition: Attachment; filename=example.html
    ** 
    ** @see `http://tools.ietf.org/html/rfc6266`
    Str? contentDisposition {
        get { getFirst("Content-Disposition") }
        private set { }
    }

    ** The length of the response body in octets (8-bit bytes).
    ** 
    **   Content-Length: 348
    Int? contentLength {
        get { makeIfNotNull("Content-Length") { Int.fromStr(it, 10, false) }}
        private set { }
    }

    ** Mitigates XSS attacks by telling browsers to restrict where content can be loaded from.
    ** 
    **   Content-Security-Policy: default-src 'self'; font-src 'self' https://fonts.googleapis.com/; object-src 'none'
    [Str:Str]? contentSecurityPolicy {
        get { makeIfNotNull("Content-Security-Policy") {
            it.split(';').reduce(Str:Str[:] { it.ordered=true }) |Str:Str map, Str dir->Obj| {
                vals := dir.split(' ')
                map[vals.first] = vals[1..-1].join(" ")
                return map
            }
        }}
        private set { }
    }

    ** Similar to `contentSecurityPolicy` only violations aren't blocked, just reported. Useful for development / testing.
    ** 
    **   Content-Security-Policy-Report-Only: default-src 'self'; font-src 'self' https://fonts.googleapis.com/; object-src 'none'
    [Str:Str]? contentSecurityPolicyReportOnly {
        get { makeIfNotNull("Content-Security-Policy-Report-Only") {
            it.split(';').reduce(Str:Str[:] { it.ordered=true }) |Str:Str map, Str dir->Obj| {
                vals := dir.split(' ')
                map[vals.first] = vals[1..-1].join(" ")
                return map
            }
        }}
        private set { }
    }

    ** The MIME type of this content.
    ** 
    **   Content-Type: text/html; charset=utf-8
    MimeType? contentType {
        get { makeIfNotNull("Content-Type") { MimeType(it, false) }}
        private set { }
    }

    ** An identifier for a specific version of a resource, often a message digest.
    ** 
    **   ETag: "737060cd8c284d8af7ad3082f209582d"
    Str? eTag {
        get { makeIfNotNull("ETag") { WebUtil.fromQuotedStr(it) }}
        private set { }
    }
    
    ** Gives the date/time after which the response is considered stale.
    ** 
    **   Expires: Thu, 01 Dec 1994 16:00:00 GMT
    DateTime? expires {
        get { makeIfNotNull("Expires") { DateTime.fromHttpStr(it, false)} }
        private set { }
    }

    ** The last modified date for the requested object, in RFC 2822 format.
    ** 
    **   Last-Modified: Tue, 15 Nov 1994 12:45:26 +0000
    DateTime? lastModified {
        get { makeIfNotNull("Last-Modified") { DateTime.fromHttpStr(it, false)} }
        private set { }
    }

    ** Used in redirection, or when a new resource has been created.
    ** 
    **   Location: http://www.w3.org/pub/WWW/People.html
    Uri? location {
        // moar lenient URL decoding - not all URLs are % encoded
        get { makeIfNotNull("Location") { Uri.decode(it, false) ?: Uri(it, false) } }
        private set { }
    }

    ** Implementation-specific headers.
    ** 
    **   Pragma: no-cache
    Str? pragma {
        get { getFirst("Pragma") }
        private set { }
    }

    ** Tells browsers how and when to transmit the HTTP 'Referer' (sic) header. 
    ** 
    **   Referrer-Policy: same-origin
    Str? referrerPolicy {
        get { getFirst("Referrer-Policy") }
        private set { }
    }

    ** Tells browsers to always use HTTPS. 
    ** 
    **   Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
    Str? strictTransportSecurity {
        get { getFirst("Strict-Transport-Security") }
        private set { }
    }

    ** HTTP cookies previously sent by the server with 'Set-Cookie'. 
    ** 
    **   Set-Cookie: UserID=JohnDoe; Max-Age=3600
    Cookie[]? setCookies {
        get { 
            cookies := getAll("Set-Cookie").map |cookieValue->Cookie| {
                cName   := (Str?) null 
                cValue  := (Str?) null
                nameValue := ""
                values := [Str:Str?][:] { caseInsensitive = true }
                cookieValue.split(';').each |value, i| {
                    pair := value.split('=')
                    if (i == 0) {
                        cName = pair[0]
                        cValue = pair.getSafe(1) ?: ""
                        if (cValue.startsWith("\""))
                            cValue = WebUtil.fromQuotedStr(cValue)
                    } else 
                        values[pair[0]] = pair.getSafe(1)
                }
                return Cookie(cName, cValue) {
                    it.maxAge   = values.containsKey("Max-Age") ? Duration.fromStr(values["Max-Age"] + "sec", false) : null  
                    it.domain   = values["Domain"]  
                    it.path     = values["Path"]  
                    it.secure   = values.containsKey("Secure")
                }
            }
            return cookies.isEmpty ? null : cookies
        }
        private set { }
    }

    ** WWW-Authenticate header to indicate supported authentication mechanisms.
    ** 
    **   WWW-Authenticate: SCRAM hash=SHA-256
    Str? wwwAuthenticate {
        get { getFirst("WWW-Authenticate") }
        private set { }
    }
    
    ** Tells browsers to trust the 'Content-Type' header. 
    ** 
    **   X-Content-Type-Options: nosniff
    Str? xContentTypeOptions {
        get { getFirst("X-Content-Type-Options") }
        private set { }
    }

    ** Clickjacking protection, set to:
    **  - 'deny' - no rendering within a frame, 
    **  - 'sameorigin' - no rendering if origin mismatch
    ** 
    **   X-Frame-Options: deny
    Str? xFrameOptions {
        get { getFirst("X-Frame-Options") }
        private set { }
    }

    ** Cross-site scripting (XSS) filter.
    ** 
    **   X-XSS-Protection: 1; mode=block
    Str? xXssProtection {
        get { getFirst("X-XSS-Protection") }
        private set { }
    }

    ** Returns the first header with the given name. (case-insensitive)
    @Operator
    Str? getFirst(Str name) {
        keyVals.find { it.key.equalsIgnoreCase(name) }?.val
    }

    ** Returns all header with the given name. (case-insensitive)
    Str[] getAll(Str name) {
        keyVals.findAll { it.key.equalsIgnoreCase(name) }.map |kv->Str| { kv.val }
    }

    @NoDoc @Deprecated { msg="Use 'val' instead." }
    Str:Str map() { val }

    ** Returns a read-only case insensitive map of the headers.
    Str:Str val() {
        (keyVals.reduce(Str:Str[:] { it.caseInsensitive = true}) |Str:Str map, kv -> Str:Str| {
            map[kv.key] = map.containsKey(kv.key) ?  map[kv.key] + "," + kv.val : kv.val
        } as Str:Str).ro
    }

    ** Iterates over the headers.
    Void each(|Str val, Str key| c) {
        keyVals.each { c(it.val, it.key) }
    }

    @NoDoc
    KeyVal[] convertMap([Str:Str]? headers) {
        keyVals := KeyVal[,]
        headers?.each |val, key| { keyVals.add(KeyVal(key, val)) }
        return keyVals
    }

    @NoDoc
    override Str toStr() {
        val.toStr
    }
    
    private Obj? makeIfNotNull(Str name, |Str->Obj?| func) {
        val := getFirst(name)
        return (val == null) ? null : func(val)
    }

    private static KeyVal[] parseHeaders(InStream in) {
        keyVals := KeyVal[,]
        while (true) {
            peek := in.peek
        
            // CRLF is end of headers
            if (peek == CR) break
            
            // if line starts with space it's a continuation of the last header field
            if (peek.isSpace && !keyVals.isEmpty) {
                last := keyVals.removeAt(-1)
                keyVals.add(KeyVal(last.key, last.val + " " + in.readLine.trim))
                continue
            }
        
            // key/value pair
            key := token(in, ':').trim
            val := token(in, CR).trim
            if (in.read != LF)
                throw ParseErr("Invalid CRLF line ending")
        
            keyVals.add(KeyVal(key, val))
        }
        
        // consume final CRLF
        if (in.read != CR || in.read != LF)
            throw ParseErr("Invalid CRLF headers ending")
        
        return keyVals
    }

    ** Read the next token from the stream up to the specified separator. 
    ** We place a limit of 4096 bytes on a single token.
    ** Consume the separate char too.
    private static Str token(InStream in, Int sep) {
        // read up to separator
        tok := in.readStrToken(maxTokenSize) |Int ch->Bool| { return ch == sep }
        
        // sanity checking
        if (tok == null) throw IOErr("Unexpected end of stream")
        if (tok.size >= maxTokenSize) throw ParseErr("Token too big")
        
        // read separator
        in.read
        
        return tok
    }   
}

@NoDoc
const class KeyVal {
    const Str key
    const Str val
    new make(Str key, Str val) {
        this.key = key
        this.val = val
    }
    @NoDoc
    override Str toStr() { "$key = $val" }
}