Wednesday, April 18, 2007

Rails & JavaScript tip: Disable submit button on all forms after submit

(UPDATE: SEE COMMENTS FOR CODE UPDATE)

I'm still testing this, but so far it works. Please let me know if you come across any bugs with it.

If you've ever watched non-tech savy people use a computer you've seen them double-click EVERYTHING. Including submit buttons on forms. Which leads to double-data entry.

Eew.

I didn't find anything built into Rails to deal with this (I could have missed it). I've coded a JavaScript solution that applies itself to every form in my web application, Pudding, without having to do anything to the forms. The exception to this are the Ajax forms. I'm not happy with my solution, but I've got it working. (If anyone sees a better way to do this, please let me know!)

For the code below to work you need to be using Ruby on Rails, the "submit_tag" function to generate all your submit buttons, and have the Prototype JavaScript library included in your pages. (You can adapt this to your own framework, but you've got to have a consistent name for your submit buttons. "submit_tag" gives every submit button the name, "commit". And I'm using Prototype's functions to make things easier for me.)


var FormHelper = {
disableSubmitButtonByEvent: function(event) {
var theForm = Event.element(event);
var submitButton = theForm.commit;
this.disable(submitButton);
},

disableSubmitButton: function(theForm) {
var submitButton = theForm.commit;
this.disable(submitButton);
},

disable: function(submitButton) {
submitButton.value = "Processing...";
$(submitButton).disable();
}
};


function addFormHelperListener() {
var formsInPage = document.forms;
for (var i=0; i<=formsInPage.length; i++) {
Event.observe(formsInPage[i],
'submit',
FormHelper.disableSubmitButtonByEvent.bindAsEventListener(FormHelper));
}
}

function addListeners(e) {
addFormHelperListener();
}

Event.observe(window, 'load', addListeners);



How does this all work?

Starting from the bottom of the code...

The Event.observe(window, 'load', addListeners); line forces your web browser to call the addListeners(e) function once the page is done loading. The addListeners(e) function is a great place to put all function calls that "setup" event listeners.

The addFormHelperListener() function is then called. It loops over all the forms on your page and ties the submission of the form to the disableSubmitButtonByEvent function up there in that FormHelper object. The disableSubmitButtonByEvent takes a look at the event that fired off (the submission of the form) and pulls out the DOM element that caused it - the form.

Since the Ruby on Rails function, "submit_tag", is used on all my forms they contain the HTML attribute, name="commit". Which allows me to grab the submit button out of the form object. Then it's easy! You just change the value of the submit button and then disable it.

Now the user can't double click the submit button!

As I mentioned earlier, the code works for all the forms on my web application except the Ajax ones. See that function, disableSubmitButton?

When you're using the "form_remote_tag" function you just add this option and you're good to go.



:before => "FormHelper.disableSubmitButton(this)"



A full example looks like this:



<% form_remote_tag(:update => [ID OF ELEMENT TO UPDATE],
:before => "FormHelper.disableSubmitButton(this)",
:url => {:controller => 'piece',
:action => 'new_comment'},
:complete => "[I'M CALLING SOME JAVASCRIPT TO SYNC UP SOME PAGE ELEMENTS.]") do -%>



I hate tacking on that ":before" option, but I couldn't figure out why it wasn't working. I'm guessing it's got something to do with the ":complete" option....

FUN FACT:
I had originally written this:



for (var i=0; i<=formsInPage.length; i++)



like this:



for (var currentForm in formsInPage)



But it didn't work in Safari.

Safari, you're on notice.

Labels: ,

Saturday, March 24, 2007

Capistrano, deploying from a local subversion repository

The recommended way of setting up deployment for a Ruby on Rails application is to use Capistrano. Unfortunately, by default your SVN repo must be accessible to the production server. Not good news if you've got your source code unaccessible to the outside world (behind a firewall, on your local laptop), like I do.

I did a little Googling for a solution to this problem and found this plugin. Thanks Wolfmans!

Labels: ,

Monday, March 12, 2007

Amazing Rails, Guest Post on mymicroisv.com

Bob Walsh, author of the book, Micro-ISV: From Vision to Reality, invited me to write a guest post on the mymicroisv.com blog regarding my choice of using Ruby on Rails for Pudding. It was published this morning!

Thanks for opportunity Bob!

Labels: ,

Monday, January 15, 2007

Rails Tip : Creating New Data (locked to the User)

(Update: Great post at the Rails Way site.)

This is a small continuation from yesterday's, "Access to Data", Rails Tip. (The biggest, "duh", tip ever. I promise, this one is a little less obvious!)

The code I used to illustrate constraining your find methods to the current user's account was this:

def show
@project = Project.find_by_id_and_account_id(params[:id], account_of_user().id)
end

You can do something similiar for creating new objects, by using associations.

def create
@project = account_of_user.projects.build(params[:project])
end

This binds the new project to the account of the user. ("account_of_user" being a reference to the account of the user you've authenticated.)

I can't believe I didn't know you could do this until I came across this post while researching Rails-related security.

Labels: ,

Sunday, January 14, 2007

Rails Tip : Access to Data

This wasn't immediately obvious to me when I started writing Ruby on Rails code. The solution(s) I was using prior to this dawning-on-me worked, but weren't as easy as what I've got illustrated below.

Once you're done reading this you'll probably say, "This is the biggest duh ever!" And to be honest, it really is.

But when you're first starting with Ruby on Rails, and you're seeing all these examples that say, "It's so easy! you just do this, SomeTotallyAwesomeClass.find(params[:id]), and it returns the model object!", it's easy to code in gigantic security holes into your controllers.

Let's say you've got the following:
-A web app that allows people to create their own account for the purpose of creating projects.
-Each project is associated with the account.
-Only users that are associated with the account that created a project can access the project.

For example, Bob creates an account named, "evilness", so he can manage his project, "Take Over The World". Bob adds his friend Bill to the account so they can both access it. They can reach their account via:

http://evilness.projectsarecool.com

The web application prompts them for their user/pass, once provided, they're in. They can view their taking-over-the-world progress when clicking on the url:

http://evilness.projectsarecool.com/project/show/23

Note: 23 is the ID of their project, "Take Over The World"

Now let's say that Bill (because he's totally evil), after logging into his account, types in this url:

http://evilness.projectsarecool.com/project/show/24

Note: 24 is the ID of the project, "Save the African Swallows". A project being managed by a Monty Python fan, Michael. Michael has nothing to do with the "evilness" account, and Bill and Bob have nothing to do with Michael's totally-awesome account, "holygraillovers".

Michael reaches his project via the url:

http://holygraillovers.projectsarecool.com/project/show/24

What does the web application do when Bill, the evil bastard, types in that 24?

Well, let's find out. Here are the objects in play: Account, which can have one or more User objects, and one or more Project objects.

A very lazy way to look up a project object in your controller is to do this:

def show
@project = Project.find(params[:id])
end

That takes the "ID" in the URL, looks up the Project, and returns it to the view.

A very lazy way that happens to protect Michael from Bill and Bob's evil eyes is the following:

def show
@project = Project.find_by_id_and_account_id(params[:id], account_of_user().id)
end

When a user logs into your application, the application should know who they are. I.e. The app knows it's Bill, Bob, or Michael. Since the app knows who is logged in, it should know which account they are associated with. Use that information (illustrated by the function call to account_of_user()) when you do "find" calls in the controller and you'll be better off.

Using this technique the project just won't be found. It will look up the Project ID, "24", but since Bill's "evilness" account doesnt' have the same ID as Michael, "holygraillovers" account, the "find" won't find it.

This is by no means the end-all-be-all of security for a Rails app. This is meant to illustrate one tiny little thing you can do to help yourself. The above example sure as heck won't help you when the model object you're looking up isn't directly associated with the account.

For more things to "watch out for", read this nice list.

(Update: Check out this great post on the Rails Way site.)

Labels: ,

Pudding Update, Working on Security

I'm gutting a lot of the security code in Pudding. It's to hard to work with, and it has more holes than swiss cheese. I'm finding that having 1 controller for every model object is a lot easier to secure. There is just a lot less each controller has to worry about. I'm not going full REST routing, but I'm running away from crap like this:

/client_management/client_company?client_company_id=1
/client_management/client?client_id=1

and enjoying these:

/client_company/1
/client/1

Labels: , ,

Sunday, December 03, 2006

Pudding with proper URL's

I've finally made the switch to the fancy "subdomain as account key" style of URL's for Pudding. Previously, when you signed up for an account the URL you would work from looked like this:

http://www.ataraxispudding.com/company/yourcompanyname

Kinda ghetto!

The URL will now look like this:

http://yourcompanyname.ataraxispudding.com

I just finished squashing the last of the bugs related to change. I need to make a few more tweaks to the way the application reacts to "www.ataraxispudding.com", but the internals of the app are sound.

A few things about the transition
  • Setting up the server for wildcard subdomains. I've seen the configuration addition for Apache, and it looks dead simple. (It's like 1 line of configuration.) Unfortunately (for this situation) I'm using lighttpd, and the setup turned out to be a little more complicated. Luckily, Graeme Mathieson, has a really good tutorial for setting up OSX with a local name server (Thanks Tyler!), which you then use to work the wildcard subdomain magic. The write up has one small portion that confused me (and caused me to spend a hour scratching my head), so I'm thinking of writing up a super slimmed down guide to getting everything setup.
  • After the server setup, grabbing the subdomain is really easy with Ruby on Rails. I've got this in my ApplicationController:

before_filter { |c| c.subdomain = c.request.subdomains.first }

def subdomain=(value)
@subdomain = value
end

def subdomain
@subdomain
end

I just do my lookup for the user's account with, "subdomain", and it works!
  • Tests are your friend. The controllers that had a descent amount of functional tests (unit tests for controllers) were easy to get working with the new URL scheme - those without were a BEAR! I ended up writing a few tests along the way, which is always a good thing. But the funny thing is, the last bug I fixed was solved by accidentally looking at the command line output of the web server while I was trying to use the app in a browser.
  • Routing and code simplier. Removing, "company/yourcompanyname", from the front of the URL has made my Ruby on Rails routing file a helluvalot simpler! I did a little bit of refactoring with this change, and my URL's in general are easier to work with.

Speaking of refactoring... There is a bunch of refactoring I'd love to do with the code base of this application. This is my first Ruby on Rails app!
Overall, I'm very very very happy I made this change. I think it gives Pudding a more professional feel. It only took 2 working sessions to get everything going (I'm not counting the first session where I read up on HowToUseSubdomainsAsAccountKeys).

Labels: , ,