A URL shortener in pure Rails, and the one line that decides who it redirects to
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
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.
Related quizzes
Two short ones on the machinery this article leans on:
- Two
rendercalls in one Rails action: why a redirect after a render raises, and what "and return" is actually doing. - When
upcase!returnsnilmid-chain: the bang convention that makesincrement!behave the way it does.
Comments
No comments yet. Be the first.