Here we go again.
After building an image modal with CSS only (and completely neglecting accessibility (sorry, @grahamthedev)) and an attempt to establish CSS as a backend language (although it worked, you people didn't like it very much for some reason. I wonder why.), we're finally back with stuff you and I probably shouldn't ever do.
Today: Let's use Bash (yes, Bash) to create a CMS!
Pascal no
Pascal yes! Pascal always yes!
To give credit where credit is due: This idea came from a funny discussion with a co-worker about how to overcomplicate stuff. He came up with a "bash server", and we started to exaggerate it more and more until I said, "Challenge accepted," and here we are.
Disclaimer: We'll neglect security, apart from some password encryption and most best practices. This tool will be something I'll never ever ever use in production, and you, dear reader, should not do it either. Please. There. You've been warned.
Defining what it does
We'll create a very basic CMS:
- A user can log in and log out (so we need users and session handling)
- A logged-in user can create, update and delete pages
- An anonymous user can read all pages
- A page consists of a navigation title, a route path, some markup and a flag if it should show up in the main navigation
However, to use Bash as a backend language, we first need to get it to handle HTTP requests. Luckily, there are Bash utilities we can use to listen to TCP requests and send responses, most notably netcat
. We "only" need to parse a request and generate a response.
Once that's working, we'll use SQLite to load the requested page and render its markup.
Let's move on to the first bits of code.
The database schema
The boring bits first. We'll use the following database schema for our SQLite database and add a few default records:
CREATE TABLE IF NOT EXISTS pages (
routePath TEXT NOT NULL,
navTitle TEXT,
isInMainNavigation BOOLEAN NOT NULL,
markup TEXT NOT NULL
);
INSERT INTO pages VALUES
('/', 'Home', 1, '<h2>Hello, Bash CMS!</h2>'),
('/about', 'About', 1, '<h2>About</h2><p>This page was created entirely with BashCMS!</p> <p><a href="/about/bash-cms">Learn more</a></p>'),
('/about/bash-cms', NULL, 0, '<h2>About BashCMS</h2><p>BashCMS is a CMS entirely written in Bash!</p>');
CREATE TABLE IF NOT EXISTS users (
username TEXT NOT NULL,
password TEXT NOT NULL /* Hash generated with sha256sum */
);
INSERT INTO users VALUES
('admin', 'fc8252c8dc55839967c58b9ad755a59b61b67c13227ddae4bd3f78a38bf394f7'); /* pw: admin */
CREATE TABLE IF NOT EXISTS sessions (
sessId TEXT NOT NULL,
userRowId INTEGER
);
We can access the database via the sqlite3
CLI tool, which we can feed with data from our Bash script.
The actual server
Let's start with the Bash part now. To listen to HTTP requests, we use Netcat. We'll be using the OpenBSD Netcat version.
In the listen mode, Netcat is interactive. It prints out the request details (i.e. the headers, the body, the HTTP method and all) on STDOUT and expects the user to write the response in the STDIN.
For those unfamiliar with Linux/Bash, STDIN and STDOUT are the default ways to communicate with a program. What we see in the terminal is usually STDOUT, and the keyboard input is STDIN. A program is allowed to read from STDIN and write to STDOUT.
Once we send something to Netcat, it sends that over the wire and terminates. This means that we can only handle a single request at a time. For the server to run continuously, we need to start Netcat again and let it listen after it has terminated.
To read and write from Netcat, we also need a way to programmatically read STDOUT and write to STDIN. We can do this using a utility called coproc
, which executes a given command asynchronously in a subshell. We do nothing as long as Netcat is waiting for some incoming requests. Only once Netcat starts to write to the STDOUT do we start reading and save it to a variable.
There is, however, one small problem: Netcat does not tell us if and when it's finished writing to STDOUT. We need to determine that ourselves. The most straightforward approach is to wait for an empty new line and stop there.
We basically end up with a structure like this:
while true # You know your script will be fun when it starts with an endless loop.
do
coproc nc -l -p 1440 -q1 -v # Spawns netcat such that we can read from and write to it
REQ_RAW="" # This will contain the entire request
IFS="" # Delimiter for `read`
while read -r TMP; do # Read every line from STDOUT
REQ_RAW+=$TMP$'\n' # Append the line to the REQ variable
# If the length of TMP is equal to one byte
if [[ ${#TMP} -eq 1 ]] ; then
break
fi
done <&"${COPROC[0]}" # Reads from the coproc STDOUT, line by line
echo $REQ_RAW # Output the request for testing purposes
kill "$COPROC_PID" # Kill the process for the subsequent request
wait "$COPROC_PID" # Wait until it's actually gone
done
If you're really unfamiliar with Bash, this looks intimidating. It might even do so for people who know Bash. I learned a lot during this experiment, and I am deeply convinced that Bash works very much like quantum physics: One does not understand Bash; one gets used to it.
Back to business... The "empty line" approach breaks down as soon as we want to read the HTTP body in case of a POST request. Luckily, HTTP knows a header called Content-Length
that tells us the exact number of bytes.
This blows up the code for our server tremendously:
while true
do
coproc nc -l -p 1337 -q1 -v # Spawns netcat such that we can read from and write to it. Listens on port 1337.
REQ_RAW="" # This will contain the entire request
IFS="" # Delimiter for `read`
while read -r TMP; do
REQ_RAW+=$TMP$'\n' # Append the line to the REQ variable
TMPLEN=$(echo $TMP|wc -c) # Figure out the length of $TMP in bytes
# Deduct the length of the read bytes from the rest of the body length
if [[ $BODYLENGTH -ge 0 ]]; then # Still some body left to read
BODYLENGTH=$((BODYLENGTH - TMPLEN))
fi
# If the request has a body (determined by the header, which is usually the last one)
# We continue reading the exact number of bytes
if [[ "$TMP" =~ ^"Content-Length: " ]]; then
BODYLENGTH=$(echo "$TMP"|grep -o '[[:digit:]]\+')
HAS_BODY=1
fi
# Read the entire body; abort reading
if [[ $HAS_BODY -eq 1 ]] && [[ $BODYLENGTH -le 0 ]]; then
break
fi
# No body but empty line encountered, abort reading
if [[ $HAS_BODY -eq 0 ]] && [[ $TMPLEN -le 2 ]]; then
break
fi
done <&"${COPROC[0]}" # Reads from the coproc STDOUT, line by line
# Display the entire request for debugging
echo $REQ_RAW
kill "$COPROC_PID" # Kill the process for the subsequent request
wait "$COPROC_PID" # Wait until it's actually buried
done
This works well already. We basically have a request logger now. Progress!
The anatomy of a HTTP request
We first need to parse the request to determine what the server should execute. Let's look at what we're dealing with.
A typical HTTP request is structured like this:
[Method] [Path + Query String] HTTP/[HTTP Version]
[Headers]
[Body]
When I perform a GET request on the server, it outputs something like this:
GET / HTTP/1.1
User-Agent: PostmanRuntime/7.29.0
Accept: */*
Cache-Control: no-cache
Host: localhost:1440
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
A POST request, on the other hand, could look like this:
POST / HTTP/1.1
User-Agent: PostmanRuntime/7.29.0
Accept: */*
Cache-Control: no-cache
Host: localhost:1440
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: multipart/form-data; boundary=--------------------------328683080620751780512479
Content-Length: 169
----------------------------328683080620751780512479
Content-Disposition: form-data; name="hello"
world
----------------------------328683080620751780512479--
We can work with this.
Adding some logic
The request is stored as a single string in a variable called REQ_RAW
, so we can parse it using several other Bash utilities.
We create a function called parse_request
and put that into a separate file to keep things organized. We then call this function after the reading loop:
#!/usr/bin/bash
source ./server/parse_request.sh
while true # Continue doing this, kind of like an event loop.
do
coproc nc -l -p 1440 -q1 -v # Spawns netcat such that we can read from and write to it
## ...
# Declare an associative array called `REQUEST`
declare -A REQUEST=()
parse_request $REQ_RAW REQUEST
# Print the contents of the associative array
declare -p REQUEST
# Add more magic here
kill "$COPROC_PID" # Kill the process for the subsequent request
wait "$COPROC_PID" # Wait until it's actually gone
done
This function needs to do a few things at once:
- Determine the HTTP method
- Determine the route the user has requested
- Parse out any GET variables (i.e.
?foo=bar
, etc.) - Parse out the body
- Parse out cookies
We can parse the very first line of the request to get the HTTP method and route path. Afterwards, we parse the cookies and check if we need to parse a body, which only happens on POST and PUT requests.
#
# Parses the entire request
#
function parse_request() {
RAW_REQ=$1
# This makes the REQUEST associative array available to write to
# We need to make sure to not call it REQUEST, though, because
# that name is already reserved in the outer scope
declare -n INNER_REQ="$2"
# Extract the request line: method, path (+ query string) and version
REQUESTLINE=`echo "${RAW_REQ}" | sed -n 1p`
IFS=' ' read -ra PARTS <<< "$REQUESTLINE"
METHOD=${PARTS[0]}
REQUEST_PATH=${PARTS[1]}
# Split query string from the actual route
IFS='?' read -ra REQUEST_PATH_PARTS <<< "$REQUEST_PATH"
REQUEST_ROUTE=${REQUEST_PATH_PARTS[0]}
QUERY_STRING=${REQUEST_PATH_PARTS[1]}
if [[ "$QUERY_STRING" != "" ]]; then
parse_query_string $QUERY_STRING INNER_REQ
fi
parse_cookies $RAW_REQ INNER_REQ
# If we're dealing with either a POST or a PUT request, chances are there's a form body.
# We extract that with the previously found $FORMDATA_BOUNDARY.
if [[ "$METHOD" == "POST" ]] || [[ "$METHOD" == "PUT" ]]; then
parse_body $RAW_REQ INNER_REQ
fi
INNER_REQ["METHOD"]="$METHOD"
INNER_REQ["ROUTE"]="$REQUEST_ROUTE"
}
The query string parsing is pretty straightforward:
#
# Parses the query string and assigns it to the request object
#
function parse_query_string() {
RAW_QUERY_STRING=$1
declare -n REQ_ARR="$2"
# Split the query parameters into a hashmap
IFS='&' read -ra QUERYPARTS <<< "$QUERY_STRING"
for PART in "${QUERYPARTS[@]}"; do
IFS='=' read -ra KEYVALUE <<< "$PART"
KEY=${KEYVALUE[0]}
VALUE=${KEYVALUE[1]}
REQ_ARR["QUERY","$KEY"]="$VALUE"
done
}
And so is the cookie parsing:
#
# Parses cookies out of the request headers
#
function parse_cookies() {
RAW_REQ_BODY=$1
declare -n REQ_ARR="$2"
COOKIE_LINE=`echo $RAW_REQ_BODY|grep 'Cookie:'`
COOKIE=${COOKIE_LINE#"Cookie:"}
if [[ "$COOKIE" != "" ]]; then
IFS=';' read -r -d '' -a COOKIEPARTS <<< "$COOKIE"
for PART in "${COOKIEPARTS[@]}"; do
if [[ "$PART" != "" ]]; then
IFS='=' read -ra KEYVALUE <<< "$PART"
KEY=${KEYVALUE[0]//" "/""} # Remove all spaces, so we don't have leading spaces
VALUE=${KEYVALUE[1]}
REQ_ARR["COOKIE","$KEY"]=${VALUE::-1}
fi
done
fi
}
In both functions, we carefully rip the necessary parts out of the entire request and split it by some characters, namely ?
and =
for the query string and ;
and =
for the cookies. We then remove some unnecessary spaces and write it to the REQUEST
associative array.
Parsing the body is more complex. We're dealing with the multipart/form-data
format to allow for multi-line strings and, potentially, file uploads. I found it actually more straightforward to work with than any URL encoding.
#
# Parses the POST body and assigns it to the request object
#
function parse_body() {
RAW_REQ_BODY=$1
declare -n REQ_ARR="$2"
FORM_BOUNDARY_LINE=`echo $RAW_REQ_BODY|grep 'Content-Type: multipart/form-data; boundary='`
FORM_BOUNDARY=${FORM_BOUNDARY_LINE#"Content-Type: multipart/form-data; boundary="}
# Replace the $FORMDATA_BOUNDARY with a single character so we can split with that.
TMP_BODY_PARTS=`echo "${RAW_REQ_BODY//"$FORM_BOUNDARY"/$'§'}" | head -n -2` # We need to use _some_ character to use `read` here.
IFS='§' read -r -d '' -a BODYPARTS <<< "$TMP_BODY_PARTS"
for PART in "${BODYPARTS[@]}"; do
KEY=`echo "${PART}" | grep -o -P '(?<=name=").*?(?=")'`
if [[ "$KEY" != "" ]]; then
VALUE=`echo "${PART}" | head -n -1 | tail -n +4`
REQ_ARR["BODY","$KEY"]=${VALUE::-1}
fi
done
}
When we run the code now with our GET request from before, we get the following output from our Bash server:
declare -A REQUEST=([ROUTE]="/" [METHOD]="GET" )
(Yes, declare -p
creates a declare -A
statement, so one could execute that again to have the same associative array.)
The mentioned POST request would output this:
declare -A REQUEST=([BODY,hello]="world" [ROUTE]="/" [METHOD]="POST" )
Neat!
Reacting to the request
Similar to the REQUEST
array, we declare a RESPONSE
array. This array will contain the DOM we deliver, the status code, and some headers, like Set-Cookie
or Location
for redirects.
Since we need to be able to tell users apart (some are logged in and some are not), we implement a function called set_session
. This generates a session ID, writes it to the SQLite database, and sets a session cookie. Any following request from the same client will send that same session ID cookie.
function set_session() {
declare -n REQ="$1"
declare -n RES="$2"
if [[ "${REQ[COOKIE,SESSID]}" != "" ]]; then
# SESSID cookie was already set once; reset it
RES["COOKIES,SESSID"]="${REQ[COOKIE,SESSID]}"
else
# No SESSID cookie, so let's generate one
SESSID=`echo $RANDOM | md5sum | head -c 20; echo;` # Taken from SO.
# Save cookie into database
sqlite3 db.sqlite "insert into sessions values ('${SESSID}', NULL);" ".exit"
RES["COOKIES,SESSID"]="$SESSID"
fi
}
Notice how we need both the REQ
and the RES
array: We already write to the RESPONSE
array by setting a COOKIES
key with a sub-key called SESSID
.
We call this function after we call parse_request
:
while true; do
# Request reading shenanigans
declare -A REQUEST=()
declare -A RESPONSE=()
parse_request $REQ_RAW REQUEST
set_session REQUEST RESPONSE
# More stuff later, don't worry
kill "$COPROC_PID" # Kill the process for the subsequent request
wait "$COPROC_PID" # Wait until it's actually gone
done
Next, we can implement a function to react to the actual request. We call it render_cms_page
. In there, we look in the database for any entry with a route that matches the route from the request:
function render_cms_page() {
REQUEST=$1
declare -n RES="$2"
DOM=`sqlite3 db.sqlite "select markup from pages where routePath='${REQUEST[ROUTE]}';" ".exit"`
if [[ "$DOM" == "" ]]; then
RES["BODY"]=`render_response "Not found."`
RES["STATUS"]="404 Not found"
else
RES["BODY"]=`render_response $DOM`
RES["STATUS"]="200 OK"
fi
}
You might notice the render_response
function in there, too. We use that to generate all of the surrounding HTML, such as a page header and navigation and some CSS:
function render_response() {
DOC_START=`doc_start $1`
PAGE_HEADER=`page_header`
DOC_END=`doc_end`
cat <<EOF
$DOC_START
$PAGE_HEADER
<main>
$2
</main>
$DOC_END
EOF
}
In there, however, we have the functions doc_start
, page_header
and doc_end
:
function doc_start() {
cat <<EOF
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>$1</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/jgthms/minireset.css@master/minireset.min.css">
<style>
* { font-family: monospace; font-size: 16px; }
h1 { font-size: 1.5rem; font-weight: bold; }
h2 { font-size: 2rem; font-weight: bold; margin-bottom: 0.5rem; }
p { margin-bottom: 0.5rem; }
header { padding: 1rem; border-bottom: 1px solid #000; }
main { padding: 1rem; }
nav ul { margin-top: 1rem; display: flex; gap: 1rem; }
label { display: block; }
</style>
</head>
<body>
EOF
}
function doc_end() {
cat <<EOF
</body>
</html>
EOF
}
function page_header() {
# Fetch header-relevant pages
cat <<EOF
<header>
<h1>Bash CMS</h1>
<nav>
<ul>
EOF
while read -r PAGE_ROW; do
IFS='|' read -r -d '' -a PAGE_PARTS <<< "$PAGE_ROW"
NAV_TITLE=${PAGE_PARTS[0]}
ROUTE_PATH=${PAGE_PARTS[1]}
cat <<EOF
<li><a href="$ROUTE_PATH">$NAV_TITLE</a></li>
EOF
done <<< `sqlite3 db.sqlite "select navTitle, routePath from pages where isInMainNavigation=1;" ".exit"`
LOGGEDIN_USERID=`sqlite3 db.sqlite "select userRowId from sessions where sessId = '${REQUEST[COOKIE,SESSID]}'" ".exit"`
if [[ "$LOGGEDIN_USERID" == "" ]]; then
cat <<EOF
<li><a href="/login" class="login">Login</a></li>
EOF
else
cat <<EOF
<li><a href="/add-new-page" class="login">Add new page</a></li>
<li><a href="/logout" class="login">Logout</a></li>
EOF
fi
cat <<EOF
</ul>
</header>
EOF
}
And with that, we're almost done.
The last step to an actual response is to render a response string. Much like an HTTP request, a response is a single multi-line string with different parts. We only need to assemble it correctly:
function generate_response_string() {
declare -n RES="$1"
# Transform cookie entries into Set-Cookie headers
COOKIES=""
for RESKEY in "${!RES[@]}"; do
if [[ "$RESKEY" =~ ^"COOKIES," ]]; then
COOKIE_NAME=${RESKEY#"COOKIES,"}
COOKIES+="Set-Cookie: $COOKIE_NAME=${RES[$RESKEY]}
" # Adds a newline after this Set-Cookie header.
fi
done
RES["CONTENT_TYPE"]="text/html"
RES["HEADERS","Content-Type"]="${RES[CONTENT_TYPE]}; charset=UTF-8"
RES["HEADERS","Server"]="Bash. Please don't send cat /etc/passwd as a cookie because hacking is bad :("
HEADERS=""
for RESKEY in "${!RES[@]}"; do
if [[ "$RESKEY" =~ ^"HEADERS," ]]; then
HEADER_NAME=${RESKEY#"HEADERS,"}
HEADERS+="${HEADER_NAME}: ${RES[$RESKEY]}
" # Adds a newline after this Set-Cookie header.
fi
done
# declare -p RES
cat <<EOF
HTTP/1.1 ${RES[STATUS]}
${COOKIES::-1}
${HEADERS}
${RES[BODY]}
EOF
}
And we're good to go. Let's see what this does:
(On a side note: Using backticks anywhere is making me nervous now. Who knows what it'll execute...)
Adding the routes
Now that we've implemented the dynamic routes let's take care of the static ones, such as /login
, /edit
, /add-new-page
, /logout
and /delete
. For that, we add two more functions: One for the login form and one for the edit form:
function render_login_page() {
cat <<EOF
<form method="POST" action="/login" enctype="multipart/form-data">
<label for="username">User name</label>
<input type="text" name="username" id="username" value="$1">
<label for="password">Password</label>
<input type="password" name="password" id="password">
<button type="submit">Login</button>
</form>
EOF
}
function render_edit_form() {
NAVTITLE=$1
IS_IN_MAIN_NAVIGATION=""
ROUTEPATH=$3
DOM=$4
if [[ "$2" == "1" ]]; then
IS_IN_MAIN_NAVIGATION=" checked"
fi
cat <<EOF
<form method="POST" enctype="multipart/form-data">
<label for="navtitle">Navigation title</label>
<input type="text" name="navtitle" id="navtitle" value="$NAVTITLE">
<label for="routepath">Route path</label>
<input type="text" name="routepath" id="routepath" value="$ROUTEPATH">
<label for="is_in_main_navigation">
Is it in navigation?
<input type="checkbox" value="1" name="is_in_navigation"$IS_IN_MAIN_NAVIGATION>
</label>
<div>
<label for="dom">Content</label>
<textarea name="dom" id="dom" style="width: 100%; height: 300px;">$DOM</textarea>
</div>
<button type="submit">Save</button>
</form>
EOF
}
And lastly, we expand the render_cms_page
function:
function render_cms_page() {
REQUEST=$1
declare -n RES="$2"
if [[ "${REQUEST[ROUTE]}" == "/login" ]]; then
if [[ "${REQUEST[METHOD]}" == "POST" ]]; then
USERNAME=${REQUEST[BODY,username]}
PASSWORD=`echo ${REQUEST[BODY,password]} | sha256sum`
USERID=`sqlite3 db.sqlite "select rowid from users where username='$USERNAME' and password='${PASSWORD::-3}'"`
if [[ "$USERID" == "" ]]; then
DOM=`render_login_page $USERNAME`
DOM+="<p>Username or password incorrect</p>"
RES["BODY"]=`render_response "Login" $DOM`
RES["STATUS"]="200 OK"
else
sqlite3 db.sqlite "update sessions set userRowId = $USERID where sessId = '${REQUEST[COOKIE,SESSID]}'"
RES["STATUS"]="307 Temporary Redirect"
RES["HEADERS","Location"]="/"
fi
else
DOM=`render_login_page`
RES["BODY"]=`render_response "Login" $DOM`
RES["STATUS"]="200 OK"
fi
DOM=`render_login_page ${REQUEST[BODY,username]}`
elif [[ "${REQUEST[ROUTE]}" == "/logout" ]]; then
sqlite3 db.sqlite "update sessions set userRowId = NULL where sessId = '${REQUEST[COOKIE,SESSID]}'"
RES["STATUS"]="307 Temporary Redirect"
RES["HEADERS","Location"]="/"
elif [[ "${REQUEST[ROUTE]}" == "/add-new-page" ]]; then
if [[ "${REQUEST[METHOD]}" == "POST" ]]; then
IS_IN_MAIN_NAVIGATION="0"
if [[ "${REQUEST[BODY,is_in_navigation]}" == "1" ]]; then
IS_IN_MAIN_NAVIGATION="1"
fi
sqlite3 db.sqlite "insert into pages values ('${REQUEST[BODY,routepath]}', '${REQUEST[BODY,navtitle]}', ${IS_IN_MAIN_NAVIGATION}, '${REQUEST[BODY,dom]}');" ".exit"
RES["STATUS"]="307 Temporary Redirect"
RES["HEADERS","Location"]="${REQUEST[BODY,routepath]}"
else
DOM=`render_edit_form`
RES["BODY"]=`render_response "New page" $DOM`
RES["STATUS"]="200 OK"
fi
elif [[ "${REQUEST[ROUTE]}" == "/edit" ]]; then
LOGGEDIN_USERID=`sqlite3 db.sqlite "select userRowId from sessions where sessId = '${REQUEST[COOKIE,SESSID]}'" ".exit"`
if [[ "$LOGGEDIN_USERID" == "" ]]; then
RES["STATUS"]="403 Forbidden"
RES["BODY"]=`render_response "Nope" "Not allowed to do that"`
else
if [[ "${REQUEST[METHOD]}" == "POST" ]]; then
IS_IN_MAIN_NAVIGATION="0"
if [[ "${REQUEST[BODY,is_in_navigation]}" == "1" ]]; then
IS_IN_MAIN_NAVIGATION="1"
fi
sqlite3 db.sqlite "update pages set routePath='${REQUEST[BODY,routepath]}', navTitle='${REQUEST[BODY,navtitle]}', isInMainNavigation=${IS_IN_MAIN_NAVIGATION}, markup='${REQUEST[BODY,dom]}' where routePath='${REQUEST[QUERY,route]}';" ".exit"
RES["STATUS"]="307 Temporary Redirect"
RES["HEADERS","Location"]="${REQUEST[BODY,routepath]}"
else
PAGE=`sqlite3 db.sqlite "select navTitle, isInMainNavigation, routePath, markup from pages where routePath='${REQUEST[QUERY,route]}'"`
IFS='|' read -r -d '' -a PAGEPARTS <<< "$PAGE"
DOM=`render_edit_form ${PAGEPARTS[0]} ${PAGEPARTS[1]} ${PAGEPARTS[2]} ${PAGEPARTS[3]}`
RES["BODY"]=`render_response "Edit" $DOM`
RES["STATUS"]="200 OK"
fi
fi
elif [[ "${REQUEST[ROUTE]}" == "/delete" ]]; then
LOGGEDIN_USERID=`sqlite3 db.sqlite "select userRowId from sessions where sessId = '${REQUEST[COOKIE,SESSID]}'" ".exit"`
if [[ "$LOGGEDIN_USERID" == "" ]]; then
RES["STATUS"]="403 Forbidden"
RES["BODY"]=`render_response "Nope" "Not allowed to do that"`
else
sqlite3 db.sqlite "delete from pages where routePath='${REQUEST[QUERY,route]}';" ".exit"
echo "delete from pages where routePath='${REQUEST[QUERY,route]}';"
RES["STATUS"]="307 Temporary Redirect"
RES["HEADERS","Location"]="/"
fi
else
DOM=`sqlite3 db.sqlite "select markup from pages where routePath='${REQUEST[ROUTE]}';" ".exit"`
LOGGEDIN_USERID=`sqlite3 db.sqlite "select userRowId from sessions where sessId = '${REQUEST[COOKIE,SESSID]}'" ".exit"`
if [[ "$LOGGEDIN_USERID" != "" ]]; then
DOM+="<div style='margin-top: 20px;'><a href='/edit?route=${REQUEST[ROUTE]}'>Edit</a> | <a href='/delete?route=${REQUEST[ROUTE]}'>Delete</a></div>"
fi
if [[ "$DOM" == "" ]]; then
RES["BODY"]=`render_response "Not found" "Not found."`
RES["STATUS"]="404 Not found"
else
RES["BODY"]=`render_response "Bash CMS!" $DOM`
RES["STATUS"]="200 OK"
fi
fi
}
And we're good. With 443 lines of code, we've written a basic CMS from scratch in Bash only!
Demo time!
(The gif might take a few seconds to load...)
Q&A time!
Q: Does it perform well?
A: No. Not at all. This script can handle a single request at a time. Even Apache can handle several hundred connections at once.
Q: Should I use this...
A: No. Please, for the love of everything, don't.
Q: Does the font need to be monospaced? That's so 1990s
A: Yes. We're using Bash, so why shouldn't it be monospaced?
Q: Anything else?
A: I use Arch, by the way.
I hope you enjoyed reading this article as much as I enjoyed writing it! If so, leave a ❤️! I write tech articles in my free time and like to drink coffee every once in a while.
If you want to support my efforts, you can offer me a coffee ☕ or follow me on Twitter 🐦! You can also support me directly via Paypal!
Top comments (28)
A great article as always! My condolences for you must have had to go through in order to complete the article. That said, this was a very fun and educative read.
I think it also applies that
to bash 😉
Thank you! Yeah, I've lost a few braincells during this, which isbthe exact reason I didn't care about security; The complexity would be unbearable.
Bash is interesting in that regard: There's a gazillion ways of doing the exact same thing, so once you think you've figured out how to do a thing, the next SO post you look at will do it entirely different with no apparent reason. I'm just happy that Bash doesn't rely on probabilities under the hood.
Thankfully bash doesn't play dice, even if it feels like that too often :p
This is awesome. My favorite post on DEV for the past couple weeks.
Thank you so much, glad you liked it!
lmfao, I love this.
I see I am not the only one on a run of "abusing the internet"!
I can't wait to see what you do next...mine is "Conway's game of life in pure HTML"! 😂💗💗
Let's see what my brain decides to come up with next, I usually have little to no control over my creative impulses :D
Conway's game of life in pure HTML sounds complex as anything, very much looking forward to that!
I feel like when I was a child and my mother told me an incredible but plausible horror story to teach me something valuable.
Yeah, very impressive.
Yeah, don't do this at home !
In general don't use Bash except when someone else is maintaining it.
Haha, I can relate to that one :D One can do incredible things with Bash (as this post hopefully showcases) and knowimg it to some degree helps a lot, especially in a DevOps setup. I've seen countless deployment scripts using it, Bash is just so convenient. However, just because it's possible to build large-scale applications with Bash, doesn't mean we should build them. :)
For my own sanity, any non trivial Bash script is too much bash script.
As soon as I see an if statement, or a loop, or a function,
I give up Bash and use $favoriteProgrammingLanguage instead.
Didn't see that coming 😂
To be honest, neither did I, a month ago. It's just amazing how fast dumb ideas can come to life!
That's neat!
Thank you!
I saw the title and I knew this was going to be one hell of a ride.
Great one, Pascal.
Thank you so much! Glad you liked it! For some reason, most of my "Don't try this at home" posts turn out to be quite the adventure. At least in development, unchartered land tends to be fun :D
Now that's what I call a crazy idea!!
Crazy indeed, but hey, at least it's possible :D
I think I should suggest my bank to use this server.
Great article by the way!
Thank you, so glad you liked it! The question is, though, what's worse: COBOL or Bash, right? :D
Yes i like it
Thank you so much!
great content. Ty!
You're very welcome, glad you liked it!
It is so crazy! GREAT! 😃
Thank you, glad you liked it!
is this some kinda black magic? 🤯
Nope, Bash only, the next best thing to black magic! 😅