DEV Community

Cover image for ⚠️ Don't try this at home: A CMS written in Bash ONLY??
Pascal Thormeier
Pascal Thormeier

Posted on • Edited on

⚠️ Don't try this at home: A CMS written in Bash ONLY??

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
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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--
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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" )
Enter fullscreen mode Exit fullscreen mode

(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" )
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

And we're good to go. Let's see what this does:

A header, navigation and the content from the  raw `/` endraw  page!

(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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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!

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!

Buy me a coffee button

Top comments (28)

Collapse
 
yjdoc2 profile image
YJDoc2

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.

Bash works very much like quantum physics: One does not understand Bash; one gets used to it.

I think it also applies that

..if you understand QM, then you don't understand QM...

to bash 😉

Collapse
 
thormeier profile image
Pascal Thormeier

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.

Collapse
 
yjdoc2 profile image
YJDoc2

Thankfully bash doesn't play dice, even if it feels like that too often :p

Collapse
 
cicirello profile image
Vincent A. Cicirello

This is awesome. My favorite post on DEV for the past couple weeks.

Collapse
 
thormeier profile image
Pascal Thormeier

Thank you so much, glad you liked it!

Collapse
 
grahamthedev profile image
GrahamTheDev

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"! 😂💗💗

Collapse
 
thormeier profile image
Pascal Thormeier

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!

Collapse
 
jmfayard profile image
Jean-Michel 🕵🏻‍♂️ Fayard • Edited

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.

Collapse
 
thormeier profile image
Pascal Thormeier

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. :)

Collapse
 
jmfayard profile image
Jean-Michel 🕵🏻‍♂️ Fayard • Edited

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.

Collapse
 
j471n profile image
Jatin Sharma

Didn't see that coming 😂

Collapse
 
thormeier profile image
Pascal Thormeier • Edited

To be honest, neither did I, a month ago. It's just amazing how fast dumb ideas can come to life!

Collapse
 
bogomil profile image
Bogomil Shopov - Бого

That's neat!

Collapse
 
thormeier profile image
Pascal Thormeier

Thank you!

Collapse
 
whyafan profile image
Afan Khan

I saw the title and I knew this was going to be one hell of a ride.

Great one, Pascal.

Collapse
 
thormeier profile image
Pascal Thormeier

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

Collapse
 
slimgee profile image
Given Ncube

Now that's what I call a crazy idea!!

Collapse
 
thormeier profile image
Pascal Thormeier

Crazy indeed, but hey, at least it's possible :D

Collapse
 
lumen profile image
Lumen Lohn Freitas

I think I should suggest my bank to use this server.

Great article by the way!

Collapse
 
thormeier profile image
Pascal Thormeier

Thank you, so glad you liked it! The question is, though, what's worse: COBOL or Bash, right? :D

Collapse
 
aboucodeur profile image
aboucodeur

Yes i like it

Collapse
 
thormeier profile image
Pascal Thormeier

Thank you so much!