In this walkthrough, I go through the available options and an example using attachment_fu to handle file uploads and image thumbnailing, and responds_to_parent to implement the iframe remoting pattern to work around javascript’s security restrictions on file system access.
You can also download the complete example.
Sure, you can write one yourself (or bake the code directly into your app), but unless you have specific requirements you should take a look at what’s available. Even if you do have a good excuse, you can learn from the existing plugins or extend them. The three that I’ve used over the past two years are:
Recommendation: attachment_fu if you are using Rails 1.2+, otherwise acts_as_attachment.
attachment_fu supports three processors out of the box:
Recommendation: image_science if you only need image resizing and can handle the slightly inferior thumbnail quality, minimagick otherwise.
The installation process is quite long for the image processors, so I’ve just linked to them here:
sudo gem install mini_magick
script/plugin install http://svn.techno-weenie.net/projects/plugins/attachment_fu/
I’ll use a restful model for our file uploads since it’s all the rage (here’s a good introduction). You can create a restful scaffold using the following command:
ruby script/generate scaffold_resource asset filename:string content_type:string size:integer width:integer height:integer parent_id:integer thumbnail:string created_at:datetime
This will create the controllers, models, views and a migration. I’ve included support for saving image properties (width and height attributes) and thumbnailing (parent_id and thumbnail attributes).
Here is the resulting migration if you want to do it manually:
class CreateAssets < ActiveRecord::Migration
def self.up
create_table :assets do |t|
t.column :filename, :string
t.column :content_type, :string
t.column :size, :integer
t.column :width, :integer
t.column :height, :integer
t.column :parent_id, :integer
t.column :thumbnail, :string
t.column :created_at, :datetime
end
end def self.down
drop_table :assets
end
end
In the model, it’s really a one liner to add file upload features.
class Asset < ActiveRecord::Base has_attachment :storage => :file_system,
:max_size => 1.megabytes,
:thumbnails => { :thumb => '80x80>', :tiny => '40x40>' },
:processor => :MiniMagick # attachment_fu looks in this order: ImageScience, Rmagick, MiniMagick validates_as_attachment # ok two lines if you want to do validation, and why wouldn't you?
end
The has_attachment (or acts_as_attachment method for those not using attachment_fu) adds a lot of useful methods such as image? to determine if the file is an image, and public_filename(thumbnail=nil) to retrieve the filename for the original or thumbnail. I usually add methods to determine other file types such as movies, music, and documents.
The options available are:
content_type – Allowed content types. Allows all by default. Use :image to allow all standard image types.min_size – Minimum size allowed. 1 byte is the default.max_size – Maximum size allowed. 1.megabyte is the default.size – Range of sizes allowed. (1..1.megabyte) is the default. This overrides the :min_size and :max_size options.resize_to – Used by RMagick to resize images. Pass either an array of width/height, or a geometry string.thumbnails – Specifies a set of thumbnails to generate. This accepts a hash of filename suffixes and RMagick resizing options.thumbnail_class – Set what class to use for thumbnails. This attachment class is used by default.path_prefix – path to store the uploaded files. Uses public/#{table_name} by default for the filesystem, and just #{table_name} for the S3 backend. Setting this sets the :storage to :file_system.storage – Use :file_system to specify the attachment data is stored with the file system. Defaults to :db_system.In the above we’re storing the files in the file system and are adding two thumbnails if it’s an image: one called ‘thumb’ no bigger than 80×80 pixels, and the other called ‘tiny’. By default, these will be stored in the same directory as the original: /public/assets/nnnn/mmmm/ with their thumbnail name as a suffix. To show them in the view, we just do the following: <%= image_tag(image.public_filename(:thumb)) %>
validates_as_attachment ensures that size, content_type and filename are present and checks against the options given to has_attachment; in our case the original should be no larger than 1 megabyte.
To enable multipart file uploads, we need to set multipart => true as a form option in new.rhtml. The uploaded_data file input field is used by attachment_fu to store the file contents in an attribute so that attachment_fu can do its magic when the uploaded_data= method is called.
<%= error_messages_for :asset %>
<% form_for(:asset, :url => assets_path, :html => { :multipart => true }) do |form| %>
<p>
<label for="uploaded_data">Upload a file:</label>
<%= form.file_field :uploaded_data %>
</p>
<p>
<%= submit_tag "Create" %>
</p>
<% end %>
We’ll also pretty up the index code. We want to show a thumbnail if the file is an image, otherwise just the name:
<h1>Listing assets</h1> <ul id="assets"> <% @assets.each do |asset| %> <li id="asset_<%= asset.id %>"> <% if asset.image? %> <%= link_to(image_tag(asset.public_filename(:thumb))) %><br /> <% end %> <%= link_to(asset.filename, asset_path(asset)) %> (<%= link_to "Delete", asset_path(asset), :method => :delete, :confirm => "are you sure?"%>) </li> <% end %> </ul><br /><%= link_to 'New asset', new_asset_path %>
Don’t forget to do a rake db:migrate to add the assets table. At this stage you can start your server and go to http://localhost:3000/assets/new to add a new file. After being redirected back to the index page you’ll notice that thumbnails are showing in our index with the originals. To get rid of this, we can modify assets_controller to only display originals by checking if the parent_id attribute is nil. attachment_fu also allows you to store thumbnails into a different model, which would make this step unnecessary.
def index
@assets = Asset.find(:all, :conditions => {:parent_id => nil}, :order => 'created_at DESC')
respond_to do |format|
format.html # index.rhtml
format.xml { render :xml => @assets.to_xml }
end
end
Let’s try and AJAX our file uploads. The current user flow is:
What we want to happen is to have all that occur on the index page, with no page refreshes. Normally you would do the following:
Add the Javascript prototype/scriptaculous libraries into your layout.
<%= javascript_include_tag :defaults %>
Change the form_for tag to a remote_form_for
<% remote_form_for(:asset, :url => assets_path, :html => { :multipart => true }) do |f| %>
Add format.js to the create action in the controller to handle AJAX requests:
def create
@asset = Asset.new(params[:asset])
respond_to do |format|
if @asset.save
flash[:notice] = 'Asset was successfully created.'
format.html { redirect_to asset_url(@asset) }
format.xml { head :created, :location => asset_url(@asset) }
format.js
else
format.html { render :action => "new" }
format.xml { render :xml => @asset.errors.to_xml }
format.js
end
end
end
Make a create.rjs file to insert the asset at the bottom of your list:
page.insert_html :bottom, "assets", :partial => 'assets/list_item', :object => @asset
page.visual_effect :highlight, "asset_#{@asset.id}"
Create a partial to show the image in the list
<li id="asset_<%= list_item.id %>">
<% if list_item.image? %>
<%= link_to(image_tag(list_item.public_filename(:thumb))) %><br />
<% end %>
<%= link_to(list_item.filename, asset_path(list_item))%> (<%= link_to_remote("Delete", {:url => asset_path(list_item), :method => :delete, :confirm => "are you sure?"}) %>)
</li>
Add AJAX deletion (optional)
If you’ve noticed the changes in the previous code, I’ve added AJAX deletion of files as well. To enable this on the server we add a destroy.rjs file to remove the deleted file form the list.
page.remove "asset_#{@asset.id}"
In the controller you also need to add format.js to the delete action.
Keep our form views DRY (optional)
We should also make the file upload form contents into a partial and use it in new.rhtml as well as index.rhtml.
_form.rhtml
<p>
<label for="uploaded_data">Upload a file:</label>
<%= form.file_field :uploaded_data %>
</p>
<p>
<%= submit_tag "Create" %>
</p>
new.rhtml
<% form_for(:asset, :url => assets_path, :html => { :multipart => true }) do |form| %>
<%= render(:partial => '/assets/form', :object => form)%>
<% end %>
Add the form to index.rhtml
<% remote_form_for(:asset, :url => assets_path, :html => { :multipart => true }) do |form| %>
<%= render(:partial => '/assets/form', :object => form) %>
<% end %>
Now that we have all our code in place, go back to the index page where you should be able to upload a new file using AJAX.
Unfortunately there is one problem. A security restriction with javascript prevents access to the filesystem. If you used validations for your asset model you would have gotten an error complaining about missing attributes. This is because only the filename is sent to the server, not the file itself. How can we solve this issue?
To get around the AJAX/file upload problem we make use of the iframe remoting pattern. We need a hidden iframe and target our form’s action to that iframe. First, we change the index.rhtml to use a form_for tag. To get rails to process our action like an AJAX request we simply add a ”.js” extension to the form’s action. We then set the iframe to a 1×1 sized pixel so it doesn’t get shown. Don’t use display:none or your iframe will be hidden from your form and depending on your browser you will end up opening a new window, load the response in the main window, or download the server response.
<% form_for(:asset, :url =>formatted_assets_path(:format => 'js'), :html => { :multipart => true, :target => 'upload_frame'}) do |form| %>
<%= render(:partial => '/assets/form', :object => form) %>
<% end %>
<iframe id='upload_frame' name="upload_frame" style="width:1px;height:1px;border:0px" src="about:blank"></iframe>
To handle the form on the server, we can use Sean Treadway’s responds_to_parent plugin.
script/plugin install http://responds-to-parent.googlecode.com/svn/trunk/
This plugin makes it dead simple to send javascript back to the parent window, not the iframe itself. Add the following to your create action:
def create
@asset = Asset.new(params[:asset])
respond_to do |format|
if @asset.save
flash[:notice] = 'Asset was successfully created.'
format.html { redirect_to asset_url(@asset) }
format.xml { head :created, :location => asset_url(@asset) }
format.js do
responds_to_parent do
render :update do |page|
page.insert_html :bottom, "assets", :partial => 'assets/list_item', :object => @asset
page.visual_effect :highlight, "asset_#{@asset.id}"
end
end
end
else
format.html { render :action => "new" }
format.xml { render :xml => @asset.errors.to_xml }
format.js do
responds_to_parent do
render :update do |page|
# update the page with an error message
end
end
end
end
end
end
At this point you no longer need the create.rjs file.
NOW you should be able to get your index page and upload a file the AJAX way!
There are some more changes you need to make it production ready:
Just add the following action to your assets controller; don’t forget to add the route to your routes.rb file.
def download
@asset = Asset.find(params[:id])
send_file("#{RAILS_ROOT}/public"+@asset.public_filename,
:disposition => 'attachment',
:encoding => 'utf8',
:type => @asset.content_type,
:filename => URI.encode(@asset.filename))
end
Update: 2007/05/23 Thanks to Geoff Buesing for pointing out that we can use formatted_routes.
Update: 2007/05/26 Updated a bug in the initial index.html example (thanks Benedikt!) and added a download link to the final example (see the first paragraph).