March 27, 2011

ajax post, sign in, post, redirect, get

While working on socialite, we ran against the following problem.
The users can vote up submissions by clicking the up arrow next to the submissions:

The way this usually works is through an ajax request that posts the vote to the server. The Rails view that handles that reads like so:

 1 - if current_user.nil? or current_user.can_vote_for(submission)
 2   = link_to vote_up_path(:id => submission.id), :method => :post, :remote => true, :class => "vote-up", :id => "voting-booth-#{submission.id}", :title => 'vote up' do
 3     != icon 'circle-arrow-n'
 4 
 5 %span
 6   %span{:class => 'score-bracket'}= "["
 7   %span{:class => 'score', :id => "score-for-submission-#{submission.id}"}>= submission.score.to_i
 8   %span{:class => 'score-bracket'}= "]"
 9 
10 :javascript
11   $(document).ready(function(){
12     $('#voting-booth-#{submission.id}').bind('ajax:success', function(obj, data){
13       data = eval("(" + data + ")");
14       $('#score-for-submission-' + data['id']).text(data['score']);
15       $('#voting-booth-' + data['id']).addClass('invisible');
16     });
17   });

Everything works fine with this setup, except when the user is not signed in. In that case, we could hide the up arrow but that's not very user-friendly. We really want to show things like that so the users understand what they are allowed or expected to do.

Usually, when a user that is not registered clicks a link that would require him to be registered; the server will remember that link, show a sign in page, and then redirect to that link. We cannot do that in our case because the link is a post. Further, it's an ajax post so the rendered content makes no sense outside of its usual page.

So the solution we came up with is to remember the current page as the user clicks the arrow and also to remember the post request -- both pieces of information are stored in the session. Then, after the user signs in or signs up, we artificially execute the post before redirecting to the original page -- the one where they triggered the post request.

The first thing we have to do is remember the post request when the user is not signed in. We do that by adding the following to our application controller:

 1 class ApplicationController < ActionController::Base
 2 
 3   # this before filter will save an attempted post request for later execution after the user is authenticated
 4   def save_post_before_authenticating
 5     if request.env['warden'].unauthenticated? and request.post?
 6       logger.info "Will save the POST request for later execution"
 7       session[:pre_sign_in_post] = {:controller => controller_name, :action => action_name, :params => params.dup}
 8     end
 9 
10     authenticate_user!
11   end
12 
13 end

Here we are using warden, the underlying library behind the devise gem.

The save_post_before_authenticating method is added as a before filter to the vote_up action:

  before_filter :save_post_before_authenticating, :only => [:vote_up]

Next, we have to handle the 401 response sent back from the server when a vote takes place for a user that is not signed in. The javascript code reads like that:

  $(document).ready(function(){
    $(document.body).bind('ajax:error', function(status, xhr, err){
      if(xhr.status == 401){
        window.location = '/sign_in_then_redirect?current_url=' + escape(window.location)
      }
    });
  });

The URL we are now reaching out to (sign_in_then_redirect) will remember the current page and redirect to the sign in page:

 1 class ApplicationController < ActionController::Base
 2 
 3   def sign_in_then_redirect
 4     flash[:alert] = "Please sign in first."
 5     session[:last_get_url] = params[:current_url]
 6     redirect_to new_session_url(:user)
 7   end
 8 
 9 end

At this point, the user will sign in and we will need to execute the saved post request and redirect them to their previous location. We have all the information we need but artificially executing the post request is tricky. We need to hack together a few things. First off, the after_sign_in_path_for is a method from the devise gem that is executed when trying to figure out where to redirect the users after they sign in. So we hook into that method and do some request processing. We instantiate the controller that would have been created by the post request and we call its action method. Before we call the action method though, we have to make sure that params and request actually point to the old post params and request.

As a bonus, we replace the flash notice with whatever is in flash[:pre_sign_in_notice] so the vote up action can reassure the user that their vote was taken into account.

The code for these two methods is as follows:

 1 class ApplicationController < ActionController::Base
 2 
 3   def after_sign_in_path_for resource_or_scope
 4     last_get_url = session[:last_get_url]
 5     if last_get_url.nil?
 6       return super
 7     end
 8 
 9     # if a post request was saved in the session then we execute it -- see #save_post_before_authenticating
10     execute_saved_post_request if session[:pre_sign_in_post]
11 
12     last_get_url
13   end
14 
15   def execute_saved_post_request
16     logger.info "Executing the saved POST request before redirecting"
17 
18     begin
19       class_name = session[:pre_sign_in_post][:controller].capitalize + "Controller"
20       controller_instance = class_name.constantize.new
21 
22       current_request = request
23       saved_params = session[:pre_sign_in_post][:params]
24       metaclass = (class << controller_instance; self; end)
25 
26       metaclass.send(:define_method, :request) { return current_request }
27       metaclass.send(:define_method, :params) { return saved_params }
28 
29       controller_instance.send(session[:pre_sign_in_post][:action])
30       flash[:notice] = flash[:pre_sign_in_notice] if flash[:pre_sign_in_notice]
31     rescue
32       logger.error "An error occurred trying to execute the saved POST request: #{$!}"
33       flash[:alert] = $!.message
34     ensure
35       session[:pre_sign_in_post] = nil
36     end
37   end
38 end

The above solution is far from generic but it works well for socialite. Among other things, if the action method from the saved post request calls out to render, this solution will not work. At the very least, the render call would have to be stubbed.

Feel free to have a better look at the code through the github page. And let me know if you have suggestions to improve this solution.

March 5, 2011

socialite

We just started working on a CMS for social news websites. I will post more about it later but here's the link to the github project: Socialite.