A Simple Link Shortener In Rails

One of the neat things that Dealush allows it’s users to do is to automatically tweet sales when they are listed. A key part of the tweet is a link back to the sale listing on Dealush. If your have heard of twitter you probably know that one of the distinctive features of twitter is that each message can be no more than 140 chars. Now a typical sale listing on Dealush has a URL that looks like this:

http://dealush.com/shopping-sales/zy2y/melbourne-sale-79-make-up-at-gorgeous-melbourne-central-211-la-trobe-street-melbourne-vic-3000

Not exactly short now is it? It’s actually 133 chars long! That won’t leave much room in the tweet for a decent description of the sale. The common solution to this problem is to use a link shortening service like Bit.ly or Is.gd, but I wanted people to see “Dealush.com” in the tweet so I decided to roll my own link shortener.

The majority of the solution consists of two parts:

  1. a model for storing the details of the shortened link (including the user the shortened link belongs to and counter that increments as the link is clicked);
  2. a controller to accept incoming requests, grab the shortened link data out of the database and redirecting the visitors request to the target URL;

Some niceities of the solution:

  • The controller does a 301 redirect, which is the recommended type of redirect for maintaining maximum google juice to the original URL;
  • A unique code of is generated for each shortened link, instead of using the id of the shortened link record. This means that we can get more unique combinations than if we just used numbers;
  • The link records a count of how many times it has been “un-shortened”;
  • The link can be linked to a user, this allows for stats of the link usage for a particular user and other interesting things;
  • The controller spawns a new thread to record information to the database, allowing the redirect to happen as quickly as possible;

Some suggested improvements:

  • There has not been an attempt to remove ambiguous characters (i.e. 1 l and capital i, or 0 and O etc.) from the unique key generated for the link. This means people might copy the link incorrectly if copying the link by hand;
  • The shortened links are found with a case-insensitive search on the unique key. This means that the system can’t take advantage of upper and lower case  to increase the number of unique combinations. This may have an effect for people copying the link by hand;
  • The system could pre-generate unique keys in advance, avoiding the database penalty when checking that a newly generated key is unique;
  • The system could store the shortened URL if the url is to be continually rendered;
  • Some implementations might want duplicate links to be generated each time a user requests it.

Anyway, I am pretty happy with solution as is. Dealush doesn’t need a super scalable link shortener as our needs are modest when compared to dedicated link shorteners. Also this solution can be extended as I need and best yet, it was FUN!

The code is below, and is released under a MIT licence.

The model (shortened_link.rb):

class ShortenedLink < ActiveRecord::Base
  # this won't please the MVC pedants, but it makes sense
  # to access the url writer from the model since the
  # model is dealing with the generation of a url
  include ActionController::UrlWriter

  belongs_to :user # allows the shortened link to be associated with a user

  # this is best placed in your env files so you can have localhost for dev
  HOST_NAME = 'dealush.com'
  UNIQUE_KEY_LENGTH = 5
  # it can be useful to easily know how long the shortened link will be
  # the start of the link for dealush is http://dealush.com/s/ (21 chars)
  # change the number as needed for your site
  LENGTH = 21 + UNIQUE_KEY_LENGTH 

  # generate a shortened link from a url
  # link to a user if one specified
  def self.generate(orig_url, user=nil)
    # don't want to generate the link if it has already been generated
    # so check the datastore
    uid = user.nil? ? nil : user.id
    sl = ShortenedLink.find_by_url_and_user_id(orig_url, uid)
    return sl.shortened_url if sl

    # generate a unique key for the link
    begin
      # has about 50 million possible combos
      unique_key = self.generate_random_string(UNIQUE_KEY_LENGTH)
    end while ShortenedLink.find_by_unique_key unique_key

    # create the shortened link, storing it
    sl = ShortenedLink.create(:url => orig_url, :unique_key => unique_key, :user => user)

    # return the url
    return sl.shortened_url
  end

  # create the shortened url from the unique key
  def shortened_url
    # use the url writer to generate the url
    return link_translate_url(:unique_key => self.unique_key, :host => HOST_NAME)
  end

  # generate a random string
  def self.generate_random_string(size = 6)
    # not doing uppercase as url is case insensitive
    charset = ('a'..'z').to_a + (0..9).to_a
    (0...size).map{ charset.to_a[rand(charset.size)] }.join
  end

end

The controller (shortened_links_controller.rb):

class ShortenedLinksController < ApplicationController

  # find the real link for the shortened link key and redirect
  def translate
    sl = ShortenedLink.find_by_unique_key(params[:unique_key])

    if sl
      # don't want to wait for the increment to happen, make it snappy!
      Thread.new do
        # this is the place to enhance the metrics captured
        # for the system. You could log the request origin
        # browser type, ip address etc.
        sl.increment!(:use_count)
      end
      # do a 301 redirect to the destination url
      head :moved_permanently, :location => sl.url
    else
      # if we don't find the shortened link, redirect to the root
      head :moved_permanently, :location => root_url
    end
  end

end

The migration (XXXXXX_create_shortened_links.rb):

class CreateShortenedLinks < ActiveRecord::Migration
  def self.up
    create_table :shortened_links do |t|

      t.integer :user_id # we can link this to a user for interesting things
      t.string :url, :null => false # the url this shortened link points to
      t.string :unique_key, :null => false # a unique identifier for the link
      t.integer :use_count, :null => false, :default => 0 # how many times a link has been accessed

      t.timestamps
    end

    add_index :shortened_links, :unique_key
    add_index :shortened_links, :user_id
  end

  def self.down
    remove_index :shortened_links, :unique_key
    remove_index :shortened_links, :user_id
    drop_table :shortened_links
  end
end

routes.rb

map.link_translate "/s/:unique_key", :controller => "shortened_links", :action => "translate"