LaunchKit
← All posts
Video · 6 min read · by Mehdi Farsi · 217 views

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

Watch on YouTube

Text version

You want a long link to go in and a short one to come out. In Rails that's one model, one controller and one route, and here's the entire feature before we explain any of it:

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.

The model, and the slug that writes itself

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. Running rails generate model ShortUrl slug:string target_url:string clicks_count:integer gets you a migration that 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, because a counter that starts at nil raises on its first increment, and the column default is the only version of "starts at zero" that holds for writers going around Active Record.

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 gets evaluated once, when the class is loaded, and every record in the process would then share one slug. The lambda's called per instance, at initialization, which is why this works:

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

It's alphanumeric rather than hex or uuid because the slug gets read aloud, typed from a phone and pasted into a chat window. And notice what the lambda buys you beyond that: the record's valid before it's saved. Nothing has to reach the database to find out whether the object is coherent, so you can test it without a transaction.

The two guards on that slug aren't redundant. uniqueness: true runs a SELECT and then an INSERT, and another request can slip a row in between them. The validation's the polite path that returns an error message; the unique index is the one that's actually true. A shortener with only the validation hands two people the same link eventually.

One route, one action

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 the only reason the controller can 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 somebody has to type or say out loud.

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

We use 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 you'd hit two lines later.

increment! is doing more than it looks. It issues an atomic UPDATE short_urls SET clicks_count = clicks_count + 1, so two simultaneous clicks can't both read 4 and both write 5. It also leaves updated_at alone unless you pass touch: true, which is exactly what you want from a counter.

One trap before you copy that line into a bigger model: on a class with optimistic locking, increment! bumps lock_version, and every other request already holding that record then fails with StaleObjectError. A counter isn't a content change, so it shouldn't invalidate anyone's copy of the row. Reach for update_all on those models.

allow_other_host, and the open redirect it exists to stop

Since Rails 7, redirect_to refuses an external host unless you say otherwise, and that default isn't paranoia. Put the two versions side by side and they're almost the same line:

# Safe: the target came out of a row we wrote.
redirect_to @short_url.target_url, allow_other_host: true

# Never do this: the target came out of the request.
redirect_to params[:url], allow_other_host: true

Two lanes ending in the same redirect_to call. In the safe lane, GET /u/kR3xQ goes through find_by! to a short_urls row, and the row's target_url is what gets redirected to. In the open redirect lane, GET with a url parameter goes straight from params to redirect_to, tagged no row and no database. Caption: same flag, what changes is whether the value ever passed through a row you wrote.

With the second one, 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 you, and it still ships weekly because the fix reads like an inconvenience.

The first one's 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 isn't a switch you flip to make an error go away. It's an assertion that you know where the URL came from, and it's only true if you actually do.

So if anyone with an account can create a shortener, that assertion stops holding and the check moves up a level. Validate that the scheme is http or https at write time, and reject anything else long before it reaches a redirect.

What the shortener doesn't do yet

Collisions get likely faster than you think. Five alphanumeric characters is about 916 million slugs, but the birthday bound puts a 1% collision chance 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.

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 problem every view counter has, with the same answer: a user-agent test and a prefetch header check, applied everywhere you count.

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

Two 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.