How I built Atom: A Simple URL Shortener with Flask and Hashids

3 min read

Have you ever encountered short URLs from bit.ly and t.co? These services are called URL Shorteners. Website URLs are often long that it is not easy to send them thru text messages or tweets because of the length limitation. Here is an easy way to build a URL shortener using Flask, and Hashids.

I will be calling our application as Atom since the word "atom" ⚛ is analogous to "small".

Update (January 6, 2019): To protect the service from attackers, I added reCAPTCHA.  For further reading, read this blog written by John Sobanski

Let's start!

We will use Python as our main programming language and Flask as our web framework because why not? These are easy to use. The URLs will be stored in a MySQL database and we will use SQLAlchemy as our interface between our web server and the database. To handle web forms easily, we will use Flask-WTF. Lastly, we will use Hashids to generate short hashes or strings.


The Database Model

We will be using pure SQLAlchemy and not Flask-SQLAlchemy. The code below shows the model which will represent our MySQL table. id and url are the most important columns.

from sqlalchemy import Column, String, TIMESTAMP, text
from sqlalchemy.dialects.mysql import BIGINT

from models import Base


class Atom(Base):
    __tablename__ = 'atoms'
    id = Column(BIGINT(unsigned=True), primary_key=True)
    url = Column(String(2_000))
    added = Column(TIMESTAMP, nullable=False, server_default=text('CURRENT_TIMESTAMP'))

The following code creates the table atoms in MySQL database. Notice that I have used CyMySQL as our database driver. One way to store credentials is thru environment variables.

For the list of drivers for MySQL, click here.

import os

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

from models import Base

engine = create_engine(f'mysql+cymysql://{os.getenv("DB_USER")}:{os.getenv("DB_PASSWORD")}@{os.getenv("DB_HOST")}:'
                       f'{os.getenv("DB_PORT")}/{os.getenv("DB_NAME")}', pool_recycle=7200, pool_pre_ping=True)
session_maker = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base.metadata.create_all(bind=engine)

The Form

Using WTForms is an easy way to populate and validate HTML <forms> from server-side. We used the validators URL to validate URLs from users and Length to limit our URLs to 2,000 characters.

from flask_wtf import FlaskForm, RecaptchaField
from wtforms import SubmitField, StringField
from wtforms.validators import Length, URL


class AtomForm(FlaskForm):
    url = StringField('URL', validators=[URL(require_tld=True, message="Invalid URL"),
                                         Length(max=2000, message="Maximum URL length is 2000")])
    recaptcha = RecaptchaField()
    atomize = SubmitField('Atomize')

The code below shows how to use the form on a HTML template.

{% if atomized %}
    <input id="atomized" class="form-control" value="{{ atomized }}" readonly>
{% endif %}
<form method="POST" action="{{ url_for('index') }}" autocomplete="off">
    {{ form.csrf_token }}
    <div class="form-group has-feedback {{ 'has-error' if form.url.errors else '' }}">
        {{ form.url(class_='form-control' + (' is-invalid' if form.url.errors else ''), placeholder='Enter URL') }}
        {% if form.url.errors %}
            <div>
                <small class="text-danger">{{ form.url.errors[0] }}</small>
            </div>
        {% endif %}
    </div>
    <div class="form-group has-feedback {{ 'has-error' if form.recaptcha.errors else '' }}">
        <div class="text-center">
            {{ form.recaptcha() }}
        </div>
        {% if form.recaptcha.errors %}
            <div class="text-center">
                <small class="text-danger">{{ form.recaptcha.errors[0] }}</small>
            </div>
        {% endif %}
    </div>
    <div class="form-group has-feedback">
        {{ form.atomize(class_='btn btn-primary btn-block btn-flat') }}
    </div>
</form>

The Application Code

We will be using the Hashids module to generate hash strings from the column id to represent the URL.

As an added security, we will store a salt into the environment variable ATOM_SALT. Adding a salt prevent attackers from easily guessing the URLs, although this only adds complexity. With the right tools and enough time, a capable attacker can guess the URLs.

Obtain reCAPTCHA keys from Google reCAPTCHA and save them to the environment variables RECAPTCHA_PUBLIC_KEY and RECAPTCHA_PRIVATE_KEY. Take note that the 'Site Key' will be stored into the RECAPTCHA_PUBLIC_KEY.

To learn more about salts, click here.

import binascii
import os
from urllib.parse import urlparse

from flask import Flask, request, url_for, render_template, redirect
from hashids import Hashids


application = Flask(__name__)
application.config['RECAPTCHA_PUBLIC_KEY'] = os.getenv('RECAPTCHA_PUBLIC_KEY')
application.config['RECAPTCHA_PRIVATE_KEY'] = os.getenv('RECAPTCHA_PRIVATE_KEY')


@application.route('/', methods=['GET', 'POST'])
def index():
    global session_maker
    form = AtomForm()
    atomized = None
    if request.method == 'POST' and form.validate_on_submit():
        if urlparse(form.url.data).netloc == urlparse(url_for('atom.index', _external=True)).netloc:
            form.errors['url'] = ['URLs from Atom (this site) are not allowed']
        else:
            database_session = make_session()
            try:
                a = Atom(url=form.url.data)
                database_session.add(a)
                database_session.commit()
                hash_id = Hashids(salt=os.getenv('ATOM_SALT')).encode(a.id)
                atomized = url_for('atom.get_url', hash_id=hash_id, _external=True)
            finally:
                database_session.close()
        form.url.data = ''
    return render_template('atom.html', form=form, atomized=atomized)


@application.route('/<hash_id>')
def get_url(hash_id):
    global session_maker
    database_session = session_maker()
    try:
        atom_id = Hashids(salt=os.getenv('ATOM_SALT')).decode(hash_id)[0]
        row = database_session.query(Atom).filter_by(id=atom_id).first()  # type: Atom
        if row:
            return redirect(row.url)
        raise LookupError
    except (IndexError, LookupError):
        return redirect(url_for('index'))
    finally:
        database_session.close()

The Website

To make it useful and presentable to users, I used Bootstrap to write the pages.

I used Start Bootstrap - Clean Blog template

For the finishing touch, I used clipboard.js to easily copy the generated URLs. I used jquery.qrcode.js to add a QR Code - you can easily scan it with your phone!


Conclusion

This blog and the examples were written to show how to create a URL shortener service.

Read more articles like this in the future by buying me a coffee!

Buy me a coffeeBuy me a coffee