#!/bin/zsh
# 
# ZWS 1.0
#
# Copyright  2004, Adam Chodorowski. All rights reserved.
# This file is part of the ZWS program, which is distributed under
# the terms of version 2 of the GNU General Public License.
#
# $Id: ZWS,v 1.21 2004/05/26 22:24:25 adam Exp $

zmodload zsh/stat     || exit 1
zmodload zsh/datetime || exit 1
zmodload zsh/net/tcp  || exit 1
autoload -U tcp_proxy

export TZ=GMT

# Change commenting to enable debug output.
#alias debug='echo -E >>$log'
alias debug='echo -E >/dev/null'

#----------------------------------------------------------------------------
# Reads a single line, removing any CR characters (since clients normally
# send CRLF as end-of-line).

get_line()
{
    local line
    read -r line
    echo -E "$line" | tr -d "\r"
}

#----------------------------------------------------------------------------
# Writes a single line, using CRLF as end-of-line.
# $* = line to write

put_line()
{
    echo -nE "$*"
    echo -e  "\r"
}

#----------------------------------------------------------------------------
# Parses command line options.
# $* = options

declare port
declare root
declare log

parse_options()
{
    o_port=(-p 4280)
    o_root=(-r WWW)
    o_log=(-d ZWS.log)

    zparseopts -K -- p:=o_port r:=o_root h=o_help
    if [[ $? != 0 || "$o_help" != "" ]]; then
        echo Usage: $(basename "$0") "[-p PORT] [-r DIRECTORY]"
        exit 1
    fi

    port=$o_port[2]
    root=$o_root[2]
    log=$o_log[2]

    if [[ $root[1] != '/' ]]; then root="$PWD/$root"; fi
}

#----------------------------------------------------------------------------
# Identifies the mime-type of a file.
# $1 = path to file

identify()
{
    case "$1" in
        *.css) echo text/css;;
        *)     file -ibL "$1";;
    esac
}

#----------------------------------------------------------------------------
# Parses HTTP Range header field.
# Assumes headers have been parsed and are stored in r_headers.

declare r_range_type
declare r_range_start
declare r_range_end

parse_range()
{
    local string=$(echo "$r_headers[Range]" | tr -d "\ ")
    if [[ ! -z "$string" ]]; then
        r_range_type=$(echo "$string" | cut -f1 -d\=)
        r_range_start=$(echo "$string" | cut -f2 -d\= | cut -f1 -d-) 
        r_range_end=$(echo "$string" | cut -f2 -d\= | cut -f2 -d-) 
    fi
}

#----------------------------------------------------------------------------
# Sends file to client.
# $1 = path to file
# $2 = optional response code and message

send_file()
{
    if [[ ! -z "$r_version" ]]; then
        local length=$(stat +size "$1")   # complete length of file
        local count="$length"             # amount of bytes to actually send
        local skip=0                      # amount of bytes to skip
        
        if [[ -z "$2" ]]; then
            parse_range
            debug hdr_rng_start $r_range_start
            debug hdr_rng_end $r_range_end
            if [[ "$r_range_type" == "bytes" ]]; then
                if [[ ! -z "$r_range_start" ]]; then
                    skip=$(($r_range_start))
                fi

                if [[ ! -z "$r_range_end" ]]; then
                    count=$(($r_range_end - $r_range_start + 1))  
                else
                    count=$(($length - $skip))
                fi
                debug snd_206
                put_line HTTP/1.1 206 Partial Content
            else
                debug snd_200
                put_line HTTP/1.0 200 OK
            fi
        else
            debug snd_custom
            put_line "$2"
        fi
        
        put_line Connection: Close
        put_line Accept-Ranges: bytes
        put_line Date: $(date +"%a, %d %b %Y %H:%M:%S")
        put_line Last-Modified: $(strftime "%a, %d %b %Y %H:%M:%S GMT" $(stat +mtime "$1"))
        put_line Content-Type: $(identify "$1")
        put_line Content-Length: "$count"

        if [[ "$r_range_type" == "bytes" ]]; then
            put_line Content-Range: ${skip}-$((${count} + ${skip} - 1))/${length}
        fi
        
        put_line
        
        if [[ "$r_range_type" == "bytes" ]]; then
            dd if="$1" bs=1 skip=$skip count=$count 2>/dev/null
            return
        fi
    fi
    
    dd if="$1" 2>/dev/null
}

#----------------------------------------------------------------------------
# Sends error message to client.
# $1 = error code

send_error()
{
    local message

    if [[ ! -z "$r_version" ]]; then
        local description
    
        case "$1" in
            400) description="Bad Request";;
            404) description="Not Found";;
            501) description="Method Not Implemented";;
        esac
        
        message="HTTP/1.0 $1 $description" 
    fi

    send_file "errors/$1" "$message"
}

#----------------------------------------------------------------------------
# Canonicalizes paths and replaces character entities.
# $1 - path to canonicalize

# FIXME:
#
# 1) /foo/../ -> /  
# 2) /./      -> /
# 3) //       -> /
#
# sed commands:
# 1) sed -e 's%/[^/]*/\.\./%/%'
# 2) sed -e 's%/\./%/%'
# 3) sed -e 's%//%/%'


make_replace_re()
{
    local    rs
    local -i c_tab=0x09 c_and=0x26 c_slash=0x2f c_bslash=0x5c

    for ((i = $1; i <= $2; i += 1)); do
        if   [[ $i == $c_tab ]]; then
            rs+='s/%09/\t/;'
        elif [[ $i == $c_and || $i == $c_slash || $i == $c_bslash ]]; then
            rs+="$(printf 's/%%%02x/\%b/;' $i $(printf '\\x%02x' $i))"
        else
            rs+="$(printf 's/%%%02x/%b/;' $i $(printf '\\x%02x' $i))"
        fi
    done
    echo -E "$rs"
}

canonicalize()
{
    if [[ -z "$replace_re" ]]; then
        local r0="$(make_replace_re 0x09 0x09)"
        local r1="$(make_replace_re 0x20 0x7f)"
        local r2="$(make_replace_re 0xa0 0xff)"
        replace_re="$r0$r1$r2"
    fi

    echo -E "$1" | sed -e "$replace_re"
}

#----------------------------------------------------------------------------
# Parses the request line and headers.

declare    r_method  
declare    r_path
declare    r_version
declare -A r_headers

parse_request()
{
    request_line=$(get_line)   
    debug "$request_line"

    r_method=$(echo "$request_line" | cut -f1 -d\ )
    r_path=$(echo "$request_line" | cut -f2 -d\ )
    r_version=$(echo "$request_line" | cut -f3 -d\ )

    if [[ ! -z "$r_version" ]]; then
        # Parse HTTP/1.0+ headers
        while true; do
            header_line=$(get_line)
            debug hdr "$header_line"
            if [[ -z "$header_line" ]]; then break; fi
            
            key=$(echo $header_line | cut -f1 -d:)
            value=$(echo $header_line | cut -f2- -d:)
            r_headers+=($key $value)
        done 
    fi
}

#----------------------------------------------------------------------------
# Handles a single connection.

serve()
{
    parse_request
    
    if [[ "$r_method" != "GET" ]]; then
        send_error 501
        return
    fi

    debug rpl_pre "$r_path"
    r_path=$(canonicalize "$r_path")
    debug rpl_post "$r_path"
    echo "$r_path" | grep "\.\." >/dev/null 2>&2
    if [[ $? == 0 ]]; then
        send_error 400
        return
    fi    
    
    if [[ "$r_path" != "" ]]; then
        # Append index.html if the path has a trailing '/'
        # and there is no regular file with that name.
        if [[ ! -f "$root/$r_path" && "$r_path[-1]" == "/" ]]; then
            r_path="${r_path}index.html"
        fi

        if [[ -f "$root/$r_path" ]]; then
            send_file "$root/$r_path"
        else
            send_error 404
        fi
    fi
}

parse_options $*
tcp_proxy $port serve

