I wanted a project to allow attachments to be added to tasks. My requirements were that: users should be able to add multiple attachments to a task up to some maximum limit; there should be validation on the size of the attachments (combined with the number limit this should ensure I don’t run out of disk space too fast); and, that there should be some security on downloading a task’s attachments. Paperclip seems to be the popular Rails attachment at the moment and is still under active development, so I hoped to use that. However, it does not handle multiple attachments for a model. There is a plugin PaperclipPolymorph which adds multiple attachments to Paperclip, but I just couldn’t get it to meet my validation and security requirements. In the end I wrote my own solution and this article details it.
Update: This tutorial was completed using Rails 2.1.0, since then there have been a few changes, see the first comment (by Flyc) below. If you are using Rails 2.3+ you will at a minimum need to rename app/controller/appilication.rb to apps/controller/application_controller.rb
Firstly, if you don’t need multiple attachments, take a look at the Paperclip documentation and this webfellas article and you should be fine. Also, if you just need multiple attachments without any of the extra stuff, then PaperclipPolymorph will probably meet your needs. Lastly, I found this article by Brian Getting detailing how to handle multiple attachments with Attachment Fu (a Paperclip alternative). My tutorial closely follows his as I used it as a base for my investigations.
To demonstrate how I met my requirements I will use the example of creating a simple Rails app with a single model class, task, and allow that class to handle multiple attachments. You can download this project here.
Step 1 : Create the project
Here I just create a very simple Rails project called attachments
with
a single model class Task
which contains only title and body
attributes. Then I install Paperclip using Git. As I don’t need any of
the image based functionality in Paperclip I haven’t installed any of
its associated images packages, see the documentation if you think you
might need them.
rails -d attachments
ruby script/generate scaffold Task title:string body:text
ruby script/plugin install git://github.com/thoughtbot/paperclip.git
Step 2: Add the attachments model
Below is the migration I used to create a table for storing the
associations between model objects and their attachments. I have made it
polymorphic so it can be used with other model classes. It is called
Assets
as if it is called Attachments
there is an error in some
versions of Rails. Use
class Assets < ActiveRecord::Migration
def self.up
create_table :assets do |t|
t.string :data_file_name
t.string :data_content_type
t.integer :data_file_size
t.integer :attachable_id
t.string :attachable_type
t.timestamps
end
add_index :assets, [:attachable_id, :attachable_type]
end
def self.down
drop_table :assets
end
end
Then create the attachments model class in asset.rb
. This class has a
few handy shortcut methods to get access to attachment information and a
polymorphic association to the actual attachment data
.
class Asset < ActiveRecord::Base
has_attached_file :data,
belongs_to :attachable, :polymorphic => true
def url(*args)
data.url(*args)
end
def name
data_file_name
end
def content_type
data_content_type
end
def file_size
data_file_size
end
end
To have a standard multiple attachments model like is seen in Brian
Getting
article and PaperclipPolymorph we just need to add an association to the
Task
model. In task.rb
add the line:
has_many :assets, :as => :attachable, :dependent => :destroy
.
Step 3: Add Validations
To ensure that there are never more than 5 attachments on a Task
and
that each attachment is less than a megabyte, add the following lines to
task.rb
. Putting validations on the Task
model allows the Asset
model to be reused by other classes if they need multiple attachments.
These limits can obviously be easily modified by changing the constants.
You will see these constants used throughout the rest of the project, so
that the control and display of validations is in a single place.
validate :validate_attachments
Max_Attachments = 5
Max_Attachment_Size = 1.megabyte
def validate_attachments
errors.add_to_base("Too many attachments - maximum is #{Max_Attachments}") if assets.length > Max_Attachments
assets.each {|a| errors.add_to_base("#{a.name} is over #{Max_Attachment_Size/1.megabyte}MB") if a.file_size > Max_Attachment_Size}
end
Step 4: Add Security
By default Paperclip stores attachments under the public
directory,
thus they are generally available to everyone. To secure the attachments
Paperclip needs to be told to store the attachments in a different
place, and provided with a controller method to retrieve them. To store
the attachments in an assets
directory directly under the project
root, and have the show
method on the AssetsController
retrieve
them, then modify the association in asset.rb
to be the following:
has_attached_file :data,
:url => "/assets/:id",
:path => ":rails_root/assets/docs/:id/:style/:basename.:extension"
This will require the creation of assets_controller.rb
as below. I
have just put the comment # do security check here
in the method where
you need to add any security checking as it tends to be application
specific. Likely security includes checking the user is logged in or has
a particular role or has permission to view the object which has the
attachment.
Update: if you are using a web server that handles it, you can add
:x_sendfile => true
on the end of the send_file
to lower memory
consumption. See the Rails documentation for more
information.
class AssetsController < ApplicationController
def show
asset = Asset.find(params[:id])
# do security check here
send_file asset.data.path, :type => asset.data_content_type
end
end
Step 5: Setup Display
To handle the display for this simple app there is a new
page that
allows users to add attachments to a task, a show
page that displays
the attachments for task and an edit
page that allows both. To do this
some javascript needs to be set up. Also the rest of this tutorial
assumes that the image public/images/attachment.png
is present and
that the css includes the following (I just added it to
public/stylesheets/scaffold.css
):
#attachment_list #pending_files{ list-style:none;padding:0; }
#attachment_list li { padding:0 0 0.5em 21px;background:url('/images/attachment.png') no-repeat 0 2px; }
#attachment_list li #remove { margin-left:1em; }
I have used a slightly modified version of the multifile javascript
library used by Brian
Getting
and originally by the
Stickman.
This assumes you are using Prototype and
Scriptaculous. It allows users to add a
number of files at once which are then stored in page and sent on an
update. Create the file public/javascripts/multifile.js
as below:
// -------------------------
// Multiple File Upload
// -------------------------
function MultiSelector(list_target, max) {
this.list_target = list_target;this.count = 0;this.id = 0;if( max ){this.max = max;} else {this.max = -1;};this.addElement = function( element ){if( element.tagName == 'INPUT' && element.type == 'file' ){element.name = 'attachment[file_' + (this.id++) + ']';element.multi_selector = this;element.onchange = function(){var new_element = document.createElement( 'input' );new_element.type = 'file';this.parentNode.insertBefore( new_element, this );this.multi_selector.addElement( new_element );this.multi_selector.addListRow( this );this.style.position = 'absolute';this.style.left = '-1000px';};if( this.max != -1 && this.count >= this.max ){element.disabled = true;};this.count++;this.current_element = element;} else {alert( 'Error: not a file input element' );};};this.addListRow = function( element ){var new_row = document.createElement('li');var new_row_button = document.createElement( 'a' );new_row_button.setAttribute('id', 'remove');new_row_button.title = 'Remove This Attachment';new_row_button.href = '#';new_row_button.innerHTML = 'Remove';new_row.element = element;new_row_button.onclick= function(){this.parentNode.element.parentNode.removeChild( this.parentNode.element );this.parentNode.parentNode.removeChild( this.parentNode );this.parentNode.element.multi_selector.count--;this.parentNode.element.multi_selector.current_element.disabled = false;return false;};new_row.innerHTML = element.value.split('/')[element.value.split('/').length - 1];new_row.appendChild( new_row_button );this.list_target.appendChild( new_row );};
}
Then just add the javascript into the page layout for any page that will
require the ability to upload files (lazily, I put it in
layouts/tasks.html.erb
despite not all task views needing it).
<%= javascript_include_tag "prototype", "application", "effects", "controls", "multifile" %>
Step 6: New Action
Firstly, to add new attachments to the scaffold new.html.erb
the form
needs to be updated to include :multipart => true
, this allows the
attachments to be sent along with the HTTP request. Then the
Pending Attachment
paragraph creates the input field and
MultiSelector
from multifile.js
handles attachment upload on submit.
The empty pending_files
will contain references to the attachments.
Note the use of the Task
constants for the allowable number and size
of the attachments.
<h1>New task</h1>
<% form_for(@task, :html => { :multipart => true }) do |f| %>
<%= f.error_messages %>
<p>
<%= f.label :title %><br />
<%= f.text_field :title %>
</p>
<p>
<%= f.label :body %><br />
<%= f.text_area :body %>
</p>
<p>Pending Attachments: (Max of <%= Task::Max_Attachments %> each under <%= Task::Max_Attachment_Size/1.megabyte%>MB)
<% if @task.assets.count >= Task::Max_Attachments %>
<input id="newfile_data" type="file" disabled />
<% else %>
<input id="newfile_data" type="file" />
<% end %>
<div id="attachment_list"><ul id="pending_files"></ul></div>
<script type="text/javascript">
var multi_selector = new MultiSelector($('pending_files'), <%=Task::Max_Attachment_Size%>);
multi_selector.addElement($('newfile_data'));
</script>
</p>
<p>
<%= f.submit "Create" %>
</p>
<% end %>
<%= link_to 'Back', tasks_path %>
In the controller the attachments need to be read in and added to the
new task. The below code script needs to be added into
app/controllers/tasks_controller.rb
. Here most of the work is
offloaded to the protected process_file_uploads
method, so it can be
reused in other methods. Before the save or update of a task which may
have attachments added to it, call process_file_uploads
which just
loops through the HTTP parameters and builds any given attachments. They
will then be validated and persisted to the database with a save or
update.
# POST /tasks
def create
@task = Task.new(params[:task])
process_file_uploads(@task)
if @task.save
flash[:notice] = 'Task was successfully created.'
redirect_to(@task)
else
render :action => "new"
end
end
protected
def process_file_uploads(task)
i = 0
while params[:attachment]['file_'+i.to_s] != "" && !params[:attachment]['file_'+i.to_s].nil?
task.assets.build(:data => params[:attachment]['file_'+i.to_s])
i += 1
end
end
Step 7: Show Action
The show action is a little different - it doesn’t allow any attachments
to be added, instead just displaying a list of the current task
attachments. Below in app/views/tasks/show.html.erb
this is done with
a partial.
<p>
<b>Title:</b>
<%=h @task.title %>
</p>
<p>
<b>Body:</b>
<%=h @task.body %>
</p>
<p><b>Attached Files:</b><div id="attachment_list"><%= render :partial => "attachment", :collection => @task.assets %></div></p>
<%= link_to 'Edit', edit_task_path(@task) %> | <%= link_to 'Back', tasks_path %>
The partial app/views/tasks/_attachment.html.erb
just displays some
basic information about the attachment, including a link for it to be
downloaded using attachment.url
. Remember from Step 4 that this will
return the url of the AssetsController
’s show
method to add a layer
of security. I have left the remove link in the partial, this will be
explained in the next step.
<% if !attachment.id.nil? %><li id='attachment_<%=attachment.id %>'><a href='<%=attachment.url %>'><%=attachment.name %></a> (<%=attachment.file_size/1.kilobyte %>KB)
<%= link_to_remote "Remove", :url => asset_path(:id => attachment), :method => :delete, :html => { :title => "Remove this attachment", :id => "remove" } %></li>
<% end %>
Step 8: Removing Attachments
The _attachment.html.erb
partial included a link to remove the
attachment. This requires the following destroy
method added to the
assets_controller.rb
. It simply finds the attachment and deletes it.
The page is updated through javascript returned to the user’s browser.
def destroy
asset = Asset.find(params[:id])
@asset_id = asset.id.to_s
@allowed = Task::Max_Attachments - asset.attachable.assets.count
asset.destroy
end
The destroy
method’s view is app/views/assets/destroy.rjs
which
using javascript finds the attachment deleted and removes it from the
page. It then checks for an attachment add section (by looking for a
newfile_data
id) and if it finds one updates the number of attachments
that may still be added. With this check the RJS can still be used
without error on pages that do not have a newfile_data
input nor a
MultiSelector
.
page.hide "attachment_#{@asset_id}"
page.remove "attachment_#{@asset_id}"
# check that a newfile_data id exists
page.select('newfile_data').each do |element|
page.assign 'multi_selector.max', @allowed
if @allowed < Task::Max_Attachments
page << "if ($('newfile_data').disabled) { $('newfile_data').disabled = false };"
end
end
Step 9: Edit Action
To complete the views, here is the edit.html.erb
which combines the
attachment adding functionality of new
with the attachment list of
show
.
<h1>Editing task</h1>
<% form_for(@task, :html => { :multipart => true }) do |f| %>
<%= f.error_messages %>
<p>
<%= f.label :title %><br />
<%= f.text_field :title %>
</p>
<p>
<%= f.label :body %><br />
<%= f.text_area :body %>
</p>
<p>Pending Attachments: (Max of <%= Task::Max_Attachments %> each under <%= Task::Max_Attachment_Size/1.megabyte%>MB)
<% if @task.assets.count >= Task::Max_Attachments %>
<input id="newfile_data" type="file" disabled />
<% else %>
<input id="newfile_data" type="file" />
<% end %>
<div id="attachment_list"><ul id="pending_files"></ul></div>
<script type="text/javascript">
var multi_selector = new MultiSelector($('pending_files'), <%= @allowed %>);
multi_selector.addElement($('newfile_data'));
</script>
</p>
<p>
Attached Files:<div id="attachment_list"><%= render :partial => "attachment", :collection => @task.assets %></div>
</p>
<p>
<%= f.submit "Update" %>
</p>
<% end %>
<%= link_to 'Show', @task %> | <%= link_to 'Back', tasks_path %>
Step 10: Ta-da!
And that completes the tutorial. You should now have a working task list with multiple attachments. My version of the app can be downloaded here for comparison or as a base.
Comments: I have removed the commenting system (for privacy concerns), below are the comments that were left on this post before doing so…
Flyc @ 2010-01-21 - Hi Charles, Thanks for taking the time to post this article. Exactly what I needed and got the job done. Few points: 1. When I got to step 6, creating the action and specifically process_file_uploads and realized that the recently added feature of accepts_nested_attributes_for would make this easier to code. Please can you comment. Here’s an excellent tutorial from ryanb on this
Charles @ 2010-01-22 - Hi Flyc, You are quite right. This tutorial was created using Rails 2.1.0 (I should specify this in post itself) and since then there have been a few improvements. In Rails 2.3.0 the application controller was renamed (see http://guides.rubyonrails.org/2_3_release_notes.html#application-controller-renamed). So people using versions later than 2.3 will need to rename it like you did. I didn’t previously know about accepts_nested_attributes_for, but that is a good little tutorial - thanks for pointing it out. I will have to investigate. Thanks, Charles.
davemlynch @ 2010-01-22 - Hi Charles, Great post, pointed me right direction for muiltple file uploads with paperclip. Just one question when i was trying out your code, I tried recalling an image with code below <% @photo = Asset.find_by_attachable_id(@task.id)%> <%if [email protected]? %> <%= image_tag (@photo.data.url(:small))%> I was getting <img src=”/assets/4/?1266410329” alt”?1266410329”> in html code. I was expecting to get <img src=”/assets/4/small.jpg” alt”“> Any help would be gratefull
Charles @ 2010-01-22 - Hi Dave,< I’m guessing the problem you are having with <%= image_tag (@photo.data.url(:small))%> is that the image isn’t showing because the url is wrong (at least that’s the problem I had trying out your example). This is because of the security added as part of step 4. Remember that by default Paperclip stores its attachments in the public folder, but the tutorial changed this with: has_attached_file :data, :url => “/assets/:id”, :path => “:rails_root/assets/docs/:id/:style/:basename.:extension”. This means that the attachments are stored in a non-public accessible place and all access to them goes through the AssetsController (which currently doesn’t know about styles and can’t stream back images). The easiest solution to your problem is to disable security and make the attachments publically available. This can be done by changing the above line in assert.rb to: has_attached_file :data, :style => { :small => “100x100” } Note all your previous loaded attachments are now in the wrong place and won’t work - you will need to remove them first and load them again (a restart will probably help too - it did for me). If you still need security then the issue is much harder to fix. Firstly the has_attached_file line’s url will need to know about styles. It will become something like :url => “/assets/:id/:style”. Then when you create the image tag the style will be included, like <img src=”/assets/4/small”>. This still calls the AssetsController, so the routes will have to be updated so that style is passed through to the show method. Lastly, AssetsController.show will have to be modified to take in the :style, get the appropriate attachment and then stream it back to the caller. Note at the moment it uses send_file to allow the caller to download the attachment. If you also wanted to display some attachments this would have to change to handle displaying the image vs downloading. Not sure how to do that last bit. If security is not vital then just try removing it.Good luck, Charles.
Jussi @ 2010-02-28 - I tested your application. Everything worked fine since I tried to download a stored file attachment (pdf, jpg). I can download the file, but it’s always empty and damaged. I mean that size is not original and file cannot be opened. I checked that the file was stored successfully in the folder asset/docs/’id’/original. Any ideas how to solve the problem? If I disable security and store files to public folder, everything works fine.
Charles @ 2010-03-01 - Hi Jussi, I think I have worked out the issue. It seems my web server is one that can handle the >:x_sendfile => true option. If I disable that ability on the web server, then I get the error you describe (disabling security bypasses send_file). Try changing the send_file line in AssetsController.rb to send_file asset.data.path, :type => asset.data_content_type (ie. removing x_sendfile). For more info check out the send_file documentation at http://api.rubyonrails.org/classes/ActionController/Streaming.html. Thanks for spotting this. I have updated this post and example project.
Headline Shirts @ 2010-05-17 - Thanks for this tutorial, I had errors when there were no attachments so I added this to the process_file_uploads method
def process_file_uploads(design)
i = 0
if params[:attachment]
while params[:attachment]['file_'+i.to_s] != "" && !params[:attachment]['file_'+i.to_s].nil?
task.assets.build(:data =>; params[:attachment]['file_'+i.to_s])
i += 1
end
end
end
but then I got real and I wanted to make sure there was at least one attachment and that it was a pdf so in tag.rb, the Tag Model, i added some more validations:
def validate_attachments
...
## require minimum of one attachment
errors.add_to_base("Min. one attachment") if assets.length < 1
## require file to be a pdf with reasonable accuracy
assets.each {|a| errors.add_to_base('#{a.name} is not a Pdf') unless a.content_type =~ /pdf/ }
end
thanks again for the tutorial