blob: d821858897542e38645fee4ff1fb4d53f8bf613b [file] [log] [blame]
# 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
}