Sunday, August 22, 2010

RoR: Hide real id in url and html code

There are many ways to hide real ids. I'll show you one of them.

As probably most of us know we can overload to_param method for class, to show pretty well formatted slug in url, ex. 1-my_first_article
With at least this knowledge we can try to build some sort of "FunkyId" solution :)

First, create an initializer file and named it for example funky_id.rb (we can move everything from here to a plugin later) and add these lines:
module ActiveRecord
  class Base
    def to_param
      id.pak
    end
  end
end
This will overwrite to_param in all classes, which will inherit from ActiveRecord::Base.
to_param takes our object id and pack it somehow (described later)

But this will work only one way - all ids will be packed before showing them in url and html code.

We need also reversed process to translate OurCrazyID into ID, so our controller can use it propertly.
module ActionController
  class Base
    before_filter :unpak_id

    def unpak_id hash = params
      hash.keys.each do |key|
        if ( key =~ /_id$/ || key == 'id' )
          id = hash[key]
          params[key] = id.unpak.to_s if id && id.class == String && id =~ /.+\-FID$/
        end
      end
    end
  end
end
This part will run before each controller action, trying to unpack all ids found in params.

Now, what are these magic pak/unpak functions?
This is just our packing/unpacking implementation.
For example:
class Fixnum
  def pak
    tmp = self.to_s
    tmp = tmp[-1..-1] + tmp.reverse
    tmp = Base64.encode64(tmp)
    tmp = tmp.strip + "-FID"
    tmp
  end
end

class String
  def unpak
    tmp = self[0..-5]
    tmp = Base64.decode64(tmp)
    tmp = tmp[1..-1].reverse
    tmp.to_i
  end
end
So, when we "pak" some integer id, as a result we'll get string id.
When we "unpak" some string id, we'll get integer one.
Of course, we can implement this in many diffent ways, to use only integer id for input/output and so on.

IMPORTANT
We must remember to use object instead of id in paths/params:
contact_path(@contact) instead of contact_path(@contact.id),
users_path(:role_id => @role) instead of users_path(:role_id => @role.id)

I we really, really want to use .id, additionally we need to hack a little bit ActionView::Helpers::UrlHelper::url_for function.
# Not a good idea to hack this, but if you want it, you got it :)
module ActionView
  module Helpers #:nodoc:
    module UrlHelper
      include JavaScriptHelper

      def url_for(options = {})
        options ||= {}
        url = case options
        when String
          escape = true
          options
        when Hash
          options = { :only_path => options[:host].nil? }.update(options.symbolize_keys)
          options = reparse_id(options)
          escape  = options.key?(:escape) ? options.delete(:escape) : true
          @controller.send(:url_for, options)
        when :back
          escape = false
          @controller.request.env["HTTP_REFERER"] || 'javascript:history.back()'
        else
          escape = false
          polymorphic_path(options)
        end

        escape ? escape_once(url) : url
      end

      private

      def reparse_id hash
        hash.keys.each do |key|
          if ( key.to_s =~ /_id$/ || key == :id )
            id = hash[key]
            if id && id.class != String
              hash[key] = id.pak if id.class == Fixnum
              hash[key] = id.id.pak if id.is_a?(ActiveRecord::Base)
            end
          end
        end
        hash
      end

    end
  end
end

That's all and as I've said in the beginning,
there are many ways to do this.
Sorry for my English :)