Cloudflare Link Shortener

cloudflare tagJavaScript tag🌐 Github

Description

A small web app that shortens links. Built as a way to explore Cloudflare Workers, wrangler and KV.

Features

Screenshots

Home  page Basic authentication dialog Add link page Success page Error page manage page

Code Highlights

The manage page is populated by a fetch to a /list endpoint that returns a JSON array. Since this list of links could become large I wanted an easy,reusable way populate the DOM on the /manage path. For this I created an autonomous custom element called ‘ShortenedLink’. It takes ‘href’ and ‘shortid’ attributes in order to create a details tag that contains a short link, long link, and a button for deleting the link. When the delete button is clicked, a form is submitted to the /manage path with the id.

class ShortenedLink extends HTMLElement {
    constructor() {
        super();
    }
    #setDOM() {
        this.innerHTML = ""; //clear any existing content
        let details = document.createElement("details");
        let summary = document.createElement("summary");
        let shortAnchor = document.createElement("a");
        let longAnchor = document.createElement("a");
        let deleteForm = document.createElement("form");
        let delInput = document.createElement("input");
        let submit = document.createElement("button");
        let fullDisplayURL = this.href; //default on a long string
        try {
            fullDisplayURL = new URL(this.href).hostname; //attempt to shorten that string to the hostname
        } catch (e) {} //do noting since we already have a string we can use
        //ensure the string we're displaying is not too long
        let displayURL =
            fullDisplayURL.length > 20 ? fullDisplayURL.substring(0, 17) + "..." : fullDisplayURL;

        summary.innerText = `${this.shortid} (${displayURL})`;
        shortAnchor.href = `/${this.shortid}`;
        shortAnchor.innerText = this.shortid;
        longAnchor.href = this.href;
        longAnchor.innerText = this.href;
        longAnchor.target = "_blank";
        deleteForm.method = "delete";
        delInput.type = "hidden";
        delInput.name = "id";
        delInput.value = this.shortid;
        delInput.style.display = "none";
        submit.type = "submit";
        submit.innerText = "delete";
        deleteForm.appendChild(delInput);
        deleteForm.appendChild(submit);
        details.appendChild(summary);
        details.appendChild(shortAnchor);
        details.appendChild(longAnchor);
        details.appendChild(deleteForm);
        this.appendChild(details);
    }
    get href() {
        return this.getAttribute("href");
    }
    set href(val) {
        this.setAttribute("href", val);
        this.#setDOM();
    }
    get shortid() {
        return this.getAttribute("shortid");
    }
    set shortid(val) {
        this.setAttribute("shortid", val);
        this.#setDOM();
    }
}
window.customElements.define("shortened-link", ShortenedLink);

The itty-router package allows routes to be set up in a super simple way that is similar to ExpressJS. It can even accept middleware functions for added functionality such as checking authentication or conditional responses.

const router = Router();
//taken from the Cloudflare Workers site template
const serveAsset = async () => await AssetFromKV(event);

//if there is an issue with authentication then a badAuthResponse will be returned (effectively an error page)
//if there are no issues then this function will return nothing, acting as middleware.
const verifyBasicAuth = async (request) => {
    if (!request.headers.has("Authorization")) {
        return await badAuthResponse(request, "No authorization");
    } else {
        const { user, pass } = basicAuthentication(request);
        if (user != USERNAME || pass != PASSWORD) {
            return await badAuthResponse(request, "Bad authorization");
        }
    }
};

//if a path is found for a short ID then redirect to it
//otherwise this request will be handled by the normal router
const findShortURL = async (req) => {
    let key = new URL(req.url).pathname.substring(1);
    let result = null;
    await SHORTENED_LINKS.get(key)
        .then((url) => (result = url)) //assign the URL to the result
        .catch(); //let this fall through
    if (result) {
        return Response.redirect(result, 302);
    }
};

Setting up the API is as simple as passing the middleware functions before the final routing function. The ‘addRoute’ method creates link IDs from a substring of the base64 encoding of the current date and time, so while short URLs could end up very similar to each other if they’re created in succession, they will always be unique.

router.post("/add.html", verifyBasicAuth, addRoute);
router.delete("/manage.html", verifyBasicAuth, removeRoute);
//serve a JSON array of links
router.get("/list", verifyBasicAuth, listShortURLs);
//for all other routes check for a short URL with the matching path/id, then try serving the asset from KV storage
//will default on 404 if the short link or asset is not found
router.get("*", findShortURL, serveAsset);

GitHub Action

Most testing can be done using wrangler dev in the command line, however using Cloudflare’s template for this action means I can maintain the latest code on their edge network.

name: Deploy CF Worker with Wrangler

on:
    push:
        branches:
            - main
        paths:
            - "public/**"
            - "workers-site/**"
            - "wrangler.toml"

jobs:
    deploy:
        runs-on: ubuntu-latest
        name: Deploy
        steps:
            - uses: actions/checkout@v2
            - name: Publish
              uses: cloudflare/[email protected]
              with:
                  apiToken: ${{ secrets.CF_API_TOKEN }}

Itty-router makes managing routes a breeze and with continuous deployment this is a great set-up for deploying prototypes or small apps on a global scale. I’m keen to explore larger, more complex APIs or even OAuth2 in the future.

You can check out the full code on GitHub.