LaunchKit
← All posts
Video · 7 min read · by Mehdi Farsi · 41 views

A URL shortener in pure Rails, and the one line that decides who it redirects to

Watch on YouTube

Text version

The whole feature is a link that goes in and a link that comes out:

ShortUrl.create(target_url: "https://rubycademy.com", note: "RubyCademy")
# => #<ShortUrl slug: "kR3xQ", target_url: "https://rubycademy.com", clicks_count: 0>

Visit /u/kR3xQ and you land on the target, with the counter one higher than it was. No gem, no service object, no background job. What follows is every decision that fits between those two lines, in the order the video makes them.

The ShortUrl model, and its three columns

Three columns, and each one earns its place. The slug is the identity, the target is where we send people, and the counter is the only thing anyone ever asks a shortener afterwards.

rails generate model ShortUrl slug:string target_url:string clicks_count:integer

The generated migration needs one edit before it runs:

create_table :short_urls do |t|
  t.string  :slug, null: false
  t.string  :target_url, null: false
  t.integer :clicks_count, default: 0, null: false

  t.timestamps
end

add_index :short_urls, :slug, unique: true

The default belongs in the database rather than in the model. A counter that starts at nil because a row was inserted by a migration, a console script or a fixture is a counter that raises on its first increment, and NOT NULL DEFAULT 0 is the only version of "starts at zero" that holds for every writer, not just the ones going through Active Record.

Slugs that generate themselves

The interesting line is the slug. It has to exist before validation runs, or a brand-new record fails its own uniqueness check for the wrong reason:

class ShortUrl < ApplicationRecord
  DEFAULT_SLUG_SIZE = 5

  attribute :slug, :string, default: -> { SecureRandom.alphanumeric(DEFAULT_SLUG_SIZE) }

  validates :slug, presence: true, uniqueness: true
end

attribute takes a lambda rather than a value, and that distinction is the whole thing. A plain default is evaluated once, when the class is loaded, and every record in the process would share one slug. The lambda is called per instance, at initialization, which is why this works:

ShortUrl.new.slug   # => "7bQxK"
ShortUrl.new.slug   # => "mE2pV"

Note what that buys: the record is valid before it is saved. Nothing has to reach the database to find out whether the object is coherent, which is what makes it testable without a transaction.

SecureRandom.alphanumeric rather than hex or uuid, because the slug is going to be read aloud, typed from a phone and pasted into a chat window. Five alphanumeric characters is about 916 million combinations, which is comfortable until it very suddenly is not - see the end of this article.

Why the validation and the index are both required

The uniqueness: true validation runs a SELECT and then an INSERT. Between those two statements another request can insert the same slug, and one of the two writes wins silently. The validation is the polite path that gives a user an error message; the unique index is the one that is actually true. Neither replaces the other, and a shortener with only the validation will hand two people the same link eventually.

The redirect action, and why find_by!

class ShortUrlsController < ApplicationController
  before_action :set_short_url

  def show
    @short_url.increment!(:clicks_count)

    redirect_to @short_url.target_url, allow_other_host: true
  end

  private

  def set_short_url
    @short_url = ShortUrl.find_by!(slug: params[:slug])
  end
end

find_by! rather than find_by, so an unknown slug raises RecordNotFound and Rails renders the 404 it already has, instead of the NoMethodError on nil that find_by would produce two lines later.

Counting a click without restamping the row

increment! is doing more than it looks. It issues an atomic UPDATE short_urls SET clicks_count = clicks_count + 1, so two simultaneous clicks cannot both read 4 and both write 5. And by default it does not touch updated_at: the column moves only if you pass touch: true. That matters more than it sounds, because updated_at on a row like this is the honest answer to "when did the content last change", and a view counter that restamps it turns every visit into a content change - which is exactly the sort of thing that ends up in a sitemap's lastmod and quietly asks Google to re-crawl a page that did not move.

There is one case where increment! is the wrong tool, and it is worth knowing before you copy this into a bigger model: on a class with optimistic locking, incrementing through Active Record bumps lock_version, and every other request holding that record then fails with a StaleObjectError. A counter on such a model wants a raw update_all instead. This one has no lock_version, so increment! is the simple, correct choice.

allow_other_host, and the open redirect it exists to stop

This is the line the whole article is really about:

redirect_to @short_url.target_url, allow_other_host: true

Since Rails 7, redirect_to refuses an external host unless you say otherwise. That default is not paranoia. Drop the flag into the wrong place and you have built an open redirect:

# Never do this.
redirect_to params[:url], allow_other_host: true

Now https://yoursite.com/u?url=https://phishing.example is a link that wears your domain, your TLS certificate and your reputation, and lands the visitor somewhere else. It sails through the mail filters that trust your domain. It is one of the oldest entries in the OWASP list and it is still shipped weekly, because the fix reads like an inconvenience.

The version above is safe for one reason only: the target comes from a row you control, written by a code path that authenticates and validates, not from the request. allow_other_host is not a switch you flip to make an error go away. It is an assertion that you know where the URL came from, and it is only true if you actually do.

If shorteners can be created by anyone with an account, that assertion stops holding and the validation moves up a level: check the scheme is http or https at write time, and reject anything else before it ever reaches a redirect.

One route, and what param: :slug changes

resources :short_urls, only: :show, param: :slug, path: "u"

Three options doing three jobs. only: :show because six routes for a feature with one action is six routes to read. param: :slug swaps :id for :slug in the path and in params, which is what lets the controller read params[:slug] instead of the misleading params[:id]. And path: "u" gets the URL down to /u/kR3xQ, because every character in a shortener is one the user has to type or read aloud.

Confirm what you actually got rather than assuming:

bin/rails routes -g short_urls
# short_url GET /u/:slug(.:format) short_urls#show

What the shortener does not do yet

Three things, named rather than left as a surprise.

Collisions get likely faster than you think. Five characters is 916 million slugs, but the birthday bound means a 1% collision chance arrives at roughly 135,000 rows, not at 9 million. The unique index turns each collision into a RecordNotUnique rather than a duplicate, so the fix is a retry loop or a longer slug, and the moment to add it is before it happens.

The counter counts requests, not readers. A refresh, a preview bot, a Slack unfurl and a mail scanner each add one. Filtering those is the same problem every view counter has, and the same answer: a user-agent test and a prefetch header check, applied consistently everywhere you count.

Nothing expires. A shortener with no expiry is a permanent redirect to a URL you no longer control once the target domain lapses. A expires_at column and a scope is twenty minutes of work and the difference between a link and a liability.

Three short ones on the machinery this article leans on:

#rails #video #security

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.