#!/usr/bin/env bash PORT=8080 ADDRESS='0.0.0.0' DIR='./static/' DB_PATH="$PWD/db.sqlite" read -d '' -r USAGE <<-EOF Usage: ./server.sh [-p port] [-b addr] [-d dir] Options -h Print this message and exit. -b Address to bind to, defaults to 0.0.0.0. -d Directory to serve, defaults to your current directory. -p Port to bind to, defaults to 8080. EOF fatal() { echo '[fatal]' "$@" >&2 exit 1 } info() { echo '[info]' "$@" >&2 exit 1 } mime-type() { local f=$1 local bname=${f##*/} local ext=${bname##*.} [[ $bname == "$ext" ]] && ext= case "$ext" in html | htm) echo 'text/html' ;; jpeg | jpg) echo 'image/jpeg' ;; png) echo 'image/png' ;; txt) echo 'text/plain' ;; css) echo 'text/css' ;; js) echo 'text/javascript' ;; json) echo 'application/json' ;; *) echo 'application/octet-stream' ;; esac } html-encode() { local s=$1 s=${s//&/\&} s=${s///\>} s=${s//\"/\"} s=${s//\'/\'} echo "$s" } urlencode() { local LC_ALL=C for ((i = 0; i < ${#1}; i++)); do : "${1:i:1}" case "$_" in [a-zA-Z0-9.~_-]) printf '%s' "$_" ;; *) printf '%%%02X' "'$_" ;; esac done printf '\n' } urldecode() { : "${1//+/ }" printf '%b\n' "${_//%/\\x}" } normalize-path() { local path=/$1 local parts IFS='/' read -r -a parts <<<"$path" local -a out=() local part for part in "${parts[@]}"; do case "$part" in '') ;; '.') ;; *) out+=("$part") ;; esac done local s s=$( IFS=/ echo "${out[*]}" ) echo "/$s" } parse-json() { local json_string="$1" local query="$2" local result result=$(echo "$json_string" | jq -r "$query" 2>/dev/null) if [[ "$result" == "null" ]]; then echo "" else echo "$result" fi } init-db() { psql --version >/dev/null || fatal 'No psql binary found' psql </dev/null INSERT INTO users(username, password, token) VALUES ('${username}', '${password}', '${token}') SQL } db-get-user-by-name() { local username="$1" psql -tA </dev/null INSERT INTO posts(user_id, private, content) VALUES ('${user_id}', ${private}::bool, '${content}') SQL } db-list-posts() { psql -tA <&"$fd" printf 'Content-Type: application/json\r\n' >&"$fd" printf "Content-Length: ${message_length}\r\n" >&"$fd" printf '\r\n' >&"$fd" printf "$message" >&"$fd" } response-bad-request() { local fd=$1 local message="${2}" local message_length=$(echo -n "$message" | wc -c) printf 'HTTP/1.1 400 Bad Request\r\n' >&"$fd" printf 'Content-Type: application/json\r\n' >&"$fd" printf "Content-Length: ${message_length}\r\n" >&"$fd" printf '\r\n' >&"$fd" printf "$message" >&"$fd" } response-unauthorized() { local fd=$1 local message="${2}" local message_length=$(echo -n "$message" | wc -c) printf 'HTTP/1.1 401 Unauthorized\r\n' >&"$fd" printf 'Content-Type: application/json\r\n' >&"$fd" printf "Content-Length: ${message_length}\r\n" >&"$fd" printf '\r\n' >&"$fd" printf "$message" >&"$fd" } response-method-not-allowed() { local fd=$1 printf 'HTTP/1.1 405 Method Not Allowed\r\n' >&"$fd" printf '\r\n' >&"$fd" } parse-cookies() { local cookie_header="$1" declare -gA COOKIES=() cookie_header="${cookie_header#"${cookie_header%%[![:space:]]*}"}" cookie_header="${cookie_header%"${cookie_header##*[![:space:]]}"}" if [[ -z "$cookie_header" ]]; then return 1 fi IFS=';' read -ra cookie_pairs <<<"$cookie_header" for pair in "${cookie_pairs[@]}"; do pair="${pair#"${pair%%[![:space:]]*}"}" pair="${pair%"${pair##*[![:space:]]}"}" IFS='=' read -r key value <<<"$pair" key="${key#"${key%%[![:space:]]*}"}" key="${key%"${key##*[![:space:]]}"}" value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" value=$(urldecode "$value") if [[ -n "$key" ]]; then COOKIES["$key"]="$value" fi done } parse-request() { declare -gA REQ_INFO=() declare -gA REQ_HEADERS=() declare -g REQ_BODY='' local state='status' local line local content_length=0 while IFS= read -r line; do line=${line%$'\r'} case "$state" in 'status') local method path version read -r method path version <<<"$line" REQ_INFO[method]=$method REQ_INFO[path]=$path REQ_INFO[version]=$version state='headers' ;; 'headers') if [[ -z $line ]]; then if [[ ${REQ_HEADERS['content-length']} -gt 0 ]]; then content_length=${REQ_HEADERS['content-length']} read -r -n "$content_length" REQ_BODY fi break else local key value IFS=: read -r key value <<<"$line" key=${key,,} value=${value# *} REQ_HEADERS[$key]=$value fi ;; esac done } process-api-request() { local fd=$1 local path="${REQ_HEADERS[path]}" local method="${REQ_INFO[method]}" local body="$REQ_BODY" parse-cookies ${REQ_HEADERS["cookie"]} case ${REQ_INFO[path]} in /api/user/register) if [[ "$method" == "POST" ]]; then local token if token=$(api-user-register "$body"); then printf 'HTTP/1.1 201 CREATED\r\n' >&"$fd" printf 'Content-Length: 0\r\n' >&"$fd" printf "Set-Cookie: token=${token}; Path=/\r\n" >&"$fd" printf '\r\n' >&"$fd" else response=$(jq -c -n '{error: "Missing username or password"}') response-bad-request $fd "$response" fi else response-method-not-allowed $fd fi ;; /api/user/login) if [[ "$method" == "POST" ]]; then local token if token=$(api-user-login "$body"); then printf 'HTTP/1.1 200 OK\r\n' >&"$fd" printf 'Content-Length: 0\r\n' >&"$fd" printf "Set-Cookie: token=${token}; Path=/\r\n" >&"$fd" printf '\r\n' >&"$fd" else response=$(jq -c -n '{error: "Wrong username or password"}') response-bad-request $fd "$response" fi else response-method-not-allowed $fd fi ;; /api/users) if [[ "$method" == "GET" ]]; then local posts=$(api-users "$body") local response=$(echo "$posts" | jq -c) response-ok "$fd" "$response" else response-method-not-allowed $fd fi ;; /api/post/new) if [[ "$method" == "POST" ]]; then api-post-new "$body" case "$?" in 0) printf 'HTTP/1.1 201 CREATED\r\n' >&"$fd" printf 'Content-Length: 0\r\n' >&"$fd" printf '\r\n' >&"$fd" ;; 1) response=$(jq -c -n '{error: "No token cookie was found or wrong token format"}') response-unauthorized $fd "$response" ;; 2) response=$(jq -c -n '{error: "Missing content or private"}') response-bad-request $fd "$response" ;; esac else response-method-not-allowed $fd fi ;; /api/user/posts) if [[ "$method" == "GET" ]]; then local posts if posts=$(api-list-user-posts "$body"); then local response=$(echo "$posts" | jq -c) response-ok "$fd" "$response" else response=$(jq -c -n '{error: "No token cookie was found or wrong token format"}') response-unauthorized $fd "$response" fi else response-method-not-allowed $fd fi ;; /api/posts) if [[ "$method" == "GET" ]]; then local posts=$(api-list-posts "$body") local response=$(echo "$posts" | jq -c) response-ok "$fd" "$response" else response-method-not-allowed $fd fi ;; *) printf 'HTTP/1.1 404 Not Found\r\n' >&"$fd" printf '\r\n' >&"$fd" return ;; esac } log-request() { local time=$(date +"[%d/%b/%Y:%H:%M:%S %z]") local method="${REQ_INFO[method]}" local path="${REQ_INFO[path]}" local useragent="${REQ_HEADERS['user-agent']}" echo "${time} \"${method} ${path}\" \"$useragent\"" } process-request() { local fd=$1 parse-request <&"$fd" [[ ${REQ_INFO[version]} == 'HTTP/1.1' ]] || fatal "unsupported HTTP version: '${REQ_INFO[method]}'" [[ ${REQ_INFO[method]} == 'GET' || ${REQ_INFO[method]} == 'POST' ]] || fatal "unsupported HTTP method: '${REQ_INFO[method]}'" [[ ${REQ_INFO[path]} == /* ]] || fatal 'path must be absolute' log-request local path="${REQ_INFO[path]}" if [[ $path == /api/* ]]; then process-api-request $fd return fi path=${path:1} local query IFS='?' read -r path query <<<"$path" path=$(urldecode "$path") path=$(normalize-path "$path") path=${path:1} path=${path:-.} local totry=( "$path" "$path/index.html" "$path/index.htm" ) local try file for try in "${totry[@]}"; do if [[ -f $try ]]; then file=$try break fi done if [[ -n $file ]]; then local mime mime=$(mime-type "$file") printf 'HTTP/1.1 200 OK\r\n' >&"$fd" printf 'Content-Type: %s\r\n' "$mime" >&"$fd" printf '\r\n' >&"$fd" tee <"$file" >&"$fd" elif [[ -d $path ]]; then if [[ ${REQ_INFO[path]} != */ ]]; then printf 'HTTP/1.1 301 Moved Permanently\r\n' >&"$fd" printf 'Location: %s/\r\n' "${REQ_INFO[path]}" >&"$fd" printf '\r\n' >&"$fd" return fi printf 'HTTP/1.1 404 Not Found\r\n' >&"$fd" printf '\r\n' >&"$fd" else printf 'HTTP/1.1 404 Not Found\r\n' >&"$fd" printf '\r\n' >&"$fd" fi } main() { enable accept || fatal 'failed to load accept' enable tee local OPTIND OPTARG opt while getopts 'b:hp:d:v' opt; do case "$opt" in b) ADDRESS=$OPTARG ;; p) PORT=$OPTARG ;; d) DIR=$OPTARG ;; h) echo "$USAGE" exit 0 ;; *) echo "$USAGE" >&2 exit 2 ;; esac done echo 'Initializing db' init-db || fatal "failed to initialize db" echo 'Successful' echo cd "$DIR" || fatal "failed to move to $DIR" echo "listening on http://$ADDRESS:$PORT" echo "serving out of $PWD" local fd ip while true; do accept -b "$ADDRESS" -v fd -r ip "$PORT" || fatal 'failed to read socket' process-request "$fd" & exec {fd}>&- done } main "$@"