Files

616 lines
17 KiB
Bash
Executable File

#!/usr/bin/env bash
VERSION=v0.6.0
declare -A HTTP_HEADERS
declare -A FILE_UPLOADS
declare -A FILE_UPLOAD_TYPES
declare -A FILE_UPLOAD_NAMES
declare -A QUERY_PARAMS
declare -A FORM_DATA
declare -A PATH_VARS
declare -A COOKIES
declare -A SESSION
[[ -f 'config.sh' ]] && source config.sh
debug() {
printf "%s\n" "$@" 1>&2
}
if [[ "${DEV:-true}" == true ]]; then
USE_HMR="$(which inotifywait)"
# disable HMR when using netcat
if [[ "$TCP_PROVIDER" == "nc" ]]; then
USE_HMR=""
fi
fi
header() {
printf "%s: %s\r\n" "$1" "$2"
}
respond() {
CODE=$1
shift
printf "HTTP/1.1 %s %s\r\n" "$CODE" "$*"
header Server "bash-stack ${VERSION:-devbuild}"
[[ ! -z "$SESSION_HEADER_TO_BE_WRITTEN" ]] && \
printf "%s\n" "$SESSION_HEADER_TO_BE_WRITTEN"
}
end_headers() {
printf "\r\n"
}
trim_quotes() {
# Usage: trim_quotes "string"
: "${1//\'}"
printf '%s\n' "${_//\"}"
}
urlencode() {
# Usage: urlencode "string"
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() {
# Usage: urldecode "string"
: "${1//+/ }"
printf '%b\n' "${_//%/\\x}"
}
function create_or_resume_session() {
local KEY
local VAL
if [[ -z "${COOKIES[_session]}" ]]; then
SESSION_ID="$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32 ; echo '')"
SESSION_HEADER_TO_BE_WRITTEN=$(header Set-Cookie "_session=$SESSION_ID; Path=/; Secure; HttpOnly")
else
SESSION_ID=$(echo "${COOKIES[_session]}" | tr -dc A-Za-z0-9)
fi
if [[ -f "sessions/$SESSION_ID" ]]; then
while IFS= read -r line; do
KEY="$(echo "$line" | cut -f1)"
VAL="$(echo "$line" | cut -f2-)"
SESSION["$KEY"]="$VAL"
done < "sessions/$SESSION_ID"
fi
}
function save_session() {
if [[ "${ENABLE_SESSIONS:-false}" != true ]]; then
debug "Error: You must set ENABLE_SESSIONS=true before calling save_session!"
return
fi
local KEY
if [[ -z "$SESSION_ID" ]]; then
return
fi
touch "sessions/$SESSION_ID"
for KEY in ${!SESSION[@]}; do
printf "%s\t%s\n" "$KEY" "${SESSION[$KEY]}"
done > "sessions/$SESSION_ID"
}
function _inject_hmr() {
if [[ -z "$USE_HMR" ]]; then
return
fi
cat <<-EOF
<div style="display:none" hx-ext="sse" sse-connect="/hmr" sse-swap="none">
<div hx-trigger="sse:reload" hx-post="/hmr"></div>
</div>
EOF
}
function htmx_page() {
if [[ -z "$NO_STYLES" ]]; then
if [[ -z "$TAILWIND" ]]; then
STYLE_TEXT='<link rel="stylesheet" href="/static/style.css">'
else
STYLE_TEXT='<link rel="stylesheet" href="/static/tailwind.css">'
fi
fi
[[ ${HTTP_HEADERS["hx-request"]} == "true" ]] || [[ "$INTERNAL_REQUEST" == "true" ]] || cat <<-EOF
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta charset="UTF-8">
${STYLE_TEXT}
<script src="https://unpkg.com/htmx.org@1.9.3/dist/htmx.min.js" integrity="sha384-lVb3Rd/Ca0AxaoZg5sACe8FJKF0tnUgR2Kd7ehUOG5GCcROv5uBIZsOqovBAcWua" crossorigin="anonymous"></script>
<script src="https://unpkg.com/hyperscript.org@0.9.8"></script>
<script src="https://unpkg.com/htmx.org/dist/ext/sse.js"></script>
<script src="/static/script.js"></script>
</head>
<body>
$(_inject_hmr)
EOF
cat # meow
[[ "$INTERNAL_REQUEST" == "true" ]] || [[ ${HTTP_HEADERS["hx-request"]} == "true" ]] || [[ ! -z "$HIDE_LOGO" ]] || cat <<-EOF
<div id="signature">
<a href="https://github.com/cgsdev0/bash-stack" target="_blank">
<svg xmlns="http://www.w3.org/2000/svg" width="224" height="256" viewBox="0 0 224 256"><path fill="#FFF" d="M207.953 52.162L127.317 4.287a30.372 30.372 0 0 0-31.114 0L15.55 52.162A32.165 32.165 0 0 0 0 79.869v95.734a32.165 32.165 0 0 0 15.55 27.691l80.636 47.859a30.388 30.388 0 0 0 31.115 0l80.636-47.859a32.165 32.165 0 0 0 15.566-27.707V79.869a32.165 32.165 0 0 0-15.55-27.707Z"/><path fill="#2F3A3E" d="m208.412 52.277l-80.814-47.98a30.439 30.439 0 0 0-31.184 0l-80.83 47.98A32.236 32.236 0 0 0 0 80.045v95.945a32.236 32.236 0 0 0 15.584 27.752l80.814 47.964a30.455 30.455 0 0 0 31.183 0l80.814-47.964a32.236 32.236 0 0 0 15.6-27.769V80.045a32.236 32.236 0 0 0-15.583-27.768ZM99.23 246.803l-80.814-47.964A26.604 26.604 0 0 1 5.6 175.989V80.046a26.588 26.588 0 0 1 12.816-22.849L99.23 9.216a24.92 24.92 0 0 1 25.536 0l80.749 47.98a26.426 26.426 0 0 1 12.412 18.48c-2.687-5.712-8.723-7.282-15.762-3.236l-76.396 47.316c-9.531 5.551-16.554 11.814-16.57 23.303v94.213c0 6.877 2.767 11.327 7.039 12.638a24.759 24.759 0 0 1-4.24.405a25.034 25.034 0 0 1-12.768-3.512Z"/><path fill="#3AB14A" d="m187.007 185.06l-20.086 12.013a1.47 1.47 0 0 0-.92 1.308v5.28c0 .646.435.904.968.597l20.394-12.4a1.616 1.616 0 0 0 .613-1.615v-4.634c-.016-.598-.484-.856-.969-.55Z"/><path fill="#FFF" d="M144.263 140.832c.646-.323 1.179 0 1.195.92l.064 7.008a12.917 12.917 0 0 1 7.718-.937c.501.13.71.808.517 1.615l-1.534 6.152a2.648 2.648 0 0 1-.694 1.227a1.615 1.615 0 0 1-.404.29a.92.92 0 0 1-.597.098a10.237 10.237 0 0 0-7.444 1.194a9.349 9.349 0 0 0-5.506 8.284c0 3.229 1.615 4.117 7.25 4.214c7.444.13 10.673 3.375 10.754 10.883a26.69 26.69 0 0 1-9.882 20.135l.13 6.878a2.519 2.519 0 0 1-1.18 2.1l-4.068 2.34c-.646.323-1.18 0-1.195-.904v-6.765c-3.488 1.453-7.024 1.792-9.285.888c-.42-.162-.613-.791-.436-1.518l1.47-6.216a2.6 2.6 0 0 1 .726-1.292c.115-.11.246-.203.388-.275a.807.807 0 0 1 .662 0c2.878.78 5.948.392 8.541-1.081a11.173 11.173 0 0 0 6.314-9.688c0-3.488-1.922-4.941-6.459-4.974c-5.861 0-11.303-1.13-11.416-9.688a25.027 25.027 0 0 1 9.462-19.15l-.29-7.04a2.503 2.503 0 0 1 1.178-2.13l4.021-2.568Z"/></svg>
<span>Powered by bash</span></a>
</div>
</body>
</html>
EOF
}
# usage: subscribe [topic]
# returns: handle of a fifo (via stdout)
function subscribe() {
local TOPIC
TOPIC="$1"
if [[ -z "$TOPIC" ]]; then
debug "ATTEMPTED TO SUBSCRIBE ON EMPTY TOPIC"
return
fi
mkdir -p pubsub/"${TOPIC}"
tmppipe=$(mktemp -up pubsub/"${TOPIC}")
mkfifo -m 600 "$tmppipe"
echo "$tmppipe"
}
function unsubscribe() {
local TOPIC
TOPIC="$1"
if [[ "$TOPIC" != "pubsub/"* ]]; then
debug "FAILED TO UNSUBSCRIBE"
return
fi
rm -f "$TOPIC"
}
function publish() {
local TOPIC
local line
TOPIC="$1"
if [[ -z "$TOPIC" ]]; then
return
fi
if [[ ! -d "pubsub/${TOPIC}" ]]; then
return
fi
TEE_ARGS=$(find pubsub/"${TOPIC}" -type p)
if [[ -z "$TEE_ARGS" ]]; then
return
fi
tee $TEE_ARGS > /dev/null
}
event() {
printf "event: %s\ndata: %s\n\n" "$@"
}
# encode result
# can encode the following status codes as unix return codes:
# 200-263
# 300-363
# 400-463
# 500-563
function status_code() {
A=${1:0:1}
B=${1:1:2}
echo $(( ((A - 2) << 6) + B ))
}
function decode_result() {
ENCODED=${1}
printf "%d%02d" $(((ENCODED >> 6) + 2)) $(( ENCODED & 63 ))
}
function component() {
if [[ "$1" == "$REQUEST_PATH" ]]; then
echo "<!-- RECURSION DETECTED -->"
return
fi
local REQUEST_PATH
local REQUEST_METHOD
local ROUTE_SCRIPT
REQUEST_PATH="$1"
REQUEST_METHOD="GET"
matchRoute "$REQUEST_PATH"
INTERNAL_REQUEST=true
if [[ -f "pages/${ROUTE_SCRIPT}" ]]; then
result=$(source "pages/${ROUTE_SCRIPT}")
echo "$result"
else
echo "<!-- MISSING COMPONENT: $1 -->"
fi
}
readonly URI_REGEX='(/[^?#]*)(\?([^#]*))?'
parseHttpRequest() {
local line
# Read request line
read -r REQUEST_METHOD REQUEST_PATH_WITH_PARAMS HTTP_VERSION
HTTP_VERSION="${HTTP_VERSION%%$'\r'}"
debug "$REQUEST_METHOD $REQUEST_PATH_WITH_PARAMS $HTTP_VERSION"
[[ "$REQUEST_PATH_WITH_PARAMS" =~ $URI_REGEX ]]
REQUEST_PATH="${BASH_REMATCH[1]}"
REQUEST_QUERY="${BASH_REMATCH[3]}"
# Parse query parameters
if [[ ! -z "$REQUEST_QUERY" ]]; then
while read -r -d '&' line; do
local VARNAME="${line%%=*}"
[[ -z "$VARNAME" ]] && continue
QUERY_PARAMS["$VARNAME"]=$(urldecode "${line#*=}")
done <<< "${REQUEST_QUERY}&"
fi
# Read headers
while IFS= read -r line; do
line="${line%%$'\r'}"
[[ -z "$line" ]] && break
KEY="${line%%:*}"
HTTP_HEADERS["${KEY,,}"]="${line#*: }"
done
# Parse multipart Form Data
if [[ ${HTTP_HEADERS["content-type"]} == "multipart/form-data; "* ]]; then
BOUNDARY="${HTTP_HEADERS["content-type"]}"
BOUNDARY="${BOUNDARY#*=}"
fi
# Read cookies (yum!)
if [[ ! -z "${HTTP_HEADERS["cookie"]}" ]]; then
while read -r -d ';' line; do
COOKIES["${line%%=*}"]=$(urldecode "${line#*=}")
done <<< "${HTTP_HEADERS[cookie]};"
fi
CLEN=${HTTP_HEADERS["content-length"]}
# Read multipart body
if [[ ! -z "$BOUNDARY" ]]; then
matchRoute "$REQUEST_PATH"
ALLOW_UPLOADS=false
if directive_test=$(head -1 "pages/${ROUTE_SCRIPT}"); then
if [[ "$directive_test" == "# allow-uploads" ]]; then
ALLOW_UPLOADS=true
fi
fi
if [[ "$ALLOW_UPLOADS" != "true" ]]; then
respond 403 Forbidden
end_headers
return
fi
state="start"
reader="reading"
local -A MULTIPART_HEADERS
local -A DISPOSITIONS
while read -n2 byte; do
# we have to implement our own readline because of reasons
if [[ "$reader" == "reading" ]]; then
if [[ "$byte" == "0a" ]]; then
reader="flushing-newline"
elif [[ "$byte" == "00" ]]; then
reader="flushing-null"
else
line="${line}${byte}"
fi
fi
if [[ "$reader" == "flushing"* ]]; then
PARSED="$(echo -n $line | xxd -r -p)"
if [[ "$state" == "start" ]] && [[ "$PARSED" == "--$BOUNDARY"* ]]; then
state="headers"
MULTIPART_HEADERS=()
DISPOSITIONS=()
elif [[ "$state" == "headers" ]]; then
PARSED="${PARSED%%$'\r'}"
if [[ -z "$PARSED" ]]; then
UPLOAD_TO=$(mktemp -p uploads)
state="body"
else
KEY="${PARSED%%:*}"
MULTIPART_HEADERS["${KEY,,}"]="${PARSED#*: }"
fi
elif [[ "$state" == "body" ]]; then
if [[ "$reader" == "flushing-null" ]]; then
# this is a null char
echo -n "${line}00" | xxd -r -p >> "$UPLOAD_TO"
else
# this is a newline char
if [[ "$PARSED" == "--$BOUNDARY"* ]]; then
while read -r -d ';' line; do
DISPOSITIONS["${line%%=*}"]=$(urldecode "${line#*=}")
done <<< "${MULTIPART_HEADERS["content-disposition"]};"
NAME=$(trim_quotes "${DISPOSITIONS[name]}")
FILENAME=$(trim_quotes "${DISPOSITIONS[filename]}")
# Check if this is a file upload or a regular form field
if [[ -n "$FILENAME" ]]; then
# This is a file upload
FILE_UPLOADS["$NAME"]="$UPLOAD_TO"
FILE_UPLOAD_NAMES["$NAME"]="$FILENAME"
FILE_UPLOAD_TYPES["$NAME"]="${MULTIPART_HEADERS[content-type]}"
else
# This is a regular form field, read its value
FORM_DATA["$NAME"]=$(cat "$UPLOAD_TO")
rm -f "$UPLOAD_TO" # Clean up the temp file for form fields
fi
MULTIPART_HEADERS=()
DISPOSITIONS=()
state="headers"
if [[ "$PARSED" == "--$BOUNDARY--"* ]]; then
# i dont know how, but we made it out alive
break
fi
else
echo -n "${line}0a" | xxd -r -p >> "$UPLOAD_TO"
fi
fi
fi
reader="reading"
line=''
fi
# wheeeeeeeeeeeeeeeee
done < <(stdbuf -o0 -i0 hexdump -v -e '/1 "%02x"' -n $CLEN)
else
# Read body
[[ "$CLEN" =~ ^[0-9]+$ ]] && \
test $CLEN -gt 0 && read -rN $CLEN REQUEST_BODY;
fi
# Parse Form Data
if [[ ! -z "$REQUEST_BODY" ]] && \
[[ ${HTTP_HEADERS["content-type"]} == "application/x-www-form-urlencoded" ]]; then
while read -r -d '&' line; do
FORM_DATA["${line%%=*}"]=$(urldecode "${line#*=}")
done <<< "${REQUEST_BODY}&"
fi
}
writeHttpResponse() {
if [[ "$REQUEST_PATH" == "/static/"* ]]; then
FILE_PATH=".${REQUEST_PATH}"
if [[ ! -f "$FILE_PATH" ]]; then
respond 404 Not Found
end_headers
return
fi
REALPATH="$(realpath --relative-to="./static" "$FILE_PATH")"
FIRST_THREE="${REALPATH:0:3}"
if [[ "$FIRST_THREE" == "../" ]]; then
respond 403 FORBIDDEN
end_headers
return
fi
debug "$REALPATH"
respond 200 OK
if [[ "$REQUEST_PATH" == *".css" ]]; then
header Content-Type "text/css"
else
header Content-Type "$(file -b --mime-type "$FILE_PATH")"
fi
end_headers
cat "$FILE_PATH"
return
fi
matchRoute "$REQUEST_PATH"
[[ "${ENABLE_SESSIONS:-false}" == "true" ]] && create_or_resume_session
if [[ ! -z "$USE_HMR" ]] && [[ "$REQUEST_PATH" == "/hmr" ]]; then
if [[ "$REQUEST_METHOD" == "POST" ]]; then
respond 204 OK
header HX-Redirect "${HTTP_HEADERS[hx-current-url]}"
end_headers
return
fi
respond 200 OK
header Content-Type "text/event-stream"
end_headers
output() {
killthehmr() {
kill "$HMR_PID" &> /dev/null
wait "$HMR_PID" &> /dev/null
exit 0
}
trap 'killthehmr' TERM
while true; do
inotifywait -e MODIFY -r pages static &> /dev/null &
HMR_PID=$!
wait "$HMR_PID" &> /dev/null
event "reload"
done
}
output &
PID=$!
while IFS= read -r line; do
:
done
kill $PID &>/dev/null
wait $PID &>/dev/null
return
elif [[ -z "$ROUTE_SCRIPT" ]]; then
debug "404 no match found"
respond 404 Not Found
end_headers
return
fi
if directive_test=$(head -1 "pages/${ROUTE_SCRIPT}"); then
if [[ "$directive_test" == "# sse" ]]; then
respond 200 OK
header Content-Type "text/event-stream"
end_headers
source "pages/${ROUTE_SCRIPT}"
TOPIC="$(topic)"
if [[ -z "$TOPIC" ]]; then
debug "ERROR: EMPTY TOPIC"
return
fi
SUB_FD=$(subscribe "$TOPIC")
output() {
curiosity() {
kill "$CAT_PID" &> /dev/null
wait "$CAT_PID" &> /dev/null
exit 0
}
trap 'curiosity' TERM
while true; do
cat "$SUB_FD" &
CAT_PID=$!
wait "$CAT_PID" &> /dev/null
done
}
output &
PID=$!
[[ $(type -t on_open) == function ]] && on_open 1>&2
while IFS= read -r line; do
:
done
kill "$PID" &>/dev/null
wait "$PID" &>/dev/null
unsubscribe "$SUB_FD"
[[ $(type -t on_close) == function ]] && on_close 1>&2
return
elif [[ "$directive_test" == "# headers" ]]; then
CUSTOM_HEADERS=1
fi
fi
result=$(source "pages/${ROUTE_SCRIPT}")
CODE=$?
respond $(decode_result $CODE)
[[ -z $CUSTOM_HEADERS ]] && header Content-Type "text/html" && end_headers
printf "%s" "$result"
}
findRoutes() {
if [[ -z $ROUTES_CACHE ]] || [[ $(stat -c "%s" $ROUTES_CACHE) -eq 0 ]]; then
cd pages
find . -type f,l -iname '*.sh' | sed 's@^\./@@' | tee $ROUTES_CACHE
else
cat $ROUTES_CACHE
fi
}
findPredefinedRoutes() {
findRoutes | grep -v '\['
}
findDynamicRoutes() {
findRoutes | grep '\[[^\.]' | grep -v '\[\.\.\.'
}
findCatchAllRoutes() {
findRoutes | grep '\[\.\.\.'
}
matchRoute() {
if [[ "$1" == "/" ]] && [[ -f "pages/index.sh" ]]; then
ROUTE_SCRIPT="index.sh"
return
fi
local route
local sanitized
# for our sanity
sanitized="${1%%/}"
while IFS= read -r route; do
if [[ "/${route%.sh}" == "$sanitized" ]]; then
ROUTE_SCRIPT="$route"
return
fi
done < <(findPredefinedRoutes)
while IFS= read -r route; do
routeRegex="/${route%.sh}"
routeRegex="^$(echo "$routeRegex" | sed 's@\[[^]]*\]@([^\/]+)@g')$"
if [[ "$sanitized" =~ $routeRegex ]]; then
local -a PATH_VALS
PATH_VALS=("${BASH_REMATCH[@]}")
ROUTE_SCRIPT="$route"
[[ "/${route%.sh}" =~ $routeRegex ]]
for (( i=1; i<${#BASH_REMATCH[@]}; i++ )); do
local KEY
KEY="${BASH_REMATCH[$i]}"
KEY="${KEY//\[}"
KEY="${KEY//\]}"
PATH_VARS[$KEY]=${PATH_VALS[$i]}
done
return
fi
done < <(findDynamicRoutes)
while IFS= read -r route; do
routeRegex="/${route%.sh}"
routeRegex="^${routeRegex//\/\[\[...*\]\]/\(\/.*\)?}$"
routeRegex="${routeRegex//\//\\/}"
routeRegex="${routeRegex//\[...*\]/\(.+\)}"
routeRegex="${routeRegex//\[*\]/\([^\/]+\)}"
if [[ "$sanitized" =~ $routeRegex ]]; then
local -a PATH_VALS
PATH_VALS=("${BASH_REMATCH[@]}")
ROUTE_SCRIPT="$route"
[[ "/${route%.sh}" =~ $routeRegex ]]
for (( i=1; i<${#BASH_REMATCH[@]}; i++ )); do
local KEY
local VAL
KEY="${BASH_REMATCH[$i]}"
KEY="${KEY//\[}"
KEY="${KEY//\]}"
KEY="${KEY//\.}"
KEY="${KEY//\/}"
VAL=${PATH_VALS[$i]}
VAL=${VAL#\/}
PATH_VARS[$KEY]="$VAL"
done
return
fi
done < <(findCatchAllRoutes)
}
export -f status_code
export -f component
export -f debug
export -f subscribe
export -f unsubscribe
export -f publish
export -f header
export -f end_headers
export -f event
export -f htmx_page
export -f findPredefinedRoutes
export -f findDynamicRoutes
export -f findCatchAllRoutes
export -f matchRoute
export -f save_session
parseHttpRequest
writeHttpResponse