[AMG]: Implementation of the [Wibble] web server: ====== #!/bin/sh # # Wibble - a pure-Tcl Web server. http://wiki.tcl.tk/23626 # Copyright 2009 Andy Goth. mailto:unununium/at/aircanopy/dot/net # Available under the Tcl/Tk license. http://tcl.tk/software/tcltk/license.html # # The next line restarts with tclsh.\ exec tclsh "$0" ${1+"$@"} package require Tcl 8.6 package provide wibble 0.1 # Define the wibble namespace. namespace eval wibble { variable zones {} } # Echo request dictionary. proc wibble::vars {request response} { dict set response status 200 dict set response header content-type text/html dict set response content {} dict for {key val} $request { if {$key in {header query}} { set newval "" dict for {subkey subval} $val { append newval "[list $subkey] [list $subval] " } set val $newval } dict append response content } dict append response content
$key$val
\n sendresponse $response } # Redirect when a directory is requested without a trailing slash. proc wibble::dirslash {request response} { dict with request { if {[file isdirectory $fspath] && [string index $suffix end] ni {/ ""}} { dict set response status 301 dict set response header location $path/$rawquery sendresponse $response } else { nexthandler $request $response } } } # Rewrite directory requests to search for an indexfile. proc wibble::indexfile {request response} { dict with request { if {[file isdirectory $fspath]} { if {[string index $path end] ne "/"} { append path / } set newrequest $request dict set newrequest path $path$indexfile nexthandler $newrequest $response $request $response } else { nexthandler $request $response } } } # Generate directory listings. proc wibble::dirlist {request response} { dict with request { if {![file isdirectory $fspath]} { # Pass if the requested object is not a directory or doesn't exist. nexthandler $request $response } elseif {[file readable $fspath]} { # If the directory is readable, generate a listing. dict set response status 200 dict set response header content-type text/html dict set response content foreach elem [concat [list ..]\ [lsort [glob -nocomplain -tails -directory $fspath *]]] { dict append response content "$elem
" } dict append response content \n sendresponse $response } else { # But if it isn't readable, generate a 403. dict set response status 403 dict set response header content-type text/plain dict set response content Forbidden\n sendresponse $response } } } # Process templates. proc wibble::template {request response} { dict with request { if {[file readable $fspath.tmpl]} { dict set response status 200 dict set response header content-type text/plain dict set response content "" set chan [open $fspath.tmpl] applytemplate "dict append response content" [read $chan] chan close $chan sendresponse $response } else { nexthandler $request $response } } } # Send static files. proc wibble::static {request response} { dict with request { if {![file isdirectory $fspath] && [file exists $fspath]} { dict set response status 200 dict set response contentfile $fspath sendresponse $response } else { nexthandler $request $response } } } # Send a 404. proc wibble::notfound {request response} { dict set response status 404 dict set response header content-type text/plain dict set response content "can't find [dict get $request uri]\n" sendresponse $response } # Apply a template. proc wibble::applytemplate {command template} { set script "" set pos 0 foreach pair [regexp -line -all -inline -indices {^%.*$} $template] { lassign $pair from to set str [string range $template $pos [expr {$from - 2}]] if {$str ne ""} { append script "$command \[" [list subst $str\n] \]\n } append script [string range $template [expr {$from + 1}] $to]\n set pos [expr {$to + 2}] } set str [string range $template $pos end] if {$str ne ""} { append script "$command \[" [list subst $str] \] } uplevel 1 $script } # Get a line of data from a channel. proc wibble::getline {chan} { while {1} { if {[chan gets $chan line] >= 0} { return $line } elseif {[chan pending input $chan] > 4096} { if {[chan gets $chan line] >= 0} { return $line } else { error "line length greater than 4096" } } elseif {[chan eof $chan]} { chan close $chan return -level [info level] } else { yield } } } # Get a block of data from a channel. proc wibble::getblock {chan size} { while {1} { set chunklet [chan read $chan $size] set size [expr {$size - [string length $chunklet]}] append chunk $chunklet if {$size == 0} { return $chunk } elseif {[chan eof $chan]} { chan close $chan return -level [info level] } else { yield } } } # Decode hexadecimal URL encoding. proc wibble::unhex {str} { set pos 0 while {[regexp -indices -start $pos {%([[:xdigit:]]{2})} $str range code]} { set char [binary format H2 [string range $str {*}$code]] set str [string replace $str {*}$range $char] set pos [expr {[lindex $range 0] + 1}] } return $str } # Advance to the next zone handler using the specified request/response list. proc wibble::nexthandler {args} { return -level 2 $args } # Send a response to the client. proc wibble::sendresponse {response} { return -level 2 [list $response] } # Register a zone handler. proc wibble::handle {zone command args} { variable zones dict lappend zones $zone [list $command $args] } # Get an HTTP request from a client. proc wibble::getrequest {chan peerhost peerport} { # The HTTP header uses CR/LF line breaks. chan configure $chan -translation crlf # Parse the first line. regexp {^\s*(\S*)\s+(\S*)\s+(.*?)\s*$} [getline $chan] _ method uri protocol regexp {^([^?]*)(\?.*)?$} $uri _ path query set path [regsub -all {(?:/|^)\.(?=/|$)} [unhex $path] /] while {[regexp -indices {(?:/[^/]*/+|^[^/]*/+|^)\.\.(?=/|$)} $path range]} { set path [string replace $path {*}$range ""] } set path [regsub -all {//+} /$path /] # Start building the request structure. set request [dict create socket $chan peerhost $peerhost peerport\ $peerport method $method uri $uri path $path protocol $protocol\ header {} rawheader {} query {} rawquery $query] # Parse the headers. while {[set line [getline $chan]] ne ""} { dict lappend request rawheader $line if {[regexp {^\s*([^:]*)\s*:\s*(.*?)\s*$} $line _ key val] || ([info exists key] && [regexp {^\s*(.*?)\s*$} $line _ val])} { set key [string tolower $key] if {[dict exists $request header $key]} { set val [dict get $request header $key]\n$val } dict set request header $key $val } } # Parse the query string. foreach elem [split [string range $query 1 end] &] { regexp {^([^=]*)(?:=(.*))?$} $elem _ key val dict set request query [unhex [string map {+ " "} $key]]\ [unhex [string map {+ " "} $val]] } # Get the request body, if there is one. if {$method in {POST PUT}} { if {[dict exists $request header transfer-encoding] && [dict get $request header transfer-encoding] eq "chunked"} { # Receive chunked request body. set data "" while {[scan [getline $chan] %x length] == 1 && $length > 0} { chan configure $chan -translation binary append data [getblock $chan $length] chan configure $chan -translation crlf } } else { # Receive non-chunked request body. chan configure $chan -translation binary set data [getblock $chan [dict get $request header content-length]] chan configure $chan -translation crlf } dict set request content $data } return $request } # Get a response from the zone handlers. proc wibble::getresponse {request} { variable zones set state [list $request [dict create status 500 content "Zone error\n"]] dict set fallback status 501 dict set fallback content "not implemented: [dict get $request uri]\n" dict set fallback header content-type text/plain # Process all zones. dict for {prefix handlers} $zones { set match $prefix if {[string index $match end] ne "/"} { append match / } # Process all handlers in this zone. foreach handler $handlers { lassign $handler command options # Try all request/response pairs against this handler. set i 0 foreach {request response} $state { # Skip this request if it's not for the current zone. set path [dict get $request path] if {$path ne $prefix && ![string equal\ -length [string length $match] $match $path]} { continue } # Inject a few extra keys into the request dict. dict set request prefix $prefix dict set request suffix [string range $path\ [string length $prefix] end] if {[dict exists $options root]} { dict set request fspath\ [dict get $options root]/[dict get $request suffix] } set request [dict merge $request $options] # Invoke the handler and process its outcome. set outcome [{*}$command $request $response] if {[llength $outcome] == 1} { # A response has been obtained. Return it. return [lindex $outcome 0] } elseif {[llength $outcome] % 2 == 0} { # Filter out extra keys from the new request dicts. for {set j 0} {$j < [llength $outcome]} {incr j 2} { lset outcome $j [dict remove [lindex $outcome $j]\ prefix suffix fspath {*}[dict keys $options]] } # Update the state tree and continue processing. set state [lreplace $state $i $i+1 {*}$outcome] } else { error "invalid zone handler outcome" } incr i 2 } } } # Return 501 as default response. return $fallback } # Main connection processing loop. proc wibble::process {socket peerhost peerport} { try { chan configure $socket -blocking 0 while {1} { # Get request from client, then formulate a response to the reqeust. set request [getrequest $socket $peerhost $peerport] set response [getresponse $request] # Get the content size. if {[dict exists $response contentfile]} { set size [file size [dict get $response contentfile]] if {[dict get $request method] ne "HEAD"} { # Open the channel now, to catch errors early. set file [open [dict get $response contentfile]] chan configure $file -translation binary } } elseif {[dict exists $response content]} { dict set response content [encoding convertto iso8859-1\ [dict get $response content]] set size [string length [dict get $response content]] } else { set size 0 } # Try to parse the Range request header if present. set begin 0 set end [expr {$size - 1}] if {[dict exists $request header range] && [regexp {^bytes=(\d*)-(\d*)$} [dict get $request header range]\ _ begin end] && [dict get $response status] == 200} { dict set response status 206 if {$begin eq "" || $begin >= $size} { set begin 0 } if {$end eq "" || $end >= $size || $end < $begin} { set end [expr {$size - 1}] } } # Add content-length and content-range response headers. dict set response header content-length [expr {$end - $begin + 1}] if {[dict get $response status] == 206} { dict set response header content-range "bytes $begin-$end/$size" } # Send the response header to the client. chan puts $socket "HTTP/1.1 [dict get $response status]" dict for {key val} [dict get $response header] { set normalizedkey [lsearch -exact -sorted -inline -nocase { Accept-Ranges Age Allow Cache-Control Connection Content-Disposition Content-Encoding Content-Language Content-Length Content-Location Content-MD5 Content-Range Content-Type Date ETag Expires Last-Modified Location Pragma Proxy-Authenticate Retry-After Server Set-Cookie Trailer Transfer-Encoding Upgrade Vary Via Warning WWW-Authenticate } $key] if {$normalizedkey ne ""} { set key $normalizedkey } foreach line [split $val \n] { chan puts $socket "$key: $line" } } chan puts $socket "" # If requested, send the response content to the client. if {[dict get $request method] ne "HEAD"} { chan configure $socket -translation binary if {[dict exists $response contentfile]} { # Send response content from a file. chan seek $file $begin chan copy $file $socket -size [expr {$end - $begin + 1}] chan close $file } elseif {[dict exists $response content]} { # Send buffered response content. chan puts -nonewline $socket [string range\ [dict get $response content] $begin $end] } } # Flush the outgoing buffer. chan flush $socket } } on error {"" options} { # Log errors and report them to the client, if possible. variable errorcount incr errorcount set message "*** INTERNAL SERVER ERROR (BEGIN #$errorcount) ***\n" append message "time: [clock format [clock seconds]]\n" append message "address: $peerhost\n" if {[info exists request]} { dict for {key val} $request { if {$key eq "content" && [string length $val] > 256} { append message "request $key (len=[string length $val])\n" } elseif {$key in {header query}} { dict for {subkey subval} $val { append message "request $key $subkey: $subval\n" } } else { append message "request $key: $val\n" } } } append message "errorinfo: [dict get $options -errorinfo]\n" append message "*** INTERNAL SERVER ERROR (END #$errorcount) ***\n" log $message catch { set message [encoding convertto iso8859-1 $message] chan configure $socket -translation crlf chan puts $socket "HTTP/1.1 500 Internal Server Error" chan puts $socket "Content-Type: text/plain; charset=utf-8" chan puts $socket "Content-Length: [string length $message]" chan puts $socket "Connection: close" chan puts $socket "" chan configure $socket -translation binary chan puts -nonewline $socket $message } } finally { catch {chan close $socket} } } # Accept an incoming connection. proc wibble::accept {socket peerhost peerport} { chan event $socket readable [namespace code $socket] coroutine $socket process $socket $peerhost $peerport } # Listen for incoming connections. proc wibble::listen {port} { socket -server [namespace code accept] $port } # Log an error. Feel free to replace this procedure as needed. proc wibble::log {message} { chan puts -nonewline stderr $message } # Demonstrate Wibble if being run directly. if {$argv0 eq [info script]} { # Guess the root directory. set root [file normalize [file dirname [info script]]] # Define zone handlers. wibble::handle /vars vars wibble::handle / dirslash root $root wibble::handle / indexfile root $root indexfile index.html wibble::handle / static root $root wibble::handle / template root $root wibble::handle / dirlist root $root wibble::handle / notfound # Start a server and enter the event loop. catch { wibble::listen 8080 vwait forever } } # vim: set sts=4 sw=4 tw=80 et ft=tcl: ====== <> Wibble