Rails 5.2 ActiveStorage: Mitigating Adoption Pitfalls

omnilord
10 min readMar 20, 2018

--

tl;dr code snippets can be found here: https://gist.github.com/omnilord/4f308d4a1d0b9df02293dcaa8ee4d605.

“Colorful lines of code on a MacBook screen” by Caspar Rubin on Unsplash

Front Matter

With Rails 5.2 comes a very useful feature, ActiveStorage. ActiveStorage allows for super simple file attachments to models without adding extra cruft or other gems to shim the framework. Examples are hard to come by on how to do some of the useful things, so I figured I would fill in the gaps for a while until better signal comes through to push my noise out of the way.

*** Any paths presented here are relative to the rails root directory unless otherwise noted. ***

Setup

To start with, there are no gems to include because ActiveStorage is built right into Rails as of 5.2.0. This makes it very easy going to get started. Since it is already there, the four basics you need to know from a code perspective are the usual suspects: migration, model, controller, and view.

Okay, so, I fibbed a little there, you might need one or two gems to get things working as I describe herein but including them opens up amazing functionality with very little configuration. If you plan on doing transformations (resize, crop, etc.), you will need an ImageMagick interface. mini_magick is a good choice from my limited experience. And, you will need the SDK for your choice of cloud storage. In the example, I use AWS S3. You should add these two lines to your Gemfile if you are planning to use either of these features:

gem ‘aws-sdk-s3’ #swap this out for Azure or GCP as needed
gem ‘mini_magick’

Okay, on to storage…

First, run the ActiveStorage generator and migrate, rails active_storage:install && rails db:migrate to setup the tables.

Second, setup the models by adding has_one_attached or has_many_attached to the models that you want to attach one or many images to, respectively. Both macros take a single symbol as an argument, the field name for the attachment. The has_many_attached field will be an array of attachments, so mind that detail if you use it.

Third, you need to add the fieldname(s) to the params permit in the controller for sanitization purposes (you are using strong parameters, right?). Also, any thumbnail selection information should also be permitted and sanitized as you need. In my application, I use a jQuery extension called Jcrop to select a square for the thumbnail, then pass those values back through with the form submit.

Lastly, the view is where you will spend most of your time because the most lines of code need to be written here. In the simplest implementation, depending on your process, you either add ActiveStorage to your asset pipeline, or include an ActiveStorage build step if you are using something like WebPacker. My preference is the asset pipeline.

The quintessential part for the view can be summed up with this one-line snippet:

<%= form.file_field(:fieldname, multiple: false, direct_upload: true) %>

The two critical features here are the multiple and direct_upload attributes. Mutliple coordinates with ActiveStorage to simplify uploading multiple images as a group for has_many_attached fields, but that is boring compared to what direct_upload: true does. In the next section, we discus Cloud Storage and direct uploads.

Cloud Storage

By default, ActiveStorage will save things in the /storage directory on the local host and requires no additional work, but in many production systems the underlying host is ephemeral and can be dynamically terminated as needed by various DevOps processes making local storage an unfit configuration.

Configuring ActiveStorage for cloud is deceptively simple — deceptively because it seems like it should be a complex process and previously it was since it required including extra gems that come with their own set of boilerplate code; simple because ActiveStorage provides a very small surface area and practically no boilerplate (other than the migration) to accomplish everything you need, everything is right under the hood and conventional. As long as you provision your cloud provider for access credentials to your storage resource (ex. use IAM to setup credentials, and setup a public S3 bucket with those credentials to allow uploading), you are golden.

With a Rails 5.2 install, looking at /config/storage.yml reveals all the configurations necessary to setup your choice of cloud provide (GCS, AWS S3, AzureStorage, etc., you can even configure multiple targets with a mirror option). You’ll notice that some of the credentials are fetched*, which is a great security feature.

Once the credentials are saved, setting the config.active_storage.service value to the desired service provider symbol from /config/storage.yml in the desired environment file in /config/environments/ and ActiveStorage will store files with that provider. Do not forget to restart your server if you change config while it is running.

In the previous section, we introduced a snippet containing the view element for uploading files and it contains an attribute called direct_upload. Under normal operations, the file will be uploaded to your Rails application, then through the cloud storage gem to its final destination. Using direct upload, the form in the browser will actually send the file directly to the cloud storage during the submit process using a signed URL. There are even JavaScript callbacks you can attach to the form to process events (Upload Start, End, Progress, etc) during the upload life-cycle. This will reduce the bandwidth your app is responsible for significantly with very little trade off.

On the security front, be aware that using direct_upload does have some known loop holes in accessing files via signed URLs. There is some discussion about how if you get one signed URL, you can use it to access other files, but it seems to be an edge case most developers will not need to worry about. Feel free to peruse the Rails issues database on Github to find more information on this issue.

* If you do not know what credential storage is, there is a beautiful article on this feature available at Engine Yard.

Local Disk Storage

If you look in /config/storage.yml, you will see there is a top-level section called local. This is for storing files on the local system. This should be fine the way it is configured, unless you are using SSL locally within Rails 5.2 RC1, probably as a Puma configuration. I spent several days trying to debug this issue, and ultimately submitted an issue to Rails and got a good reply from George Claghorn. There is an undocumented, deprecated setting you can use to tell ActiveStorage what host to use for the storage URL. Add host: https://localhost:3000 to the local setting and your file URLs should be rendered correctly. This setting is deprecated and will be removed in RC2 in favor of autodetecting the host URL, but if you are like me and working on 5.2 early, this is valuable information.

Validation (or lack thereof)

One important thing from a security consideration, as of March 1, 2018, there is NO VALIDATION SUPPORT ON THE FILE BLOB DATA. In this issue, George Claghorn states that blobs will get uploaded regardless of size or file type, or any other validation occurring on the associated resource. As far as I can tell, you will need to manually purge the attachment (probably in an asynchronous job) if you fail validation. I have not done any personal testing of this, but I will update when I need to address this situation in my application.

Transformation (AKA Variants)

Another thing that was super confusing was how the variant functionality worked. To be frank, it is just a map to the underlying ImageMagick functionality, but what options are available is not exactly easy to track down. In the Rails documentation they show only resize, which was not enough for my avatar thumbnail solution.

For this feature, I needed my end users to select a square to crop a preferred thumbnail. This part is easy (using Jcrop), but how the Variant function would fit into my workflow, now THAT was the time sink that took far too much time for how easy this is.

First, you can specify multiple transformation instructions. Just pass in multiple key/value pairs in the order you want them executed (so far, I have not had any out-of-order hash access problems). To show you what I did, on my Profile model I have the follow method that returns the thumbnail variant:

def thumbnail(size = ‘100x100’)
if avatar.attached?
# I store thumbnail meta-information as a json field
# named `thumb_settings` in PostgreSQL.
# YMMV depending on your persistence layer options.
if thumb_settings.is_a? Hashdimensions = “#{thumb_settings[‘h’]}x#{thumb_settings[‘w’]}”
coord = “#{thumb_settings[‘x’]}+#{thumb_settings[‘y’]}”
avatar.variant(
crop: “#{dimensions}+#{coord}”,
resize: size
).processed
elseavatar.variant(resize: size).processedend
end
end

And voila, we have access to a 100x100 (default) thumbnail cropped from the original image available at the call of a method. With variants, there is some serious hidden logic here, possibly some bottlenecks if performance is a major concern, but for the most part you can treat the variant like you would any other attachment.

What goes on behind the scene when you call processed on a variant is very sneaky, very clever, but very well done. Whether you are storing your files locally or in the cloud, a variant directory is created (if it does not already exist) and inside a subdirectory for the specific attachment is created (think /storage/{2 letter token}/{2 letter token}/{image_hash}/ the two 2 letter token subdirectories appear to only happen with the local disk service). This inner directory will contain the blob files for all the variants of the same upload. This making it very easy to find all the variants for the same file in one location.

The most important part, however, is that through the magic of hashing, if the variant was previously created and stored, a transformation will not be attempted a second time. This saves time and resources by serving up the blob that was stored the first time.

And yes, when you delete the original attachment, or replace it with a new image, ALL variants are erased as well.

How about that? Magic that works!

On performance, if you are doing some complex transformations that might have a large resource footprint, you will probably want to do something asynchronous such as explicitly execute processed in an ActiveJob background process. If you don’t care, you can omit the `processed` method call from your code entirely and the first attempt to load the variant URL will implicitly create the blob. Whichever way you write this, the image will eventually end up generated and it will do it with minimal hassle.

One other thought on transformations: if you are using direct uploads, your app will use bandwidth to download the original image from the cloud provider then when finished to upload the variant. I do not know (and should probably look into) if you can intercept a file during the save process to perform transformations before it is sent to the final destination. Considering there are no validation callbacks, I do not expect this to be possible, yet, but I could be wrong and just have not found the right code to accomplish the tricks.

Internal Attachment

One thing that is not obvious yet is how to attach files manually from within the application instead of from an uploaded file.

One of the first attempts I made with thumbnails prior to figuring out that Variants automagically do everything I needed was, in overly-simplified code:

class Profile < ApplicationRecord
has_one_attached :avatar
has_one_attached :thumbnail
after_save :set_thumbnail
privatedef set_thumbnail
if avatar.attached?
thumbnail.attach(avatar.variant(crop: …, resize: …))
end
end
end

Not only does this approach fail, it fails to use ActiveStorage correctly; this feature is built a bit more intelligently than that. What ActiveStorage does when it creates (and saves) a variant, is it hashes and creates a unique key for each variant and stores those such that each subsequent call to variant will capture the previously stored image! Wooo! Mind blown! And if you purge the original file, all variants will implicitly be removed as well. I restate this because having all these extra files floating around was a worry for me at one point. This is great garbage collection and the kind of architecture I have come to respect, love, and am thrilled to work with — software that comes from Basecamp is amazing stuff.

If you do need to attach a file from a source other than a form submit, you can do so manually as shown in the documentation found here. A quick snippet:

avatar.attach(io: File.open(“/path/to/face.jpg”),
filename: “face.jpg”,
content_type: “image/jpg”)

If I recall correctly, you can use the `io` parameter for in-memory variants, but since I did not get it working with my thumbnail feature, don’t quote me on that.

Deleting

Deleting is relatively simple. The documentation is straight forward. If you can delete immediately, run purge on the attachment. If you need to throw it to a background process because of performance considerations, use purge_later.

As for working from the frontend, just use the normal Rails way. Use a checkbox or a hidden field that comes back with the form and performs a delete if the “delete” field is positive.

Afterthoughts

After all this spelunking in the guts of ActiveStorage, there is perhaps one thing that stands out: the lack of named variants. This would be the ability to tag a variant as a “thumbnail” or a “banner” or whatever label you want to apply to it, and when the end user saves the model, this named variant is replaced in the file storage, not just added into the mix. Right now, with the way my profile avatar is written, if an end user were to sit there and move the crop, save, move it again, save, etc. there will be numerous extra variant files that will never be loaded again. I have not found any methods for cleanly getting the existing variants or deleting them out other than manually tracing the file structure (which gets difficult when the files are not stored locally).

Until then, happy coding.

Disclaimer

I don’t normally publish articles on code because I would rather not contribute noise where others are much better authors or where there is plenty of existing source material. However, in the case of ActiveStorage, there has been such a dearth of useful information for this amazing Rails feature that I am sharing these findings and doing so now even with Rails still in RC1 (as of this writing). This is meant to be a quick dump of useful information, not a full tutorial. Please generously forgive any shortcoming in the writing style.

--

--