Uploading Files with acts_as_attachment

Problem

Contributed by: Rick Olson

You want to add file upload support to your Rails application but you need more options than are available with the file_column plug-in. Specifically, you need to be able to configure details about how file uploading is handled on a per-model basis. For example, one model may store images in a database while another saves them on the filesystem.

Solution

Use the acts_as_attachment plug-in to allow you to configure file-uploading capabilities individually, for each model that supports uploads.

Suppose you want to allow DVD collectors to upload cover art for each item in their collection. For this recipe, assume you have a Rails application configured to access your database. Start by adding this URL to your plug-in source list:

$ ruby script/plugin source http://svn.techno-weenie.net/projects/plugins

Next, download the acts_as_attachment plug-in:

$ ruby script/plugin install acts_as_attachment

Because this plug-in can depend on RMagick being installed, it's a good idea to run its test to make sure it finds everything it needs on your system:

$ rake test:plugins PLUGIN=acts_as_attachment

Now use the plug-in's attachment_model generator to generate an attachment model named dvd_cover:

$ script/generate attachment_model dvd_cover

Running this command generates the model stubs as well as an attachment migration to get started. Here's the database migration you'll use to set up the table structure:

class CreateDvdCovers < ActiveRecord::Migration
 def self.up
 create_table :dvd_covers do |t| 
 t.column "content_type", :string
 t.column "filename", :string 
 t.column "size", :integer
 # used with thumbnails, always required
 t.column "parent_id", :integer 
 t.column "thumbnail", :string
 # required for images only
 t.column "width", :integer 
 t.column "height", :integer
 end 
 # only for db-based files
 # create_table :db_files, :force => true do |t|
 # t.column :data, :binary
 # end
 end 
 def self.down
 drop_table :dvd_covers
 # only for db-based files
 # drop_table :db_files
 end 
end

The columns content_type, filename, size, parent_id, and thumbnail are all vital for acts_as_attachment. Width and height are optional and used for images only. Here's what the initial model will look like:

class DvdCover < ActiveRecord::Base
 belongs_to :dvd
 acts_as_attachment :storage => :file_system
 validates_as_attachment end

The :file_system storage option specifies that uploaded files are to go in your application's public directory. For example, if you uploaded a file called logo.gif, you'd end up with the following file path on your server: public/dvd_covers/1/logo.gif.

The validates method sets up the essential validations: checking that the file size is within the limits you've specified, that the content type matches what you want, and that the filename, size, and content_type fields are present. The default file size ranges from 1 B to 1 MB. Because DVD covers typically won't be that large, set up some constraints on what files are allowed. You can always use the :image shortcut to specify any common image type (e.g., GIF, JPG, PNG).

app/models/dvd_cover.rb:

class DvdCover < ActiveRecord::Base
 belongs_to :dvd 
 acts_as_attachment :storage => :file_system, 
 :max_size => 300.kilobytes, 
 :content_type => :image,
 :thumbnails => { 
 :thumb => [50, 50], 
 :geometry => 'x50' 
 }
 validates_as_attachment end

Setting up a controller and some initial views does not require any special code. acts_as_attachment creates an uploaded_data= setter that does all the processing for you. Here's everything you need for a working example:

app/controllers/dvd_covers_controller.rb:

class DvdCoversController < ApplicationController
 def index
 @dvd_covers = DvdCover.find(:all)
 end
 def new
 @dvd_cover = DvdCover.new
 end
 def show
 @dvd_cover = DvdCover.find params[:id]
 end
 def create
 @dvd_cover = DvdCover.create! params[:dvd_cover]
 redirect_to :action => 'show', :id => @dvd_cover
 rescue ActiveRecord::RecordInvalid
 render :action => 'new'
 end end

Here's a view to list all uploaded files or images:

app/views/dvd_covers/index.rhtml:

<h1>DVD Covers</h1>
<ul>
<% @dvd_covers.each do |dvd_cover| -%>
 <li><%= link_to dvd_cover.filename, :action => 'show', 
 :id => dvd_cover %></li>
<% end -%>
</ul>
<p><%= link_to 'New', :action => 'new' %></p>

Next, here's a form, containing a multipart, file selection element:

app/views/dvd_covers/new.rhtml:

<h1>New DVD Cover</h1>
<% form_for :dvd_cover, :url => { :action => 'create' }, 
 :html => { :multipart => true } do |f| -%>
 <p><%= f.file_field :uploaded_data %></p>
 <p><%= submit_tag :Create %></p>
<% end -%>

Finally, here's some code to display individual DVD cover images:

app/views/dvd_covers/show.rhtml:

<p><%= @dvd_cover.filename %></p>
<%= image_tag @dvd_cover.public_filename, 
 :size => @dvd_cover.image_size %>

Discussion

The acts_as_attachment plug-in is designed to be specified on multiple models in your application, rather than having a global Attachment model that other models depend on.

Like file_column, acts_as_attachment supports thumbnail images. The first way to trigger the generation of thumbnails is with the resize_to option:

acts_as_attachment :storage => :file_system, :resize_to => '300x200'

The option takes two forms of parameters: a standard width/height array ([300, 200]), or an RMagick geometry string. The various codes can give you a lot of power.

Resizing the original image is not always desired. Sometimes you will want to change thumbnail sizes and regenerate. Not having the original around makes this impossible. So instead, we'll create various thumbnail sizes.

acts_as_attachment :storage => :file_system, 
 :thumbnails => { :normal => '300>', :thumb => '75' }

The'300>' geometry code resizes the width to 300 if it's larger and keeps aspect ratio. The '75' geometry code always resizes the width to 75, while keeping the aspect ratio.

Now let's change the show view to accommodate for these new thumbnails:

<p>Original: <%= link_to @dvd_cover.filename, @dvd_cover.public_filename %></p>
<% @dvd_cover.thumbnails.each do |thumb| -%>
<p><%= thumb.thumbnail.to_s.humanize %>: 
 <%= link_to thumb.filename, thumb.public_filename %></p>
<% end -%>

There are a few things to explain here:

  • public_filename is a dynamic method that gets the public path to a file. This only works on filesystem attachments. It basically takes the full_filename (absolute path to the file on the server) and strips the RAILS_ROOT from the beginning, making it suitable for links.
  • Attachments have a parent association that links to the original image, and a thumbnail has_many that links to all the thumbnails. You can use this to iterate through all the thumbnails for an image.
  • Thumbnails store the thumbnail key taken from the :thumbnails options above. This example, DVD Covers application, uses normal and thumb. File-based attachments add this to the end of the file, resulting in names like cover.jpg, cover_normal.jpg, and cover_thumb.jpg.
  • public_filename is smart enough to take a thumbnail key to generate its filename. For instance, the show action above can be rewritten more efficiently without having to load the thumbnails:
    <% DvdCover.attachment_options[:thumbnails].keys.each do |key| -%>
    <p><%= key.to_s.humanize %>: 
     <%= link_to key, @dvd_cover.public_filename(key) %></p>
    <% end -%>
    

See Also