Uploading Files With Rails and Shrine

There are many file uploading gems out there like CarrierWave, Paperclip, and Dragonfly, to name a few. They all have their specifics, and probably you’ve already used at least one of these gems.

Uploading Files With Rails and Shrine

Today, however, I want to introduce a relatively new, but very cool solution called Shrine, created by Janko Marohnić. In contrast to some other similar gems, it has a modular approach, meaning that every feature is packed as a module (or plugin in Shrine’s terminology). Want to support validations? Add a plugin. Wish to do some file processing? Add a plugin! I really love this approach as it allows you to easily control which features will be available for which model.

In this article I am going to show you how to:

  • integrate Shrine into a Rails application
  • configure it (globally and per-model)
  • add the ability to upload files
  • process files
  • add validation rules
  • store additional metadata and employ file cloud storage with Amazon S3

The source code for this article is available on GitHub.

The working demo can be found here.

Integrating Shrine

To start off, create a new Rails application without the default testing suite:

rails new FileGuru -T

I will be using Rails 5 for this demo, but most of the concepts apply to versions 3 and 4 as well.

Drop the Shrine gem into your Gemfile:

gem "shrine"

Then run:

bundle install

Now we will require a model that I am going to call Photo. Shrine stores all file-related information in a special text column ending with a _data suffix. Create and apply the corresponding migration:

rails g model Photo title:string image_data:text
rails db:migrate

Note that for older versions of Rails, the latter command should be:

rake db:migrate

Configuration options for Shrine can be set both globally and per-model. Global settings are done, of course, inside the initializer file. There I am going to hook up the necessary files and plugins. Plugins are used in Shrine to extract pieces of functionality into separate modules, giving you full control of all the available features. For example, there are plugins for validation, image processing, caching attachments, and more.

For now, let’s add two plugins: one to support ActiveRecord and another one to set up logging. They are going to be included globally. Also, set up file system storage:

config/initializers/shrine.rb

require "shrine"
require "shrine/storage/file_system"

Shrine.plugin :activerecord
Shrine.plugin :logging, logger: Rails.logger

Shrine.storages = {
  cache: Shrine::Storage::FileSystem.new("public", prefix: "uploads/cache"),
  store: Shrine::Storage::FileSystem.new("public", prefix: "uploads/store"),
}

Logger will simply output some debugging information inside the console for you saying how much time was spent to process a file. This can come in handy.

2015-10-09T20:06:06.676Z #25602: STORE[cache] ImageUploader[:avatar] User[29543] 1 file (0.1s)
2015-10-09T20:06:06.854Z #25602: PROCESS[store]: ImageUploader[:avatar] User[29543] 1-3 files (0.22s)
2015-10-09T20:06:07.133Z #25602: DELETE[destroyed]: ImageUploader[:avatar] User[29543] 3 files (0.07s)

All uploaded files will be stored inside the public/uploads directory. I don’t want to track these files in Git, so exclude this folder:

.gitignore

public/uploads

Now create a special “uploader” class that is going to host model-specific settings. For now, this class is going to be empty:

models/image_uploader.rb

class ImageUploader < Shrine
end

Lastly, include this class inside the Photo model:

models/photo.rb

include ImageUploader[:image]

[:image] adds a virtual attribute that will be used when constructing a form. The above line can be rewritten as:

  include ImageUploader.attachment(:image)  
  # or
  include ImageUploader::Attachment.new(:image) 

Nice! Now the model is equipped with Shrine’s functionality, and we can proceed to the next step.

Controller, Views, and Routes

For the purposes of this demo, we’ll need only one controller to manage photos. The index page will serve as the root:

pages_controller.rb

class PhotosController < ApplicationController
  def index
    @photos = Photo.all
  end
end

The view:

views/photos/index.html.erb

<h1>Photos</h1>

<%= link_to 'Add Photo', new_photo_path %>

<%= render @photos %>

In order to render the @photos array, a partial is required:

views/photos/_photo.html.erb

<div>
  <% if photo.image_data? %>
    <%= image_tag photo.image_url %>
  <% end %>
  <p><%= photo.title %> | <%= link_to 'Edit', edit_photo_path(photo) %></p>
</div>

image_data? is a method presented by Shrine that checks whether a record has an image.

image_url is yet another Shrine method that simply returns a path to the original image. Of course, it is much better to display a small thumbnail instead, but we will take care of that later.

Add all the necessary routes:

config/routes.rb

  resources :photos, only: [:new, :create, :index, :edit, :update]

  root 'photos#index'

This is it—the groundwork is done, and we can proceed to the interesting part!

Uploading Files

In this section I will show you how to add the functionality to actually upload files. The controller actions are very simple:

photos_controller.rb

def new
    @photo = Photo.new
end

def create
    @photo = Photo.new(photo_params)
    if @photo.save
        flash[:success] = 'Photo added!'
        redirect_to photos_path
    else
        render 'new'
    end
end

The only gotcha is that for strong parameters you have to permit the image virtual attribute, not the image_data.

photos_controller.rb

private

def photo_params
    params.require(:photo).permit(:title, :image)
end

Create the new view:

views/photos/new.html.erb

<h1>Add photo</h1>

<%= render 'form' %>

The form’s partial is also trivial:

views/photos/_form.html.erb

<%= form_for @photo do |f| %>
  <%= render "shared/errors", object: @photo %>

  <%= f.label :title %>
  <%= f.text_field :title %>

  <%= f.label :image %>
  <%= f.file_field :image %>

  <%= f.submit %>
<% end %>

Once again, note that we are using the image attribute, not the image_data.

Lastly, add another partial to display errors:

views/shared/_errors.html.erb

<% if object.errors.any? %>
  <h3>The following errors were found:</h3>

  <ul>
    <% object.errors.full_messages.each do |message| %>
      <li><%= message %></li>
    <% end %>
  </ul>
<% end %>

This is pretty much all—you can start uploading images right now.

Validations

Of course, much more work has to be done in order to complete the demo app. The main problem is that the users may upload absolutely any type of file with any size, which is not particularly great. Therefore, add another plugin to support validations:

config/inititalizers/shrine.rb

Shrine.plugin :validation_helpers

Set up the validation logic for the ImageUploader:

models/image_uploader.rb

Attacher.validate do
    validate_max_size 1.megabyte, message: "is too large (max is 1 MB)"
    validate_mime_type_inclusion ['image/jpg', 'image/jpeg', 'image/png']
end

I am permitting only JPG and PNG images less than 1MB to be uploaded. Tweak these rules as you see fit.

MIME Types

Another important thing to note is that, by default, Shrine will determine a file’s MIME type using the Content-Type HTTP header. This header is passed by the browser and set only based on the file’s extension, which is not always desirable.

If you wish to determine the MIME type based on the file’s contents, then use a plugin called determine_mime_type. I will include it inside the uploader class, as other models may not require this functionality:

models/image_uploader.rb

plugin :determine_mime_type

This plugin is going to use Linux’s file utility by default.

Caching Attached Images

Currently, when a user sends a form with incorrect data, the form will be displayed again with errors rendered above. The problem, however, is that the attached image will be lost, and the user will need to select it once again. This is very easy to fix using yet another plugin called cached_attachment_data:

models/image_uploader.rb

plugin :cached_attachment_data

Now simply add a hidden field into your form.

views/photos/_form.html.erb

<%= f.hidden_field :image, value: @photo.cached_image_data %>
<%= f.label :image %>
<%= f.file_field :image %>

Editing a Photo

Now images can be uploaded, but there is no way to edit them, so let’s fix it right away. The corresponding controller’s actions are somewhat trivial:

photos_controller.rb

def edit
    @photo = Photo.find(params[:id])
end

def update
    @photo = Photo.find(params[:id])
    if @photo.update_attributes(photo_params)
      flash[:success] = 'Photo edited!'
      redirect_to photos_path
    else
      render 'edit'
    end
end

The same _form partial will be utilized:

views/photos/edit.html.erb

<h1>Edit Photo</h1>

<%= render 'form' %>

Nice, but not enough: users still can’t remove an uploaded image. In order to allow this, we’ll need—guess what—another plugin:

models/image_uploader.rb

plugin :remove_attachment

It uses a virtual attribute called :remove_image, so permit it inside the controller:

photos_controller.rb

def photo_params
    params.require(:photo).permit(:title, :image, :remove_image)
end

Now just display a checkbox to remove an image if a record has an attachment in place:

views/photos/_form.html.erb

<% if @photo.image_data? %>
    Remove attachment: <%= f.check_box :remove_image %>
<% end %>

Generating a Thumbnail Image

Currently we display original images, which is not the best approach for previews: photos may be large and occupy too much space. Of course, you could simply employ the CSS width and height attributes, but that’s a bad idea as well. You see, even if the image is set to be small using styles, the user will still need to download the original file, which might be pretty big.

Therefore, it is much better to generate a small preview image on the server side during the initial upload. This involves two plugins and two additional gems. Firstly, drop in the gems:

gem "image_processing"
gem "mini_magick", ">= 4.3.5"

Image_processing is a special gem created by the author of Shrine. It presents some high-level helper methods to manipulate images. This gem, in turn, relies on mini_magick, a Ruby wrapper for ImageMagick. As you’ve guessed, you’ll need ImageMagick on your system in order to run this demo.

Install these new gems:

bundle install

Now include the plugins along with their dependencies:

models/image_uploader.rb

require "image_processing/mini_magick"

class ImageUploader < Shrine
    include ImageProcessing::MiniMagick
    plugin :processing
    plugin :versions
    # other code...
end

Processing is the plugin to actually manipulate an image (for example, shrink it, rotate, convert to another format, etc.). Versions, in turn, allows us to have an image in different variants. For this demo, two versions will be stored: “original” and “thumb” (resized to 300x300).

Here is the code to process an image and store its two versions:

models/image_uploader.rb

class ImageUploader < Shrine
    process(:store) do |io, context|
        { original: io, thumb: resize_to_limit!(io.download, 300, 300) }
    end
end

resize_to_limit! is a method provided by the image_processing gem. It simply shrinks an image down to 300x300 if it is larger and does nothing if it’s smaller. Moreover, it keeps the original aspect ratio.

Now when displaying the image, you just need to provide either the :original or :thumb argument to the image_url method:

views/photos/_photo.html.erb

<div>
  <% if photo.image_data? %>
    <%= image_tag photo.image_url(:thumb) %>
  <% end %>
  <p><%= photo.title %> | <%= link_to 'Edit', edit_photo_path(photo) %></p>
</div>

The same can be done inside the form:

views/photos/_form.html.erb

<% if @photo.image_data? %>
    <%= image_tag @photo.image_url(:thumb) %>
    Remove attachment: <%= f.check_box :remove_image %>
<% end %>

To automatically delete the processed files after uploading is complete, you may add a plugin called delete_raw:

models/image_uploader.rb

plugin :delete_raw

Image’s Metadata

Apart from actually rendering an image, you may also fetch its metadata. Let’s, for example, display the original photo’s size and MIME type:

views/photos/_photo.html.erb

<div>
  <% if photo.image_data? %>
    <%= image_tag photo.image_url(:thumb) %>
    <p>
      Size <%= photo.image[:original].size %> bytes<br>
      MIME type <%= photo.image[:original].mime_type %><br>
    </p>
  <% end %>
  <p><%= photo.title %> | <%= link_to 'Edit', edit_photo_path(photo) %></p>
</div>

What about its dimensions? Unfortunately, they are not stored by default, but this is possible with a plugin called store_dimensions.

Image’s Dimensions

The store_dimensions plugin relies on the fastimage gem, so hook it up now:

gem 'fastimage'

Don’t forget to run:

bundle install

Now just include the plugin:

models/image_uploader.rb

plugin :store_dimensions

And display the dimensions using the width and height methods:

views/photos/_photo.html.erb

<div>
  <% if photo.image_data? %>
    <%= image_tag photo.image_url(:thumb) %>
    <p>
      Size <%= photo.image[:original].size %> bytes<br>
      MIME type <%= photo.image[:original].mime_type %><br>
      Dimensions <%= "#{photo.image[:original].width}x#{photo.image[:original].height}" %>
    </p>
  <% end %>
  <p><%= photo.title %> | <%= link_to 'Edit', edit_photo_path(photo) %></p>
</div>

Also, there is a dimensions method available that returns an array containing width and height (for example, [500, 750]).

Moving to the Cloud

Developers often choose cloud services to host uploaded files, and Shrine does present such a possibility. In this section, I will show you how to upload files to Amazon S3.

As the first step, include two more gems into the Gemfile:

gem "aws-sdk", "~> 2.1"
group :development do
    gem 'dotenv-rails'
end

aws-sdk is required to work with S3’s SDK, whereas dotenv-rails will be used to manage environment variables in development.

bundle install

Before proceeding, you should obtain a key pair to access S3 via API. To get it, sign in (or sign up) to Amazon Web Services Console and navigate to Security Credentials > Users. Create a user with permissions to manipulate files on S3. Here is the simple policy presenting full access to S3:

{
  "Version": "2016-11-14",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:*",
      "Resource": "*"
    }
  ]
}

Download the created user’s key pair. Alternatively, you might use root access keys, but I strongly discourage you from doing that as it’s very insecure.

Next, create an S3 bucket to host your files and add a file into the project’s root to host your configuration:

.env

S3_KEY=YOUR_KEY
S3_SECRET=YOUR_SECRET
S3_BUCKET=YOUR_BUCKET
S3_REGION=YOUR_REGION

Never ever expose this file to the public, and make sure you exclude it from Git:

.gitignore

.env

Now modify Shrine’s global configuration and introduce a new storage:

config/initializers/shrine.rb

require "shrine"
require "shrine/storage/s3"

s3_options = {
  access_key_id:     ENV['S3_KEY'],
  secret_access_key: ENV['S3_SECRET'],
  region:            ENV['S3_REGION'],
  bucket:            ENV['S3_BUCKET'],
}

Shrine.storages = {
  cache: Shrine::Storage::S3.new(prefix: "cache", **s3_options),
  store: Shrine::Storage::S3.new(prefix: "store", **s3_options),
}

That’s it! No changes have to be made to the other parts of the app, and you can test this new storage right away. If you are receiving errors from S3 related to incorrect keys, make sure you accurately copied the key and secret, without any trailing spaces and invisible special symbols.

Conclusion

We’ve come to the end of this article. Hopefully, by now you feel much confident in using Shrine and are eager to employ it in one of your projects. We have discussed many of this gem’s features, but there are even more, like the ability to store additional context along with files and the direct upload mechanism.

Therefore, do browse Shrine’s documentation and its official website, which thoroughly describes all available plugins. If you have other questions left about this gem, don’t hesitate to post them. I thank you for staying with me, and I’ll see you soon!