The Minimalist's Web App - A Sysadmin's Perspective

By Jake Freeland on May 19, 2022

Introduction

Welcome to the cdaemon blog. This infrastructure is the child of numerous curated articles and tutorials on the internet discussing proper practice for building a dynamic web app. This specific article strives to demonstrate effective web development and will guide you through setting up a blog, but can easily conform to a number of basic web applications.

Subsequent to my vision for this platform, I attempted searching the web for phrases pertaining to:

To my surprise, there was no documentation surrounding dynamic, zero-dependency web apps. As I crawled through various sources, I quickly realized that this idolized idea of a zero-dependency web infrastructure is nearly impossible. With this in mind, I set out to document the process of building the cdaemon blog with a minimal footprint.

This guide is not the be-all end-all of web development tutorials. Instead, it serves to introduce solutions to the most conceptually challenging components of constructing a modern web app.

Article Outline

Prerequisite Knowledge

Basic HTML, CSS, JavaScript, and command line experience is assumed for this tutorial. If you feel uncomfortable with any of these or want a refresher, take a look at Mozilla's Documentation below.

Starting with front-end web development will give you a vision of what your web app will look like before you dive into the inner workings. This tutorial expects that you have already created basic HTML and CSS for your web app. If you need help getting started, follow the guides above.

File Structure

A proper file structure is essential to your project's organization and fluidity. It is smart to plan this aspect out before you start your work. I have included a table of my project's file tree below. Feel free to modify your own implementation, but I will be writing with mine in mind.

Start by creating the root directory for your project. Feel free to name this directory whatever you want. I am going to call it cdaemon.com. Next, make 3 empty directories inside the root named public, routes, and views. Then make an empty file named server.js. Each of these will be discussed in further detail later.

Here is a basic overview of my root directory:

Name Description
public public files
routes express routes
views express views
server.js express server

NodeJS Server

Backend web development exists in many languages including Go, Python, Ruby, JavaScript, PHP, and more. This specific tutorial will use JavaScript for the sake of simplicity. JavaScript serves as a universal language in (1) server-side and (2) client-side web development. With this in mind, we will need to learn one less programming language when using JavaScript.

NodeJS is a JavaScript runtime environment that enables scripting from the server side. This tutorial will use NodeJS to process blog posts and dynamically display them on a web page. Installing npm, the package manager for NodeJS, is also highly recommended for convenience.

It is advised to install NodeJS and npm using a command-line package manager (e.g. apt, brew, etc.), but downloads can alternatively be found on the their respective websites.


With NodeJS and npm installed, the server can now be initialized.

Open a terminal window at your project's root directory and run:

$ npm init -y

This will generate a package.json file to keep track of all of the packages that we install using npm.

Express

Installation

The first and arguably most important package that we need is Express. Express gives our web app the ability to intercept HTTP requests so it can communicate with a client and process data.

Install Express using npm:

$ npm install express

When npm is finished with the install, you will find a package-lock.json file and a node_modules directory in the project's root. This pair will manage the required libraries for your project.

Setup

Open the empty server.js file you created earlier and append:

const express = require("express");
const app = express();

app.get('/', (req, res) => {
    res.send("Hello World!");
});

app.listen(3000);

The Express API can be accessed using app.

The app.get(...); snippet will listen for GET requests on / and respond by sending the string, Hello World!.

NOTE: GET requests are sent by the browser when accessing a web page. For example, if you go to https://cdaemon.com, you're sending a GET request to cdaemon's /.

The app.listen(3000); will tell Express to act and communicate on port 3000. This will connect us to the server after we start it.

Starting the Server

The NodeJS server needs to be started before we can interact with our web app. Open a new terminal window at your project's root directory and issue:

$ npm start

This will start your NodeJS server, executing the contents of your server.js. Open your web browser and enter localhost:3000 into the address bar. You should see Hello World if everything was set up correctly.

As this configuration stands, every time a change is made to the project, the NodeJS server needs to be restarted. This minor inconvenience can quickly become irritating. To mitigate this, you can install nodemon.

$ npm install --save-dev nodemon

This package is optional, but it's relatively minimal and can help prevent future headaches. The npm --save-dev option saves the package as a developer dependency, excluding its use in a production environment.

In order to utilize nodemon, the package.json file must be modified. Open package.json and add "devStart": "nodemon server.js", under "scripts".

  "scripts": {
    "devStart": "nodemon server.js",
    "start": "node server.js"
  },

Save package.json, terminate your previous npm instance, and restart npm using nodemon:

$ npm run devStart

Assuming no errors arose, nodemon should now automatically restart the NodeJS server when changes are made to the project's source code.

The Public Directory

As its name implies, the public directory stores content that is publicly accessible. Static files like HTML, CSS, local scripts, and images are commonly found in public.

This tutorial does not cover the basics of HTML and CSS. Relevant documentation can be found in the Prerequisite Knowledge section.

Navigate into your public directory and make HTML, CSS, scripts, and media subdirectories. Proceed by placing your HTML and CSS files into their respective locations.

Return to your server.js file and add the following line. This will tell Express that your public folder contains static files.

const express = require("express");
const app = express();

+ app.use(express.static("public"));

app.get('/', (req, res) => {
    res.send("Hello World");
});

app.listen(3000);

The Views Directory

Dynamic visual templates, or views, are kept inside of the views directory. Creating a view can be complicated, but this tutorial opts to use EJS, a lightweight JavaScript template engine that integrates seamlessly into HTML. This means that static HTML can be turned into a view with a few minor modifications.

Install EJS using npm:

$ npm install ejs

Move any static HTML that would better serve as a dynamic view into the views directory. Then rename all of these files from FILENAME.html to FILENAME.ejs.

Next, add the following line to your server.js file to set EJS as Express's view engine.

const express = require("express");
const app = express();

+ app.set("view engine", "ejs");
app.use(express.static("public"));

app.get('/', (req, res) => {
    res.send("Hello World");
});

app.listen(3000);

If you have prepared a homepage, now is a good time to set it up.

const express = require("express");
const app = express();

app.set("view engine", "ejs");
app.use(express.static("public"));

app.get('/', (req, res) => {
- res.send("Hello World");
+ res.render("index.ejs");
});

app.listen(3000);

This tutorial uses index.ejs as the blog's home page. If your homepage filename differs, make sure to substitute index.ejs for your own. Don't worry about making dynamic modifications to your homepage yet. EJS will be discussed more in the Dynamic EJS section.

After refreshing your browser, your homepage should now appear instead of Hello World.

The Routes Directory

Over time, your server.js is bound to resemble spaghetti code. It is extremely difficult to keep your project organized when every component of your web app is kept in a single file. The routes directory serves as a solution to this problem. Although routes is entirely optional, it allows you to split your web app into multiple source files.

Inside your routes directory, create a new .js file for every isolated component of your web app. If you don't want to isolate any code now, you can always add routes later.

This tutorial is going to add routes/posts.js and routes/media.js to house code for blog posts and images respectively.

In order to access these routes, your server.js must be modified to see the new files:

...

app.get('/', (req, res) => {
  res.render("index.ejs");
});

+ const postRouter = require("./routes/posts");
+ app.use("/posts", postRouter);
+ const mediaRouter = require("./routes/media");
+ app.use("/media", mediaRouter);

...

For each route, a path must be specified. This path acts as an interface to send and receive http requests. All requests with this path will be sent to the linked JavaScript file.

For example, routes/posts.js will be accessible via the /posts path of the URL.

NOTE: The "/posts path of the URL" is referring to the "/posts" suffix in https://cdaemon.com/posts.

Blog Posts

In every route file, there must be some boiler plate code to allow communication between the server.js file and your new route:

const express = require("express");
const router = express.Router();

...

module.exports = router;

After you include the boiler plate, the route file can be treated like an extension to server.js. I am going to start by extending routes/posts.js.

const express = require("express");
const router = express.Router();
+ const db = require("../user_modules/db.cjs");
+ 
+ router.get('/', (req, res) => {
+   db.showTables("blog_tags")
+   .then(tables => {
+     res.render("posts/posts", { tags: tables, username: req.session.username });
+   })
+   .catch(console.log);
+ });
+ 
+ router.get("/archive", (req, res) => {
+   db.getOrderedData("blog_posts", "post", "date", "desc")
+   .then(posts => {
+     res.render("posts/archive", { posts: posts, username: req.session.username });
+   })
+   .catch(console.log);
+ });
+
module.exports = router;

When working within routes, router must be used to access the Express API instead of app. The code above is a simplified snippet of my routes/posts.js file. You can view the complete version here.

JavaScript Modules

NOTE: There are various types of JavaScript modules. Igor Irianto did a short write up on the differences between the module types here if you're curious.

I chose to use the older, non-standard CommonJS (CJS) module format because NodeJS uses it at this time of writing. You can see me importing the file here:

const db = require("../user_modules/db.cjs");

You only need to know that db.cjs provides functions that communicate with the database for now. We will go over the specifics later. It should be noted that db.cjs was created by me and is not available through npm or as a standard JavaScript module.

Once the module is imported, you can use its functions like this:

db.showTables("blog_tags")

Choosing a Database

One of the most important components of your blog is the database that stores your posts. Some popular choices are MongoDB, MySQL, and PostgreSQL. All three of these will do the job, but they all have varying capabilities. I'd recommend doing some research on which one fits your needs the most.

I chose the MariaDB database, an open source friendly fork of MySQL, because of its extensive documentation and loose type system. Amazon RDS has an AWS Free Tier that offers enough storage and service hours to get a basic blog running with most common databases.

Once you choose a database, you need to install its NodeJS library using npm:

$ npm install [database library]

In my case:

$ npm install mariadb

After the database's NodeJS library is installed, you will be able to access the database through an API. The aforementioned user_modules/db.cjs contains several functions that use MariaDB's API to communicate with the database. Let's take another look at the db.cjs file.

First, I import the mariadb library:

const mariadb = require(`mariadb`);

Next, I create a connection "pool" that declares my connection information:

const mariadb = require(`mariadb`);

+ let pool = mariadb.createPool({
+   host: process.env.DB_HOST,
+   user: process.env.DB_USER,
+   password: process.env.DB_PASS,
+   connectionLimit: 5
+ });

Then follow the CJS format to add desired functions into the module:

const mariadb = require(`mariadb`);

let pool = mariadb.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASS,
  connectionLimit: 5
});

+ module.exports = {
+     insertData: async function insertData(database, table, columns, data) {
+     if (Array.isArray(columns) && Array.isArray(data)) {
+       columns = columns.join(", ");
+ 
+       for (var i=0; i<data.length; ++i) {
+         data[i] = pool.escape(data[i]);
+       }
+       data = data.join(", ");
+     } else {
+       data = pool.escape(data);
+     }
+ 
+     try {
+       var conn = await pool.getConnection();
+       return await conn.query(`INSERT INTO ${database}.${table} (${columns}) VALUES (${data})`);
+     } finally {
+       if (conn) conn.close();
+     }
+   },
+ 
+   getData: async function getData(database, table) {
+     try {
+       var conn = await pool.getConnection();
+       return await conn.query(`SELECT ` FROM ${database}.${table}`);
+     } finally {
+       if (conn) conn.close();
+     }
+   }
+ }

NOTE: Those with malicious intent can use SQL injection to retrieve and manipulate data in an SQL-based database. Ensure that you're properly escaping database queries to avoid this common attack vector.

The code above is just a snippet of the entire db.cjs file. You can view the entire module here.

Dynamic EJS

Dynamic content can automatically update itself based on its input parameters. This is particularly useful for creating HTML template files. For example, instead of writing a whole new HTML file for every new blog post, we can use a single dynamic EJS template and feed post-specific data as arguments.

See the views/posts/post.ejs EJS template file:

<!DOCTYPE html>
<html>
  <head>
    <title><%= title %> - cdaemon</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="/styles/skeleton.css">
    <link rel="stylesheet" href="/styles/post.css">
    <link rel="stylesheet" href="/styles/markdown.css">
    <link rel="stylesheet" href="/fonts/opensans/css/all.css">
    <link rel="stylesheet" href="/fonts/fontawesome/css/all.min.css">
  </head>

  <body>
    <header><%- include("../partials/header") -%></header>

    <main>
      <section class="intro" style="background-image: url('/media/<%= pid %>/<%= banner %>');">
        <div class="intro-text">
          <h1><%= title %></h1>
          <h5>By <a href="/users/<%= username %>"><%= author %></a> on <%= date %></h5>
          <% if (edit_date) { %> <h5>Edited on <%= edit_date %></h5> <% } %>
        </div>
      </section>

      <section class="body markdown-themify">
        <%- body -%>
      </section>

      <section class="tags">
        <hr>
        <h6>
          Tags:
          <% for (var i=0; i<tags.length; ++i) { %>
            <a href="/tags/<%= tags[i] %>"><%= tags[i] %></a>
          <% } %>
        </h6>
      </section>
    </main>

    <footer><%- include("../partials/footer") -%></footer>
  </body>
</html>

If you recall, earlier in this tutorial I recommended installing EJS using npm. The EJS package will allow us to render dynamic .ejs files with parameters inside of routes/posts.js:

...

router.route("/:pid")
.get((req, res) => {
  db.getValueData("blog_posts", "post", "pid", req.params.pid)
  .then(post => {
    res.render("posts/post", {
      title: post[0].title,
      /` sanitization to mitigate XSS `/
      body: DOMPurify.sanitize(markdown.parse(post[0].body)),
      tags: post[0].tags.split(','),
      banner: post[0].banner,
      author: post[0].firstname + " " + post[0].lastname,
      pid: post[0].pid,
      username: post[0].username,
      date: formatDate(post[0].date),
      edit_date: formatDate(post[0].edit_date)
    });
  })
});

...

The code above will fetch post data based on the given pid and feed it into the dynamic views/posts/post.ejs. The title: post[0].title, from routes/posts.js allows us to use <h1><%= title %></h1> in views/posts/post.ejs.

NOTE: Cross-site scripting or XSS enables client-side injection of malicious scripts into public webpages. It is highly advised to sanitize any user input that goes through your web app. I used the DOMPurify library to sanitize user input.

Post Editor

You could write posts and update the blog manually, but we want this process to be automatic. The obvious alternative is to write an editor that will insert posts into the database.

I recommend starting with the editor's front-end to get an idea of the data you'll be passing around. For example, my implementation of an editor requires a title, body, tags, pid, banner, username, firstname, and lastname. After the HTML and CSS are complete, consider your options for text editing. There are an abundance of What You See Is What You Get text editors available through NodeJS libraries that provide an interface to edit text. Instead, I opted for a simple HTML text area that uses a script to convert its inner contents from markdown to HTML.

The HTML form in views/posts/editor.ejs:

<form method="post" id="publish-form" autocomplete="off" spellcheck="true">

  <!-- title -->
  <input name="title" class="title" placeholder="Title" maxlength="64" autofocus <% if (locals.post) { %> value="<%= post[0].title %>" <% } %>/>
  <hr class=rounded>

  <!-- body -->
  <textarea name="body" id="body-md" class="body" placeholder="Body (markdown supported)"><% if (locals.post) { %><%= post[0].body %><% } %></textarea>
  <hr class="rounded">

  <!-- tags -->
  <input name="tags" class="tags" placeholder="Tags (comma seperated)" maxlength="64" <% if (locals.post) { %> value="<%= post[0].tags %>" <% } %>/>
  <hr class="rounded">

  <!-- banner -->
  <input name="banner" class="banner" maxlength="256" hidden <% if (locals.post) { %> value="<%= post[0].banner %>" <% } %>/>

</form>

Markdown to HTML script public/scripts/markdown.js:

import ` as markdown from "/scripts/markdown-wasm/markdown.es.js";
await markdown.ready;

const bodyMarkDown = document.getElementById("body-md");
const bodyHTML = document.getElementById("body-preview");

bodyMarkDown.addEventListener("input", () => parse());

function parse() {
  bodyHTML.innerHTML = markdown.parse(bodyMarkDown.value);
}

NOTE: public/scripts/markdown.js uses a markdown-wasm ES module. The markdown-wasm project uses WebAssembly to parse markdown input and output equivalent HTML.

After the frontend is setup to submit a blog post, begin working on the backend. The backend's job is to receive and process form data. Return to routes/posts.js and construct the appropriate request responses:

...

router.route("/editor/new/:pid")
.get((req, res) => {
  if (req.session.username) {
    res.render("posts/editor", { author: req.session.firstname + " " + req.session.lastname });
  } else {
    res.redirect("/users/login");
  }
})

...

When the browser tries to access https://cdaemon.com/posts/editor/new/:pid, where ":pid" is any valid post ID, the code above checks if the visitor is a registered user. If the visitor is registered, Express will render the views/posts/editor.ejs file with the user's name as the author argument. If the visitor is not registered, they will be redirected to the login page.

Triggering the .get((req, res) ... ) portion of the code is as easy as visiting the URL, but we also need to send a POST request to upload blog posts into the database:

...

router.route("/editor/new/:pid")
.get((req, res) => {
  if (req.session.username) {
    res.render("posts/editor", { author: req.session.firstname + " " + req.session.lastname });
  } else {
    res.redirect("/users/login");
  }
})
+ .post((req, res) => {
+   if (req.body.title && req.body.body && req.body.banner && req.session.username) {
+     uploadPost(req.body.title, req.body.body, req.body.tags, req.params.pid,
+       req.body.banner, req.session.username, req.session.firstname, req.session.lastname)
+     .then(() => res.redirect(`/posts/${req.params.pid}`))
+     .catch(err => res.sendStatus(500));
+   } else {
+     res.sendStatus(400);
+   }
+ });

...

When the form in views/posts/editor.ejs is submitted, a POST request is sent the current URL. In this case, https://cdaemon.com/posts/editor/new/:pid would be the form's destination. If there is a valid post title, body, tags, banner and the visitor is a registered user, the post will be uploaded using the uploadPost() function found in routes/posts.js.

It's helpful to note that the fetch() API can manually send HTTP request methods for practical and debugging purposes.

Media

The first thing that you need to consider is how much storage you're willing to allot for file upload. A blog post's plain text body is small in disk size, but images and videos will fill up your drive fast. You could opt to use a third party image hosting service like Imgur or Google Photos to circumvent this issue, but then you're subject to their varying storage quotas. I prefer having more control over my files so I chose to upload them locally.

Return to views/posts/editor.ejs and ensure that you've added a button to upload media:

<div class="options-buttons">
  <input id="banner-upload" type="file" accept="image/`" autocomplete="off" hidden />
  <label for="banner-upload">Banner</label>
  <input id="media-upload" type="file" accept="image/`" autocomplete="off" multiple hidden />
  <label for="media-upload">Image</label>
  <button id="publish-btn">Publish</button>
</div>

The HTML <input id="media-upload" ... /> allows us to upload photo data into a temporary bus. Clicking the "Image" button won't do anything by itself, so a script must be used to handle the media. The public/scripts/editor.js script will process and upload the images:

...

uploadInput.addEventListener("change", () => {
  uploadMedia(uploadInput.files)
  .then(insertTemplate)
  .then(mdNames => {
    const curText = body.value;
    const curPos = body.selectionStart;
    body.value = curText.slice(0,curPos) + mdNames + curText.slice(curPos);
    return body.dispatchEvent(new Event("input"));
  })
  .then(() => uploadInput.value = "")
  .catch(alert);
});

...

When the aforementioned "Image" button is clicked, its state changes and the event above is triggered. The uploadMedia function uploads the files and then a markdown image string is inserted into the body of the post. This markdown string includes a link to the image(s) which displays them appropriately.


Inserting files into blog posts can get messy and tedious, so I would recommend creating a new route for handling media. I will be working in routes/media.js.

If you used the fetch() API to send photo data, you're going to need a way to receive that data in the backend. I used npm to install express-fileupload.

$ npm install express-fileupload

The express-fileupload package grants access to files sent in formData objects using req.files:

router.post('/upload/:pid', (req, res) => {
  uploadMedia(req.files.media, req.params.pid)
  .then(() => res.sendStatus(200))
  .catch(err => res.send(err));
});

When the images were inserted into the formData instance, they were added under the media key. That is why the code above attempts to access req.files.media.

If you're self hosting, you can use express-fileupload's .mv() method to move files as you would in a UNIX environment. The uploadMedia() function creates a new directory named after the post's PID and moves the photos contained in req.files.media into the directory for use in the blog post.

async function uploadMedia(media, pid) {
  const pidPath = path.resolve(__dirname, `../public/media/pid/${pid}/`);

  if (!fs.existsSync(pidPath)) {
    fs.mkdirSync(pidPath);
  }

  if (media.length === undefined) {
    // mv is not async but returns promise
    media.mv(pidPath + '/' + media.name)
    .catch(err => console.log(err));
  } else {
    for (var i=0; i<media.length; ++i) {
      // mv is not async but returns promise
      media[i].mv(pidPath + '/' + media[i].name)
      .catch(err => console.log(err));
    }
  }

  return 0;
}

Safety and Security

If you're planning on making your post editor available to the public, I'd recommend dedicating a large portion of your development time to security. Web vulnerabilities put your posts and users at risk. I've already mentioned SQL injection and Cross-site scripting, but there are a host of other attack vectors that pose a danger to your web app. Consider using libraries like Helmet if you think they're necessary. It is important to note that this tutorial is a demonstration and should be tailored to your use case.

Deploying Your Web App

There are countless ways to deploy a web application. You could self-host, rent a virtual private server, or use an easy-deploy service like Heroku. I use an Amazon Lightsail virtual private server where FreeBSD, NodeJS and NGINX work together to host cdaemon. Unfortunately every hosting method differs, so I cannot guide you through a standardized process.

The Future

I do not intend to open my post editor up to the public, but I have created a user login system for post commenting. This feature is low-priority to me right now, but you can take a look at my current infrastructure here if you're interested.

This marks the end of this blog post. I hope you've found my documentation helpful.

Jake Freeland


Tags: cdaemon javascript