⚠️ Don't try this at home: A CMS written in Bash ONLY??
Tags: bash, webdev, programming, donttrythisathome
Published: 2023-09-03 17:02:26

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:

```sql
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:

```bash
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:

```bash
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:

```bash
#!/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.

```bash
#
# 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:

```bash
#
# 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:

```bash
#
# 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.

```bash
#
# 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.

```bash
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`:

```bash
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:

```bash
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:

```bash
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`:

```bash
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:

```bash
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:

![A header, navigation and the content from the `/` page!](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ijz47xzor2f5rpayfzyk.png)

(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:

```bash
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:

```bash
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...)

![The BashCMS in action!](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/la1drhph72d2kk442jbv.gif)

## 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* ☕](http://buymeacoffee.com/pthormeier) *or* [*follow me on Twitter* 🐦](https://twitter.com/pthormeier)*!* *You can also support me directly via [Paypal](https://www.paypal.me/pthormeier)!*

[![Buy me a coffee button](https://dev-to-uploads.s3.amazonaws.com/i/x55hp6jopwyy161d8e2u.png)](http://buymeacoffee.com/pthormeier)