Uploading Files With Rails and ActionCable

Ilya Bodrov

This is the second part of the tutorial that explains how to enable real-time communication and file uploading with Rails and ActionCable. In the previous part we have created a Rails 5.1 chatting application powered by Clearance (for authentication), ActionCable (for real-time updates) and ActiveJob (for broadcasting). In this part you will learn how to integrate the Shrine gem and utilize the FileReader API to enable file uploading over web sockets.

As a result, you will get an application similar to this one: The source code for the tutorial is available at GitHub.

Integrating Shrine

First things first: we need a solution to enable file uploading functionality for our application. There are a handful of gems that can solve this task, including Paperclip, Dragonfly and Carrierwave, but I'd suggest sticking with a solution called Shrine that I've stumbled upon a couple of months ago. I like this gem because of its modular approach and vast array of supported features: it works with ActiveRecord and Sequel, supports Rails and non-Rails environments, provides background processing and more. Another very important thing is that this gem enables easy uploading of files sent as data URIs which is cruical for our today's task (later you will see why). You may read this introductory article by Janko Marohnić, author of the gem, explaining the motivation behind creating Shrine. By the way, I wanted to thank Janko for helping me out and answering some questions.

So, you know what to do—drop a new gem into the Gemfile:

gem 'shrine', '~> 2.6'

Then run:

bundle install

Next you will require at least basic setup for Shrine that is stored inside an initializer file:

# config/initializers/shrine.rb

require "shrine" # core
require "shrine/storage/file_system" # plugin to save files using file system

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

Shrine.plugin :activerecord # enable ActiveRecord support

Here we simply provide paths where the uploaded files and cached data will be stored. Also, we enable support for ActiveRecord by using the appropriate plugin. Most of the Shrine's functionality is packed in a form plugins, and you can configure a totally different setup by using the ones listed on the gem's homepage.

Please note that it is probably a good idea to exclude public/uploads directory from version control to avoid pushing images uploaded for testing purposes to GitHub. Add the following line to the .gitignore file:

public/uploads

An attachment attribute is required for our model as well. It should have a name of <some_name>_data and a text type. This attribute stores all the information about the uploaded file, so you don't have to add separate fields for a name, metadata etc. Create and apply a new migration:

rails g migration add_attachment_data_to_messages attachment_data:text
rails db:migrate

All attachment-specific logic and individual plugins are listed inside the uploader class:

class AttachmentUploader < Shrine
  # app/uploaders/attachment_uploader.rb
end

Later we will hook up some necessary plugins inside this class. Now the uploader can be included in the model:

# models/message.rb
include AttachmentUploader[:attachment]

Note that the same uploader may be included for multiple models.

Adding File Validations

Before proceeding, let's also secure our uploads a bit. Currently any file with any size is accepted which is not particularly great, so we are going to introduce some restrictions. This can be done using the validation_helpers plugin. I will define validations globally inside the initializer, but this can also be done per-uploader:

# config/initializers/shrine.rb
Shrine.plugin :determine_mime_type # check MIME TYPE
Shrine.plugin :validation_helpers, default_messages: {
    mime_type_inclusion: ->(whitelist) { # you may use whitelist variable to display allowed types
      "isn't of allowed type. It must be an image."
    }
}

Shrine::Attacher.validate do
  validate_mime_type_inclusion [ # whitelist only these MIME types
                                   'image/jpeg',
                                   'image/png',
                                   'image/gif'
                               ]
  validate_max_size 1.megabyte # limit file size to 1MB
end

This code is pretty much self-explaining. Apart from validation helpers, we use determine_mime_type plugin that, well, checks MIME types for the uploaded files based on their content. The default analyzer for this plugin is file utility which is not available on Windows by default. You may use some third-party solution instead (or even multiple solutions at once) as explained by the official guide. Do test, however, that all whitelisted file types are really accepted because recently I had troubles when uploading .txt files with mimemagic set as an analyzer.

Next, inside the Shrine::Attacher.validate do we allow PNG, JPEG and GIF images to be uploaded and restrict their size to 1 megabyte.

Lastly, I'd like to change our validation rule for the body attribute inside the Message model. Currently it looks like

# models/message.rb
validates :body, presence: true

but I'd like to modify it:

# models/message.rb
validates :body, presence: true, unless: :attachment_data

This way we allow messages to have no text if they have an attached file. Great!

Uploading with FileReader

Views

Shrine is now integrated into the application and we can proceed to the client-side and add a file field to the form rendered at the views/chats/index.html.erb view:

<%= form_with url: '#', html: {id: 'new-message'} do |f| %>
  <%= f.label :body %>
  <%= f.text_area :body, id: 'message-body' %>

  <div class="form-group">
    <%= f.file_field :attachment, id: 'message-attachment' %>
    <br>
    <small>Only PNG, JPG and GIF images are allowed</small>
  </div>

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

Remember that the form_with helper does not add any ids automatically, so I've specified them explicitly.

Also let's tweak the views/messages/_message.html.erb partial to display an attachment if it is present:

<div class="message">
  <strong><%= message.user.email %></strong> says:
  <%= message.body %>
  <br>
  <small>at <%= l message.created_at, format: :short %></small>
  <% if message.attachment_data? %>
    <br>
    <%= link_to "View #{message.attachment.original_filename}",
                message.attachment.url, target: '_blank' %>
  <% end %>
  <hr>
</div>

The link will simply display the file's original name and open the attachment in a new tab when clicked. Your page should now look like this:

CoffeeScript

Great, the views are prepared as well and we can write some CoffeeScript. It may be a good idea to make yourself a coffee because this is where things start to get a bit more complex. But fear not, I'll proceed slowly and explain what is going on.

The first thing to mention is that we have to send the chosen file over web sockets along with the entered message. Therefore, the file has to be processed with JavaScript. Luckily, there is a FileReader API available that allows us to read the contents of files. It works with all major browsers, including support for IE 10+ (by the way, web sockets are also supported in IE 10+).

To start off, let's change the condition inside the submit event handler. Here is its current version:

# app/assets/javascripts/channels/chat.coffee
# ...

$new_message_form.submit (e) ->
  $this = $(this)
  message_body = $new_message_body.val()
  if $.trim(message_body).length > 0
    App.chat.send_message message_body
  e.preventDefault()
  return false

I want to send the message if it has some text or if an attachment is selected (remember that we've added server-side validation inside the Message model that follows the same principle). Therefore, the condition turns to:

# We check that either the body has some contents or a file is chosen
if $.trim(message_body).length > 0 or $new_message_attachment.get(0).files.length > 0

Now, if the attachment is chosen, we need to instantiate a new FileReader object, so add another condition inside:

# ...
if $.trim(message_body).length > 0 or $new_message_attachment.get(0).files.length > 0
    if $new_message_attachment.get(0).files.length > 0 # if file is chosen
      reader = new FileReader()  # use FileReader API
      file_name = $new_message_attachment.get(0).files[0].name # get the name of the first chosen file
    else
      App.chat.send_message message_body

Here I am also storing the original file name as it has to be sent separately—there will be no way to determine it on the server-side otherwise.

The reader can now be used to carry out its main purpose: read the contents of a file. We'll use the readAsDataURL method that returns a base64 encoded string:

if $.trim(message_body).length > 0 or $new_message_attachment.get(0).files.length > 0
    if $new_message_attachment.get(0).files.length > 0 # if file is chosen
      reader = new FileReader() 
      file_name = $new_message_attachment.get(0).files[0].name
      reader.readAsDataURL $new_message_attachment.get(0).files[0] # read the chosen file
    else
      App.chat.send_message message_body

Of course, reading a file may take some time, therefore we must wait for this process to finish before sending the message. This can be done with the help of the loadend callback that is fired as soon as the file is read. Inside this callback we have access to the reader.result that contains the actual base64 encoded string. So, apply this knowledge to practice:

if $.trim(message_body).length > 0 or $new_message_attachment.get(0).files.length > 0
    if $new_message_attachment.get(0).files.length > 0 
      reader = new FileReader() 
      file_name = $new_message_attachment.get(0).files[0].name
      reader.addEventListener "loadend", -> # perform the following action after the file is loaded
        App.chat.send_message message_body, reader.result, file_name 

      reader.readAsDataURL $new_message_attachment.get(0).files[0] # read file in base 64 format
    else
      App.chat.send_message message_body

That's pretty much it. Our file reader is finished!

The send_message function requires some changes as we wish to pass the file's contents and the original filename to it:

# ...
send_message: (message, file_uri, original_name) ->
# send the message to the server along with file in base64 encoding 
        @perform 'send_message', message: message, file_uri: file_uri, original_name: original_name

All that is left to do is enable support for base64 encoded strings on the server-side, so let's move on to the next section.

Uploading Files as Data URIs

At the beginning of this tutorial I mentioned that Shrine supports uploading files in the form of data URIs. We're at the point where it becomes really important, as we need to process the base64 encoded string sent by the client and save the file properly.

To achieve this, enable the data_uri plugin for the uploader (though this can also be done globally inside the initializer):

# uploaders/attachment_uploader.rb
plugin :data_uri

It appears that having this plugin in place, uploading files as data URIs means simply assigning the proper string to the attachment_data_uri attribute:

# channels/chat_channel.rb

def send_message(data)
    message = current_user.messages.build(body: data['message'])
    if data['file_uri']
      message.attachment_data_uri = data['file_uri']
    end
    message.save
end

That's pretty much it, the uploading should now be carried out properly. Note that we do not have to make any changes to the after_create_commit callback or to the background job.

Storing the Original Filename

Another thing we need to take care of is storing the original file's name. Strictly speaking, it is not required, but this way users will be able to quickly understand which file they've received (including its original extension).

The original name can be stored inside the attachment's metadata, so another plugin called add_metadata is needed. As the name implies, it allows to provide or extract meta information from the uploaded file. This process is performed inside the uploader or the initializer. At this point, however, we do not have access to the original filename sent by the client anymore:

# uploaders/attachment_uploader.rb

plugin :add_metadata

add_metadata do |io, context|
    {'filename' => ???}
end

Luckily, we do have access to the context (that is, the actual record), so the filename can be saved inside the virtual attribute called, for instance, attachment_name:

# uploaders/attachment_uploader.rb

add_metadata do |io, context|
    {'filename' => context[:record].attachment_name}
end

All we need to do is set this virtual attribute:

# channels/chat_channel.rb

def send_message(data)
    message = current_user.messages.build(body: data['message'])
    if data['file_uri']
      message.attachment_name = data['original_name']
      message.attachment_data_uri = data['file_uri']
    end
    message.save
end

Don't forget to define the getter and setter for this attribute inside the model (or use attr_accessor):

# models/mesage.rb

def attachment_name=(name)
    @attachment_name = name
end

def attachment_name
    @attachment_name
end

And—guess what—we are done! You may now boot the server and try to upload some image (remember that we've restricted its size to 1MB though). The link to the file should be displayed under the message. Note that the original filename is preserved: You will see an output in the terminal similar to this one: The message will then be broadcasted to all clients:

Conclusion

We've come to the end of the tutorial. The chat application is finished and the user can now communicate and exchange files with ease in real-time which is quite convenient. In this tutorial you have learned how to:

  • Integrate Shrine gem to enable file uploads
  • Add file validations
  • Work with Shrine's plugins
  • Utilize FileReader Web API
  • Process files as Data URIs
  • Store file's metadata

Of course, there is more to this app that can be done: for example, it would be great to display validation errors if the message cannot be saved. However, this will be left for you as an exercise. All in all, this task is not that complex: you can, for example, set a condition inside the ChatChannel#send_message and send an error back to the author if the saving of the message failed.

Share your solutions in the comments and don't hesitate to post your questions as well. I thank you for staying with me and happy coding!

Ilya Bodrov

3 posts

Ilya Bodrov is personal IT teacher, consulting developer, author and lecturer at Moscow Aviations Institute. His primary programming languages are Ruby (with Rails), Elixir and JavaScript. He enjoys coding, teaching people and learning new things. In his free time he tweets, writes posts for his website, participates in OpenSource projects, goes in for sports and plays music.