| # An HTTP server and web framework for Jim Tcl. |
| # Copyright (C) 2014, 2015, 2016 dbohdan. |
| # License: MIT |
| namespace eval ::http { |
| source mime.tcl |
| |
| variable version 0.15.1 |
| |
| variable verbosity 0 |
| variable crashOnError 0 |
| variable maxRequestLength [expr 16*1024*1024] |
| variable routes {} |
| # A lambda run by ::http::serve before any communication with the client |
| # happens over a newly established connection's channel. Use |
| # [upvar 1 channel channel] to access the channel from the lambda. |
| variable newConnectionLambda {{} {}} |
| |
| variable statusCodePhrases [dict create {*}{ |
| 100 Continue |
| 200 OK |
| 201 {Created} |
| 301 {Moved Permanently} |
| 400 {Bad Request} |
| 401 {Unauthorized} |
| 403 {Forbidden} |
| 404 {Not Found} |
| 405 {Method Not Allowed} |
| 413 {Request Entity Too Large} |
| 500 {Internal Server Error} |
| }] |
| |
| variable requestFormat [dict create {*}{ |
| Accept: accept |
| Accept-Charset: acceptCharset |
| Accept-Encoding: acceptEncoding |
| Accept-Language: acceptLanguage |
| Connection: connection |
| Content-Disposition: contentDisposition |
| Content-Length: contentLength |
| Content-Type: contentType |
| Cookie: cookie |
| Expect: expect |
| Host: host |
| Referer: referer |
| User-Agent: userAgent |
| }] |
| |
| variable cookieFields [dict create {*}{ |
| Domain domain |
| Path path |
| Expires expires |
| Max-Age maxAge |
| Secure secure |
| HttpOnly httpOnly |
| }] |
| variable cookieFieldsInv [lreverse $::http::cookieFields] |
| variable cookieDateFormat {%a, %d-%b-%Y %H:%M:%S GMT} |
| |
| variable requestFormatLowerCase {} |
| foreach {key value} $requestFormat { |
| dict set requestFormatLowerCase [string tolower $key] $value |
| } |
| |
| variable methods [list {*}{ |
| OPTIONS GET HEAD POST PUT DELETE TRACE CONNECT |
| }] |
| |
| # A list of lambdas. Each lambda takes a response body, a list of response |
| # headers and a list of request headers and return a list consisting of an |
| # updated response body and a list of updated response headers. Can be used |
| # to implement, e.g., compression. Applied in order. |
| variable responseFilters {} |
| |
| # Sample filters. To active a filter add it to responseFilters. |
| variable sampleFilters {} |
| # Perform GZip compression of the content using an external gzip binary. |
| dict set sampleFilters gzipExternal {{body responseHeaders request} { |
| if {[dict exists $request acceptEncoding] && |
| [string match *gzip* $request(acceptEncoding)]} { |
| dict set responseHeaders contentEncoding gzip |
| set body [exec gzip << $body] |
| } |
| return [list $body $responseHeaders] |
| }} |
| # Perform GZip compression of the content using the zlib module. |
| dict set sampleFilters gzipInternal {{body responseHeaders request} { |
| if {[dict exists $request acceptEncoding] && |
| [string match *gzip* $request(acceptEncoding)]} { |
| dict set responseHeaders contentEncoding gzip |
| set body [zlib gzip $body] |
| } |
| return [list $body $responseHeaders] |
| }} |
| # Perform Deflate compression of the content using the zlib module. |
| dict set sampleFilters deflateInternal {{body responseHeaders request} { |
| if {[dict exists $request acceptEncoding] && |
| [string match *deflate* $request(acceptEncoding)]} { |
| dict set responseHeaders contentEncoding deflate |
| set body [zlib deflate $body] |
| } |
| return [list $body $responseHeaders] |
| }} |
| } |
| |
| # Return the text of an HTTP response with the body $body. |
| proc ::http::make-response {body {headers {}} {request {}}} { |
| set ::http::responseTemplate \ |
| {HTTP/1.1 $headers(code) $::http::statusCodePhrases($headers(code)) |
| Content-Type: $headers(contentType) |
| Content-Length: $length} |
| |
| set ::http::headerDefaults [dict create {*}{ |
| code 200 |
| contentType text/html |
| }] |
| |
| set headers [dict merge $::http::headerDefaults $headers] |
| |
| # Handle response processing, e.g., compression. |
| foreach lambda $::http::responseFilters { |
| lassign [apply $lambda $body $headers $request] body headers |
| } |
| |
| set length [string bytelength $body] |
| |
| set response [subst $::http::responseTemplate] |
| |
| # TODO: Generalize for other possible fields in the headers. |
| if {[dict exists $headers cookies]} { |
| foreach cookie $headers(cookies) { |
| append response "\nSet-Cookie: [::http::make-cookie $cookie]" |
| } |
| } |
| if {[dict exists $headers contentEncoding]} { |
| append response \ |
| "\nContent-Encoding: [dict get $headers contentEncoding]" |
| } |
| |
| append response "\n\n$body" |
| return $response |
| } |
| |
| # Write $message to stdout if $level <= $::http::verbosity. Levels 0 and lower |
| # are for errors that are always reported. |
| proc ::http::log {level message} \ |
| [list [list levelNumber [dict create {*}{ |
| debug 3 info 2 warning 1 error 0 critical -1 |
| }]]] { |
| set levelNumber |
| |
| if {$levelNumber($level) <= $::http::verbosity} { |
| puts [format "%-9s %s" "[string toupper $level]:" $message] |
| } |
| } |
| |
| # From http://wiki.tcl-lang.org/14144. |
| proc ::http::uri-decode str { |
| # rewrite "+" back to space |
| # protect \ from quoting another '\' |
| set str [string map [list + { } "\\" "\\\\"] $str] |
| |
| # prepare to process all %-escapes |
| regsub -all -- {%([A-Fa-f0-9][A-Fa-f0-9])} $str {\\u00\1} str |
| |
| # process \u unicode mapped chars |
| return [subst -novar -nocommand $str] |
| } |
| |
| # Decode a POST/GET form. |
| # string -> dict |
| proc ::http::form-decode {formData} { |
| set result {} |
| foreach x [split $formData &] { |
| lassign [lmap y [split $x =] { uri-decode $y }] key value |
| dict set result $key $value |
| } |
| return $result |
| } |
| |
| # Return the content up to but not including $separator in variable |
| # $stringVarName. Remove this content and the separator following it from the |
| # $stringVarName. If $separator isn't in $stringVarName's value return the whole |
| # string. |
| proc ::http::string-pop {stringVarName separator} { |
| upvar 1 $stringVarName str |
| set substrLength [string first $separator $str] |
| if {$substrLength > -1} { |
| set substr [string range $str 1 $substrLength-1] |
| set str [string range $str $substrLength+[string length $separator] end] |
| } else { |
| set substr $str |
| set str {} |
| } |
| return $substr |
| } |
| |
| # Parse a cookie dict in the format of |
| # {{name somecookie value "some value" expires 1727946435 domain foo path / |
| # secure 0 httpOnly 1} ...} into an HTTP header Set-Cookie value. |
| proc ::http::make-cookie cookieDict { |
| set result {} |
| append result "$cookieDict(name)=$cookieDict(value)" |
| dict unset cookieDict name |
| dict unset cookieDict value |
| foreach {field value} $cookieDict { |
| if {($field eq "secure") || ($field eq "httpOnly")} { |
| if {$value} { |
| append result "; $::http::cookieFieldsInv($field)" |
| } |
| } else { |
| append result "; $::http::cookieFieldsInv($field)" |
| if {$field eq "expires"} { |
| # TODO: adjust for the local timezone. clock format does not yet |
| # support the -gmt switch in Jim Tcl. |
| append result "=[clock format $value \ |
| -format $::http::cookieDateFormat]" |
| } else { |
| append result "=$value" |
| } |
| } |
| } |
| return $result |
| } |
| |
| # Parse HTTP request headers presented as a list of lines into a dict. |
| proc ::http::parse-headers {headerLines} { |
| set headers {} |
| set field {} |
| set value {} |
| |
| foreach line $headerLines { |
| # Split $line on its first space. |
| regexp {^(.*?) (.*)$} $line _ field value |
| ::http::log debug [list $line] |
| |
| if {[lsearch -exact $::http::methods $field] > -1} { |
| dict set headers method $field |
| lassign [split [lindex [split $value] 0] ?] headers(url) formData |
| dict set headers form [form-decode $formData] |
| } else { |
| # Translate "Content-Type:" to "contentType", etc. |
| set field [string tolower $field] |
| if {$field eq "cookie:"} { |
| if {![dict exists $headers cookies]} { |
| dict set headers cookies {} |
| } |
| dict set headers cookies [dict merge $headers(cookies) \ |
| [::http::parse-value $value]] |
| } elseif {[dict exists $::http::requestFormatLowerCase $field]} { |
| dict set headers $::http::requestFormatLowerCase($field) $value |
| } |
| } |
| } |
| return $headers |
| } |
| |
| # Convert an HTTP request value of type {string;key1=value1; key2="value2"} to |
| # dict. |
| proc ::http::parse-value {str} { |
| set result {} |
| foreach x [split $str ";"] { |
| set x [string trimleft $x " "] ;# For "; ". |
| if {[regexp {(.*?)="?([^"]*)"?} $x _ name value]} { |
| dict set result $name $value |
| } else { |
| dict set result $x 1 |
| } |
| } |
| return $result |
| } |
| |
| # Return the files and formPost fields in encoded in a multipart/form-data form. |
| # Very hacky. |
| proc ::http::parse-multipart-data {postString contentType newline} { |
| set result {} |
| if {[catch {set boundary [dict get \ |
| [::http::parse-value $contentType] boundary]}]} { |
| error {no boundary specified in Content-Type} |
| } |
| set boundaryLength [string length $boundary] |
| while {[set part [string-pop postString $boundary]] ne {}} { |
| set partHeader [::http::parse-headers \ |
| [split [string-pop part "$newline$newline"] $newline]] |
| # Trim "(\r)\n--" in content. |
| set part [string range $part 0 end-[string length "$newline--"]] |
| if {$part ne {}} { |
| set m [::http::parse-value $partHeader(contentDisposition)] |
| if {[dict exists $m form-data] && |
| [dict exists $m name]} { |
| # Store files and form fields separately. |
| if {[dict exists $m filename]} { |
| dict set result files \ |
| $m(name) filename $m(filename) |
| dict set result files \ |
| $m(name) content $part |
| } else { |
| dict set result formPost $m(name) $part |
| } |
| } |
| } |
| } |
| return $result |
| } |
| |
| # Return error responses. |
| proc ::http::error-response {code {customMessage ""} {request {}}} { |
| return [::http::make-response \ |
| "<h1>Error $code: $::http::statusCodePhrases($code)</h1>\ |
| $customMessage" \ |
| [list code $code] \ |
| $request] |
| } |
| |
| # Call ::http::serve. Catch and report any unhandled errors. |
| proc ::http::serve-and-trap-errors {channel clientAddr clientPort} { |
| set error [catch { |
| ::http::serve $channel $clientAddr $clientPort |
| } errorMessage errorOptions] |
| if {$error} { |
| ::http::log critical \ |
| "Unhandled ::http::serve error: $errorMessage." |
| catch {close $channel} |
| if {$::http::crashOnError} { |
| ::http::log info "Exiting due to error." |
| exit 1 |
| } |
| } |
| } |
| |
| # Handle HTTP requests over a channel and send responses. A hacky HTTP |
| # implementation. |
| proc ::http::serve {channel clientAddr clientPort} { |
| # "Preprocess" the channel before anything else is done with it, e.g., to |
| # initiate a TLS connection. |
| apply $::http::newConnectionLambda |
| |
| ::http::log info "Client connected: $clientAddr" |
| |
| set newline \r\n |
| |
| set headerLines {} |
| set firstLine 1 |
| while {[gets $channel buf]} { |
| if {$firstLine} { |
| # Change the newline variable when the incoming request has |
| # nonstandard \n newlines. This happens, e.g., when you use netcat. |
| if {[string index $buf end] ne "\r"} { |
| set newline "\n" |
| ::http::log debug \ |
| {The client uses \n instead of \r\n for newline.} |
| } |
| set firstLine 0 |
| } |
| if {$newline eq "\r\n"} { |
| set buf [string trimright $buf \r] |
| } |
| if {$buf eq {}} { |
| break |
| } |
| lappend headerLines $buf |
| } |
| |
| set request [::http::parse-headers $headerLines] |
| set error 0 |
| |
| if {(![dict exists $request method]) || (![dict exists $request url])} { |
| ::http::log error "Bad request." |
| set error 400 |
| } |
| |
| # Process POST data. |
| if {($error == 0) && ($request(method) eq "POST")} { |
| set request [dict merge {contentType application/x-www-form-urlencoded |
| contentLength 0} $request] |
| |
| if {[string is integer $request(contentLength)] && |
| ($request(contentLength) > 0)} { |
| if {$request(contentLength) <= $::http::maxRequestLength} { |
| if {[dict exists $request expect] && |
| ($request(expect) eq "100-continue")} { |
| puts $channel "HTTP/1.1 100 Continue\n" |
| } |
| |
| set postString [read $channel $request(contentLength)] |
| if {$request(contentType) eq |
| "application/x-www-form-urlencoded"} { |
| ::http::log debug "POST request: {$postString}\n" |
| dict set request formPost [form-decode $postString] |
| } elseif {[string match "multipart/form-data*" \ |
| $request(contentType)]} { |
| ::http::log debug \ |
| "POST request: (multipart/form-data skipped)" |
| # Call ::http::parse-multipart-data to parse the data. |
| set multipartDataError [catch { |
| set request [dict merge $request \ |
| [::http::parse-multipart-data \ |
| $postString \ |
| $request(contentType) \ |
| $newline]] |
| } errorMessage] |
| if {$multipartDataError} { |
| ::http::log error \ |
| "Bad request: multipart/form-data parse error:\ |
| $errorMessage." |
| set error 400 |
| } |
| } else { |
| # Put content of other types (e.g., application/json) into |
| # request(formPost) as is. |
| ::http::log debug \ |
| "POST request: ($request(contentType) skipped)" |
| dict set request formPost $postString |
| } |
| } else { |
| ::http::log error \ |
| "Request too large: $request(contentLength)." |
| set error 413 |
| } |
| } else { |
| ::http::log error "Bad request: Content-Length is invalid\ |
| (\"$request(contentLength)\")." |
| set error 400 |
| } |
| } else { |
| dict set request formPost {} |
| } |
| |
| if {[dict exists $request cookies]} { |
| ::http::log debug "cookies: $request(cookies)" |
| } |
| |
| |
| if {!$error} { |
| ::http::log info "Responding." |
| set matchResult [::http::route $channel $request] |
| lassign $matchResult route |
| if {$matchResult eq {0} || |
| [dict get $::http::routes $route $request(method) close]} { |
| close $channel |
| } |
| } else { |
| puts -nonewline $channel [::http::error-response $error] |
| close $channel |
| } |
| } |
| |
| # Start the HTTP server binding it to $ipAddress and $port. |
| proc ::http::start-server {ipAddress port} { |
| set ::http::serverSocket [socket stream.server $ipAddress:$port] |
| $::http::serverSocket readable { |
| set client [$::http::serverSocket accept addr] |
| ::http::serve-and-trap-errors $client {*}[split $addr :] |
| } |
| ::http::log info "Started server on $ipAddress:$port." |
| vwait ::http::done |
| ::http::log info "The server has shut down." |
| } |
| |
| # Call route handler for the request url if available and pass $channel to it. |
| # Otherwise write a 404 error message to the channel. |
| proc ::http::route {channel request} { |
| # Don't show the contents of large files in the debug message. |
| if {[dict exists $request files] && |
| [string length $request(files)] > 8*1024} { |
| set requestPrime $request |
| dict set requestPrime files "(not shown here)" |
| ::http::log debug "request: $requestPrime" |
| set requestPrime {} |
| } else { |
| ::http::log debug "request: $request" |
| } |
| |
| set url [dict get $request url] |
| if {$url eq {}} { |
| set url / |
| } |
| |
| set matchResult [::http::match-route \ |
| [dict keys $::http::routes] $url] |
| if {$matchResult != 0} { |
| set procName [dict get $::http::routes \ |
| [lindex $matchResult 0] $request(method) handler] |
| $procName $channel $request [lindex $matchResult 1] |
| } else { |
| puts -nonewline $channel [::http::error-response 404] |
| } |
| |
| return $matchResult |
| } |
| |
| # Return route variables contained in the url if it can be parsed as route |
| # $route. Return 0 otherwise. |
| proc ::http::get-route-variables {route url} { |
| set routeVars {} |
| foreach routeSegment [split $route /] urlSegment [split $url /] { |
| if {[string index $routeSegment 0] eq ":"} { |
| dict set routeVars [string range $routeSegment 1 end] $urlSegment |
| } else { |
| # Static parts of the URL and the route should be equal. |
| if {$urlSegment ne $routeSegment} { |
| return 0 |
| } |
| } |
| } |
| return $routeVars |
| } |
| |
| # Return the first route out of the list $routeList that matches $url. |
| proc ::http::match-route {routeList url} { |
| foreach route $routeList { |
| set routeVars [::http::get-route-variables $route $url] |
| if {$routeVars != 0} { |
| return [list $route $routeVars] |
| } |
| } |
| return 0 |
| } |
| |
| # Create a proc to handle the route $route with body $script. |
| proc ::http::add-handler {methods routes {statics {}} script} { |
| set procName "handler::${methods}::${routes}" |
| proc $procName {channel request routeVars} $statics $script |
| foreach method $methods { |
| foreach route $routes { |
| dict set ::http::routes $route $method handler $procName |
| dict set ::http::routes $route $method close 1 |
| } |
| } |
| } |
| |
| # Return the contents of $filename. |
| proc ::http::read-file {filename} { |
| set fpvar [open $filename r] |
| fconfigure $fpvar -translation binary |
| set content [read $fpvar] |
| close $fpvar |
| return $content |
| } |
| |
| # Add handler to return the contents of a static file. The file is either |
| # $filename or [file tail $route] if no filename is given. |
| proc ::http::add-static-file {route {filename {}}} { |
| if {$filename eq {}} { |
| set filename [file tail $route] |
| } |
| ::http::add-handler GET $route [list apply {{filename mimeType} { |
| upvar 1 channel channel |
| upvar 1 request request |
| puts -nonewline $channel \ |
| [::http::make-response \ |
| [::http::read-file $filename] \ |
| [list contentType $mimeType] \ |
| $request] |
| }} $filename [::mime::type $filename]] |
| } |
| |
| # A convenience procedure to use from route handlers. |
| proc ::http::respond {response} { |
| upvar 1 channel channel |
| puts -nonewline $channel $response |
| } |