Building a URL Shortener with Netlify Redirects

The Power of Netlify + GitHub + REST APIs

20 Feb 2019

I’ve always love the idea of having a custom URL shortener, I’ve owned pats.website for a while to use as this purpose. Unfortunately, most solutions are complicated and involve servers etc. Since I’ve used Netlify for lots of projects recently it got me thinking, why don’t I just use Netlify’s CDN egde powered redirects file?

I started with a simple repo that I hooked up to Netlify that contained one file _redirects. Now came the hard problem, I wanted to be able to edit this file without having to deal with git. I wanted a simple webform (on a URL only I know) that I could use to create and delete short urls.

GitHub REST API to the rescue!

I built a simple Node application that uses the GitHub API to read and update the redirects file, this sits in a free heroku instance (as it only runs when I’m adding new URLs and has no bearing on day-to-day usage of my shortener).

The core idea is pretty simple once you think about it really. There’s an express server that loads a pug file with a list of urls, and it has a form in it that then adds a line to the redirects file and posts it to GitHub.

Let’s break it down step by step:

1. Fetch the list of urls

app.get('/', function (req, res) {
    octokit.repos.getContents({
        owner,
        repo,
        path
    }).then(result => {
        const redirectsFile = Buffer.from(result.data.content, result.data.encoding).toString('utf8');
        var links = parseLinks(redirectsFile);
        res.render('index', {
            links
        })
    });
});

The Node module for the GitHub API is extremely easy to use, it creates a promise that returns an object with lots of information about the file and its last commit. In it is the content of the file, base64 encoded (and a URL to the file, if it is a blob.)

I then parse the links using my own function that finds markers in the redirects file (as I don’t want to edit all of it, as some portions need to always remain for proper functioning of the website.) and returns an array of objects:

function parseLinks(contents) {
    const redirectsSection = contents.split("####")[1];
    return redirectsSection.split('\n').filter(r => {
        const splits = r.trim().split(/[\s,]+/);
        if (splits.length > 1) {
            return true;
        } else {
            return false;
        }
    }).map(r => {
        const splits = r.trim().split(/[\s,]+/);
        return {
            path: splits[0].trim(),
            destination: splits[1].trim(),
            code: (splits[2] ? splits[2] : ""),
        }
    })
}

This first removes whitespace then creates the objects with past, destination, and code. Code is the HTTP header response code that Netlify should return, if blank it uses 302, if set to 200 (for example, or any non-redirect value) it returns the page without rewriting the URL, which is useful for masking.

I then render these out in out to a list.

2. Create New Link

When the web form posts to the express server with a new link, it again performs the above functions to get a list of links (this way the server is stateless).

First it validates that the path chosen is unique with a simple search of the list. Returning early if it’s not.

It then pushes the new link into the array and calls a set of functions that do the above in the reverse order:

// This inserts the new block of rules into the file at the correct point.
function createFile(contents, links) {
    const formattedLinks = formatLinks(links);
    const sections = contents.split("####");
    sections[1] = `####\n\n${formattedLinks}\n\n####`;
    return sections.join('');
}

// This pretty prints the rules (not necessary to be pretty but why not)
function formatLinks(links) {
    const longestPathLength = links.reduce((length, link) => {
        if (link.path.length > length) {
            return link.path.length;
        }
        return length;
    }, 0);

    const longestDestinationLength = links.reduce((length, link) => {
        if (link.destination.length > length) {
            return link.destination.length;
        }
        return length;
    }, 0);

    const formattedLinks = links.map(link => {
        return `${link.path.padEnd(longestPathLength, ' ')}   ${link.destination.padEnd(longestDestinationLength, ' ')}   ${link.code}`
    })

    return formattedLinks.join('\n');
}

Lastly, it puts it back to GitHub’s servers and updates the file (this triggers Netlify to instantly redeploy, and creates a seamless experience.) Resulting in this function:

app.post('/new', function (req, res) {
    if (req.body && req.body.urlpath && req.body.destination) {
        const newLink = {
            path: req.body.urlpath,
            destination: req.body.destination,
            code: (req.body.code ? req.body.code : "")
        };
        octokit.repos.getContents({
            owner,
            repo,
            path
        }).then(result => {
            const redirectsFile = Buffer.from(result.data.content, result.data.encoding).toString('utf8');
            var links = parseLinks(redirectsFile);
            if (validateUnique(newLink, links)) {
                links.push(newLink);
                const fileContents = createFile(redirectsFile, links);
                const message = `Added ${newLink.path}:${newLink.destination} to _redirects`;
                const content = Buffer.from(fileContents).toString('base64');
                octokit.repos.updateFile({
                    owner,
                    repo,
                    path,
                    message,
                    content,
                    sha: result.data.sha
                }).then(result => {
                    res.redirect("/");
                });
            } else {
                console.log("Not unique");
                res.status(400);
                res.send('Not unique');
            }
        })
    } else {
        res.status(400);
        res.send('Missing components');
    }

});

Lastly a logically similar operation happens for delete.

All of my code—albeit messy—is below, let me know if you set this up too:


const Octokit = require('@octokit/rest');
const express = require("express");
const bodyParser = require('body-parser');

const octokit = new Octokit({
    auth: 'token GITHUB_APPLICATION_TOKEN'
});


const owner = "PatMurrayDEV";
const repo = "pats.website";
const path = "_redirects";


function parseLinks(contents) {
    const redirectsSection = contents.split("####")[1];
    return redirectsSection.split('\n').filter(r => {
        const splits = r.trim().split(/[\s,]+/);
        if (splits.length > 1) {
            return true;
        } else {
            return false;
        }
    }).map(r => {
        const splits = r.trim().split(/[\s,]+/);
        return {
            path: splits[0].trim(),
            destination: splits[1].trim(),
            code: (splits[2] ? splits[2] : ""),
        }
    })
}

function createFile(contents, links) {
    const formattedLinks = formatLinks(links);
    const sections = contents.split("####");
    sections[1] = `####\n\n${formattedLinks}\n\n####`;
    return sections.join('');
}

function validateUnique(short, links) {
    let existingLink = links.find(obj => obj.path === short.path);
    return (existingLink ? false : true);
}

function formatLinks(links) {
    const longestPathLength = links.reduce((length, link) => {
        if (link.path.length > length) {
            return link.path.length;
        }
        return length;
    }, 0);

    const longestDestinationLength = links.reduce((length, link) => {
        if (link.destination.length > length) {
            return link.destination.length;
        }
        return length;
    }, 0);

    const formattedLinks = links.map(link => {
        return `${link.path.padEnd(longestPathLength, ' ')}   ${link.destination.padEnd(longestDestinationLength, ' ')}   ${link.code}`
    })

    return formattedLinks.join('\n');
}

function deleteLink(urlpath, links) {
    var newLinks = [];
    for (let index = 0; index < links.length; index++) {
        const r = links[index];
        console.log(r);
        if (r.path === "/"+urlpath) {
            console.log("removed" + urlpath);
        } else {
            newLinks.push(r);
        }
    }
    console.log(newLinks);
    return newLinks;
}





var app = express();

// parse application/x-www-form-urlencoded
app.use(bodyParser.urlencoded({
    extended: false
}));

// parse application/json
app.use(bodyParser.json());
app.set('view engine', 'pug');


app.get('/', function (req, res) {
    octokit.repos.getContents({
        owner,
        repo,
        path
    }).then(result => {
        const redirectsFile = Buffer.from(result.data.content, result.data.encoding).toString('utf8');
        var links = parseLinks(redirectsFile);
        res.render('index', {
            links
        })
    });
});

app.get('/delete/:id', function (req, res) {
    octokit.repos.getContents({
        owner,
        repo,
        path
    }).then(result => {
        const redirectsFile = Buffer.from(result.data.content, result.data.encoding).toString('utf8');
        var links = parseLinks(redirectsFile);

        const newLinks = deleteLink(req.params.id, links);
        const fileContents = createFile(redirectsFile, newLinks);
        const message = `Removed ${req.params.id} to _redirects`;
        const content = Buffer.from(fileContents).toString('base64');
        octokit.repos.updateFile({
            owner,
            repo,
            path,
            message,
            content,
            sha: result.data.sha
        }).then(result => {
            res.redirect("/");
        });

    });
});

app.post('/new', function (req, res) {
    if (req.body && req.body.urlpath && req.body.destination) {
        const newLink = {
            path: req.body.urlpath,
            destination: req.body.destination,
            code: (req.body.code ? req.body.code : "")
        };
        octokit.repos.getContents({
            owner,
            repo,
            path
        }).then(result => {
            const redirectsFile = Buffer.from(result.data.content, result.data.encoding).toString('utf8');
            var links = parseLinks(redirectsFile);
            if (validateUnique(newLink, links)) {
                links.push(newLink);
                const fileContents = createFile(redirectsFile, links);
                const message = `Added ${newLink.path}:${newLink.destination} to _redirects`;
                const content = Buffer.from(fileContents).toString('base64');
                octokit.repos.updateFile({
                    owner,
                    repo,
                    path,
                    message,
                    content,
                    sha: result.data.sha
                }).then(result => {
                    res.redirect("/");
                });
            } else {
                console.log("Not unique");
                res.status(400);
                res.send('Not unique');
            }
        })
    } else {
        res.status(400);
        res.send('Missing components');
    }

});

const port = parseInt(process.env.PORT) || 3000;
app.listen(port, () => console.log(`Example app listening on port ${port}!`))