An Express-like HTTP router for Bash compatible with CGI 1.1
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

566 lines
11 KiB

#!/bin/bash
# shellcheck disable=SC2034 # false flags nameref params
# shellcheck disable=SC2178 # false flags nameref params
# ======================
# Required shell options
# ======================
# Necessary for piping to "send" funciton
shopt -s lastpipe
# ===============
# Setup Functions
# ===============
# $1: Log file for redirecting stdout to
function redirectStdout() {
local output="$1";
[ -z "$output" ] && output="/dev/null";
exec 5<&1;
_exprashRedirectStdout="$output";
exec 1>>"$_exprashRedirectStdout";
printf '%s: %s\n' "${REQUEST_METHOD:-UNKNOWN}" "${PATH_INFO:-/}"
}
# =============
# Route Function
# =============
# $1: path
function get() {
[ "$REQUEST_METHOD" != "GET" ] && return 1;
all "$1";
}
# $1: path
function post() {
[ "$REQUEST_METHOD" != "POST" ] && return 1;
all "$1";
}
# $1: path
function put() {
[ "$REQUEST_METHOD" != "PUT" ] && return 1;
all "$1";
}
# $1: path
function delete() {
[ "$REQUEST_METHOD" != "DELETE" ] && return 1;
all "$1";
}
# $1: path
function all() {
[ "$_exprashRouteHandled" -eq 1 ] && return 1;
[ -n "$_exprashErrorMessage" ] && return 1;
# Reset params
_exprashParams=();
_pathMatch "$1" _exprashParams || return 1;
_exprashRouteHandled=1;
return 0;
}
function use() {
[ "$_exprashRouteHandled" -eq 1 ] && return 1;
[ -n "$_exprashErrorMessage" ] && return 1;
_exprashRouteHandled=1;
return 0;
}
# =====================
# Error Route Functions
# =====================
# $1: path
function getError() {
[ "$REQUEST_METHOD" != "GET" ] && return 1;
allError "$1";
}
# $1: path
function postError() {
[ "$REQUEST_METHOD" != "POST" ] && return 1;
allError "$1";
}
# $1: path
function putError() {
[ "$REQUEST_METHOD" != "PUT" ] && return 1;
allError "$1";
}
# $1: path
function deleteError() {
[ "$REQUEST_METHOD" != "DELETE" ] && return 1;
allError "$1";
}
# $1: path
function allError() {
[ "$_exprashRouteHandled" -eq 1 ] && return 1;
[ -z "$_exprashErrorMessage" ] && return 1;
# Reset params
_exprashParams=();
_pathMatch "$1" _exprashParams || return 1;
_exprashRouteHandled=1;
return 0;
}
function useError() {
[ "$_exprashRouteHandled" -eq 1 ] && return 1;
[ -z "$_exprashErrorMessage" ] && return 1;
_exprashRouteHandled=1;
return 0;
}
# ====
# Next
# ====
# $1 (optional): error message
function next() {
_exprashRouteHandled=0;
[ -n "$1" ] && _exprashErrorMessage="$1";
}
# =============
# App Functions
# =============
function errorMessage() {
printf '%s' "$_exprashErrorMessage";
}
function hasErrorMessage() {
[ -n "$_exprashErrorMessage" ];
}
# =================
# Request Functions
# =================
# $1: param name
function param() {
printf '%s\n' "${_exprashParams[$1]}";
}
function hasParam() {
[[ -v "_exprashParams[$1]" ]];
}
# $1: key
# $2: (optional) index for accessing array parameters
function query() {
_multiGet _exprashQuery "$1" "$2"
}
# $1: key
function hasQuery() {
_multiHas _exprashQuery "$1"
}
# $1: key
function lenQuery() {
_multiLen _exprashQuery "$1"
}
# Call this function to parse URLencoded request bodies
function useBody() {
if [[ "${HTTP_CONTENT_TYPE,,}" == "application/x-www-form-urlencoded" ]]; then
_parseUrlEncoded _exprashBody
fi
}
# $1: key
# $2: (optional) index for accessing array parameters
function body() {
_multiGet _exprashBody "$1" "$2"
}
# $1: key
function hasBody() {
_multiHas _exprashBody "$1"
}
# $1: key
function lenBody() {
_multiLen _exprashBody "$1"
}
# $1: key
function cookie() {
printf '%s' "${_exprashCookies[$1]}"
}
# $1: key
function hasCookie() {
[[ -v "_exprashCookies[$1]" ]]
}
# $1: key
# $2: value
function setCookie() {
_exprashSetCookies["$1"]="$2"
}
# ==================
# Response Functions
# ==================
function send() {
if [ "$_exprashHeadersSent" -eq 0 ]; then
sendHeaders
_exprashHeadersSent=1
fi
_sendRaw
}
function sendJson() {
setHeader 'Content-Type' 'application/json'
send
}
# $1: Path
function redirect() {
local path="$1"
if [ "$path" == 'back' ]; then
path="${HTTP_REFERER:-/}"
fi
status '302'
setHeader 'Location' "$path"
printf 'Redirecting to: %s' "$path" | send
}
# $1: satus code
function status() {
setHeader "Status" "$1"
}
# $1: Header Name
# $2: Header Value
function setHeader() {
_exprashHeaders["$1"]="$2"
}
function sendHeaders() {
printf '%s\n' "Content-Type: ${_exprashHeaders['Content-Type']}" | _sendRaw
for key in "${!_exprashHeaders[@]}"; do
[ "$key" == 'Content-Type' ] && continue
printf '%s\n' "${key}: ${_exprashHeaders[$key]}" | _sendRaw
done;
for key in "${!_exprashSetCookies[@]}"; do
printf '%s\n' "Set-Cookie: ${key}=${_exprashSetCookies[$key]}" | _sendRaw
done;
printf '\n' | _sendRaw
}
# =======
# Session
# =======
# Call this function to automatically manage sessions
# $1: (optional) session dir, defaults to "session" in the current directory
function useSession() {
local session_dir=$1
# Create default session directory
if [ -z "$session_dir" ]; then
session_dir="./session"
mkdir -p "$session_dir"
fi
# Setup session globals
_exprashUseSession=1
_exprashSessionDir=$session_dir
_loadSession || _createSession || return 1
}
# Set a session variable
# $1: variable name
# $2: (optional) vairable value
function session() {
local name="$1"
if [[ -v 2 ]]; then
local value="$2"
_exprashSession["$name"]="$value"
else
printf '%s' "${_exprashSession["$name"]}"
fi
}
# =========
# Internals
# =========
function _sendRaw() {
[ -n "$_exprashRedirectStdout" ] && exec >&5;
cat;
[ -n "$_exprashRedirectStdout" ] && exec 1>>"$_exprashRedirectStdout";
}
# $1: path
# $2: (nameref) array
function _pathToArray() {
readarray -t "$2" < <(printf '%s\n' "$1" | tr '/' '\n' | grep .);
}
# $1: route
# $2: (nameref) associative array for params
function _pathMatch() {
[ -z ${PATH_INFO+x} ] && return 1;
[ -z ${1+x} ] && return 1;
local path="$PATH_INFO";
local route="$1";
# Params associative array
local -n routeParams="$2";
local pathArr;
_pathToArray "$path" pathArr;
local routeArr;
_pathToArray "$route" routeArr;
# Get max path length
local routeLen=${#routeArr[@]};
local pathLen=${#pathArr[@]};
local maxLen=$(( routeLen >= pathLen ? routeLen : pathLen ));
for ((i=0; i<maxLen; i++)); do
local routeComponent="${routeArr[$i]}";
local pathComponent="${pathArr[$i]}";
# If route component starts with ":"
if [[ "$routeComponent" == :* ]] && [ -n "$pathComponent" ]; then
routeParams["${routeComponent:1}"]="$pathComponent";
elif [[ "$routeComponent" == '*' ]] && [ -n "$pathComponent" ]; then
continue;
elif [[ "$routeComponent" == '**' ]] && [ -n "$pathComponent" ]; then
break;
else
# Confirm paths match
[ "$routeComponent" != "$pathComponent" ] && return 1;
fi;
done;
return 0;
}
# ==================================
# Multi-dimensional Parameter Arrays
# ==================================
# $1: (nameref) associative array
# $2: key
# $3: value
function _multiAdd() {
local -n multiArr="$1"
local key="$2"
local value="$3"
local i=0
while [[ -v "multiArr[$i,$key]" ]]; do
(( i++ ))
done
multiArr["$i,$key"]="$value"
}
# $1: (nameref) associative array
# $2: key
function _multiLen() {
local -n multiArr="$1"
local key="$2"
local i=0
while [[ -v "multiArr[$i,$key]" ]]; do
(( i++ ))
done
printf '%s' "$i"
}
# $1: (nameref) associative array
# $2: key
function _multiHas() {
local -n multiArr="$1"
local key="$2"
[[ -v "multiArr[0,$key]" ]]
}
# $1: (nameref) associative array
# $2: key
# $3: index
function _multiGet() {
local -n multiArr="$1"
local key="$2"
local i="${3:-0}";
printf '%s' "${multiArr[$i,$key]}"
}
# ==================
# URL Encoded Parser
# ==================
# $1 | stdin: urlencoded string
decodeUri () {
local input_str="${1-"$(< /dev/stdin)"}"
input_str="${input_str//+/ }";
echo -e "${input_str//%/\\x}";
}
# $1: (nameref) multi associative array
# $2 | stdin: url encoded data
function _parseUrlEncoded() {
local -n parsedArr="$1"
local url_encoded_str="${2-"$(< /dev/stdin)"}"
local pair name value
while IFS= read -d '&' -r pair || [ "$pair" ]; do
name=$(decodeUri "${pair%%=*}")
value=$(decodeUri "${pair#*=}")
if [ -n "$name" ]; then
_multiAdd parsedArr "$name" "$value"
fi;
done <<< "$url_encoded_str"
}
# =======
# Cookies
# =======
# $1: (nameref) associative array
# $2 | stdin: cookie string to parse
function _parseCookies() {
local -n parsedArr="$1"
local cookie_str="${2-"$(< /dev/stdin)"}"
local pair name value
while IFS= read -d ';' -r pair || [ "$pair" ]; do
name="$(_trim "${pair%%=*}" | decodeUri)"
value="$(_trim "${pair#*=}" | decodeUri)"
if [ -n "$name" ]; then
parsedArr["$name"]="$value"
fi
done <<< "$cookie_str"
}
# =======
# Session
# =======
function _loadSession() {
[ "$_exprashUseSession" -eq 1 ] || return 1
[ -n "$_exprashSessionDir" ] || return 1
hasCookie "$_exprashSessionCookieName" || return 1
local session_id
session_id="$(cookie "$_exprashSessionCookieName")"
local session_file="${_exprashSessionDir%/}/$session_id.session"
# shellcheck disable=SC1090
source "$session_file" || return 1
# Set globals
_exprashSessionId=$session_id
}
function _createSession() {
[ "$_exprashUseSession" -eq 1 ] || return 1
[ -n "$_exprashSessionDir" ] || return 1
# mktemp args
local args=()
args+=('-p' "$_exprashSessionDir")
args+=("$(printf 'X%.0s' {1..32}).session")
local session_file
session_file=$(mktemp -u "${args[@]}") || return 1
local session_file_name=${session_file##*/}
local session_id=${session_file_name%%.*}
# Set cookie
setCookie "$_exprashSessionCookieName" "$session_id"
# Set globals
_exprashSessionId=$session_id
_exprashSession=()
}
function _saveSession() {
[ "$_exprashUseSession" -eq 1 ] || return 1
[ -n "$_exprashSessionDir" ] || return 1
[ -n "$_exprashSessionId" ] || return 1
local session_file="${_exprashSessionDir%/}/${_exprashSessionId}.session"
declare -p _exprashSession | sed '1 s/\([^-]*-\)/\1g/' > "$session_file"
}
# =================
# Utility Functions
# =================
# $1 | stdin: string to trim
function _trim() {
local str="${1-"$(< /dev/stdin)"}"
# trim leading spaces
str="${str#"${str%%[![:space:]]*}"}"
# trim trailing spaces
str="${str%"${str##*[![:space:]]}"}"
printf '%s' "$str"
}
# ========
# Shutdown
# ========
function _exprashShutdown() {
_saveSession
}
# ==============
# Initialization
# ==============
function _exprashInit() {
_exprashRedirectStdout=''
declare -gA _exprashParams=()
declare -gA _exprashBody=()
declare -gA _exprashQuery=()
declare -gA _exprashHeaders=()
declare -gA _exprashCookies=()
declare -gA _exprashSetCookies=()
_exprashUseSession=0
_exprashSessionDir=''
_exprashSessionId=''
declare -gA _exprashSession=()
_exprashSessionCookieName='exprash_session'
_exprashRouteHandled=0
_exprashErrorMessage=''
_exprashHeadersSent=0
_exprashHeaders['Content-Type']='text/html'
# Parse query string
_parseUrlEncoded _exprashQuery "$QUERY_STRING"
# Parse cookies
_parseCookies _exprashCookies "$HTTP_COOKIE"
}
# Shutdown trap
trap _exprashShutdown EXIT
# Initialize exprash
_exprashInit