In this blog post, we will discuss how to work with file uploads in a Ruby on Rails application using Active Storage.
Active Storage provides a simple way to upload files to cloud services like Amazon S3, Google Cloud Storage, etc. ActiveRecord models don't need to be modified with additional columns to associate with files. Active Storage represents files that are attached to ActiveRecord objects via ActiveStorage::Blob and ActiveStorage::Attachment.
ActiveStorage::Blob is a model of the metadata of an uploaded file, like the filename, content-type, and the URL of the actual file in the cloud service. It doesn't contain the binary data of the uploaded file.
ActiveStorage::Attachment is a join model that links the blob to the actual ActiveRecord model like a User, or a Post, etc.
In the database, these two models are represented by the following tables:
active_storage_blobs
active_storage_attachments
By inspecting the table, we can see that active_storage_attachments has a foreign key that references the id of active_storage_blobs.
Active Storage Field Management in ActiveRecord Models
In my Rails application, I have set up my Post ActiveRecord model to include an Active Storage field named photos :
# app/models/post.rb
class Post < ApplicationRecord
belongs_to :user
has_many_attached :photos
end
The has_many_attached :photos indicates that a single Post record can have multiple attached photos, each represented as an instance of ActiveStorage::Attachment.
Handling File Uploads Directly with ActiveRecord model
Given a Post record which already exists in the database, we can easily add or remove photos from it by calling post.photos.attach() and post.photos.purge(). This is quite straight forward, and so I would just skip elaborating about this.
Handling File Uploads with ActiveStorage::Blob
But what if we want to allow file upload before the Post record exists in the database? This is useful in a scenario where we want users to be able to upload files before the creation of a new Post. In the following sections, I would like to share my sample code which handles file uploads both before and after a Post creation.
Firstly, here's the Stimulus.js controller, which handles the photos upload and removal via HTTP request:
// app/javascript/controllers/upload_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static values = { postId: Number };
static targets = [
"filesInput",
"fileItem",
"filesContainer",
];
uploadFile(event) {
event.preventDefault();
const filesInput = this.filesInputTarget;
let files = Array.from(filesInput.files);
let formData = new FormData();
if (this.hasPostIdValue) {
formData.set("post_id", this.postIdValue);
}
files.forEach((file) => {
formData.append("photos[]", file);
});
fetch("/uploads", {
method: "POST",
body: formData,
headers: {
"X-CSRF-Token": document
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content"),
},
})
.then((response) => {
return response.json();
})
.then((data) => {
if (data.result === "success") {
this.filesContainerTarget.innerHTML += data.html;
filesInput.value = "";
}
});
}
removeFile(event) {
event.preventDefault();
const signedId = button.dataset.signedId;
fetch(`/uploads/${signedId}`, {
method: "DELETE",
headers: {
"X-CSRF-Token": document
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content"),
},
})
.then((response) => {
return response.json();
})
.then((data) => {
if (data.result === "success") {
const targetToRemove = this.fileItemTargets.find(
(t) => t.dataset.signedId === signedId
);
if (targetToRemove) {
targetToRemove.remove();
}
}
})
}
}
In the Rails app, here's the UploadsController which handles the file upload and removal:
# app/controllers/uploads_controller.rb
class UploadsController < ApplicationController
def create
photos = params.require(:photos)
post_id = params[:post_id]
blobs = []
photos.each do |photo|
blob =
ActiveStorage::Blob.create_and_upload!(
io: photo,
filename: photo.original_filename,
content_type: photo.content_type,
)
if post_id.present?
post = Post.find(post_id)
post.photos.attach(blob.signed_id)
end
blobs.push(blob)
end
html_content =
render_to_string(
partial: "posts/photos_list",
locals: {
blobs: blobs,
hidden_input: true
},
)
render json: { result: "success", html: html_content }
end
def destroy
# Fetch the blob using the signed id
blob = ActiveStorage::Blob.find_signed(params[:id])
if blob
if blob.attachments.any?
# the blob is attached to post record
blob.attachments.each { |attachment| attachment.purge }
else
blob.purge
end
render json: { result: "success" }
else
# blob not found
head :unprocessable_entity
end
end
private
end
Here's the PostsController which handle the CRUD of the Post model:
class PostsController < ApplicationController
before_action :authenticate_user!
before_action :set_post, only: %i[ show edit update destroy ]
# GET /posts or /posts.json
def index
@posts = current_user.posts.all
end
# GET /posts/1 or /posts/1.json
def show
end
# GET /posts/new
def new
@post = current_user.posts.new
end
# GET /posts/1/edit
def edit
end
# POST /posts or /posts.json
def create
file_signed_ids = params[:post].delete(:photo_signed_ids)
@post = current_user.posts.new(post_params)
respond_to do |format|
if @post.save
# for photos uploaded, attach the blob's signed_id to post record
attach_files(file_signed_ids)
format.html { redirect_to post_url(@post), notice: "Post was successfully created." }
format.json { render :show, status: :created, location: @post }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @post.errors, status: :unprocessable_entity }
end
end
end
# PATCH/PUT /posts/1 or /posts/1.json
def update
respond_to do |format|
if @post.update(post_params)
format.html { redirect_to post_url(@post), notice: "Post was successfully updated." }
format.json { render :show, status: :ok, location: @post }
else
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @post.errors, status: :unprocessable_entity }
end
end
end
# DELETE /posts/1 or /posts/1.json
def destroy
@post.destroy
respond_to do |format|
format.html { redirect_to posts_url, notice: "Post was successfully destroyed." }
format.json { head :no_content }
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_post
@post = current_user.posts.find(params[:id])
end
# Only allow a list of trusted parameters through.
def post_params
# params.fetch(:post, {})
params.require(:post).permit(:message, photos: [])
end
def attach_files(file_signed_ids)
if file_signed_ids.present?
file_signed_ids.each do |signed_id|
@post.photos.attach(signed_id)
end
end
end
end
Here's the erb templates used for the form UI:
# app/views/posts/_form.html.erb
<%= form_with(model: post, class: "contents") do |form| %>
<div class="mt-10">
<%= form.label :message %>
<%= form.text_field :message %>
</div>
<div
class="my-10"
data-controller="upload"
<% if post.persisted? %>
data-upload-post-id-value="<%= post.id %>"
<% end %>
>
<div>
<%= form.label :photos %>
<div data-upload-target="filesContainer">
<%
photos = post.persisted? ? post.photos : []
blobs = photos.map { |photo| photo.blob }
%>
<% if blobs.present? %>
<%= render partial: "posts/photos_list",
locals: {
blobs: blobs,
hidden_input: false
} %>
<% end %>
</div>
</div>
<div class="w-full block">
<%= file_field_tag "photos[]",
multiple: true,
id: "photos",
data: {
upload_target: "filesInput"
} %>
<button
class="btn btn-primary py-1 px-2"
data-action="click->upload#uploadFile"
>
<span class="button-span flex items-center justify-center">
upload
</span>
</button>
</div>
</div>
<div class="inline">
<%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
</div>
<% end %>
# app/views/posts/_photos_list.html.erb
<% blobs.each do |blob| %>
<% signed_id = blob.signed_id
display_name = blob.filename.to_s
blob_path = rails_blob_path(blob, disposition: "attachment") %>
<div
id="<%= display_name %>"
class="my-2"
data-signed-id="<%= signed_id %>"
data-upload-target="fileItem"
>
<%= link_to display_name,
blob_path,
title: display_name,
class: "no-underline hover:underline" %>
<% if hidden_input %>
<input
type="hidden"
name="post[photo_signed_ids][]"
value="<%= signed_id %>"
>
<% end %>
<button
id="remove-button"
class="btn btn-primary py-1 px-2 ml-5"
data-action="click->upload#removeFile"
data-signed-id="<%= signed_id %>"
>
<span class="button-span w-5 h-5 flex items-center justify-center">X</span>
</button>
</div>
<% end %>
Handling Photos Upload and Removal before the creation of a new Post
In the new Post creation form, user is able to upload and remove photos. Here's some screenshots of the new Post creation form:
When the form is freshly loaded:
When user has filled in the message and uploaded some photos:
After user clicking the Create Post button, the new Post is created:
Uploading photos before the creation of a new Post
Let's start with handling files upload.
Before creating the Post, user can select multiple photos for upload. When the upload button is clicked, this action is handled by upload_controller.js in uploadFile():
// app/javascript/controllers/upload_controller.js
export default class extends Controller {
...
uploadFile(event) {
...
const filesInput = this.filesInputTarget;
let files = Array.from(filesInput.files);
let formData = new FormData();
...
files.forEach((file) => {
formData.append("photos[]", file);
});
fetch("/uploads", {
method: "POST",
body: formData,
...
})
.then((response) => {
return response.json();
})
.then((data) => {
if (data.result === "success") {
this.filesContainerTarget.innerHTML += data.html;
filesInput.value = "";
}
});
}
It makes a HTTP POST request to the /uploads endpoint, where the selected photos are sent as photos[] param.
In the Rails app, the above HTTP request is handled by create action in the UploadsController:
# app/controllers/uploads_controller.rb
class UploadsController < ApplicationController
def create
photos = params.require(:photos)
...
blobs = []
photos.each do |photo|
blob =
ActiveStorage::Blob.create_and_upload!(
io: photo,
filename: photo.original_filename,
content_type: photo.content_type,
)
...
blobs.push(blob)
end
html_content =
render_to_string(
partial: "posts/photos_list",
locals: {
blobs: blobs,
hidden_input: true
},
)
render json: { result: "success", html: html_content }
end
...
end
It gets an array of photos from the photos param, and uploads each of them via ActiveStorage::Blob.create_and_upload!().
The uploaded blobs are stored in the blobs array, which is passed into the app/views/posts/_photos_list.html.erb partial. For each of the blob, the partial produces a html snippet similar to the below:
<div ... data-signed-id="eyJfcmFpb..." data-upload-target="fileItem">
<a title="cat-2.jpg" ... href="/rails/active_storage/blobs/redirect/eyJfcmFpb.../cat-2.jpg?disposition=attachment">cat-2.jpg</a>
<input type="hidden" name="post[photo_signed_ids][]" value="eyJfcmFpb...">
<button ... data-action="click->upload#removeFile" data-signed-id="eyJfcmFpb...">
<span ...>X</span>
</button>
</div>
These html contents are returned to the caller, which is upload_controller.js. The contents are appended into the filesContainer section of the form. So in the form, we can see that these UI elements are shown for each of the uploaded file:
- The file name with a downloadable link
- A X remove button, which allows user to remove the photo
- The photo's signed id as a hidden input field
Upon clicking the Create Post button, the Post creation is handled here:
class PostsController < ApplicationController
...
def create
file_signed_ids = params[:post].delete(:photo_signed_ids)
@post = current_user.posts.new(post_params)
respond_to do |format|
if @post.save
# for photos uploaded, attach the blob's signed_id to post record
attach_files(file_signed_ids)
format.html { redirect_to post_url(@post), notice: "Post was successfully created." }
...
else
format.html { render :new, status: :unprocessable_entity }
...
end
end
end
private
...
def attach_files(file_signed_ids)
if file_signed_ids.present?
file_signed_ids.each do |signed_id|
@post.photos.attach(signed_id)
end
end
end
end
It gets an array of blob's signed ids from the photo_signed_ids params. These are the hidden input fields which represent the uploaded blobs's signed id.
After creating the new post via @post.save, it calls attach_files(file_signed_ids) to attach each of the signed ids to the Post.
So we have covered how to support photos upload before a Post creation, and then attach the uploaded blobs to the Post after the model creation.
Removing photos before the creation of a new Post
Next let's dwell into the file removal.
In the form, user is able to click on the X button to remove a particular photo they have uploaded. This action is handled by upload_controller.js in removeFile():
// app/javascript/controllers/upload_controller.js
export default class extends Controller {
...
removeFile(event) {
...
const signedId = button.dataset.signedId;
fetch(`/uploads/${signedId}`, {
method: "DELETE",
headers: {
"X-CSRF-Token": document
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content"),
},
})
.then((response) => {
return response.json();
})
.then((data) => {
if (data.result === "success") {
const targetToRemove = this.fileItemTargets.find(
(t) => t.dataset.signedId === signedId
);
if (targetToRemove) {
targetToRemove.remove();
}
}
})
}
}
It makes a HTTP DELETE request to the /uploads endpoint, passing in the blob's signed id.
In the Rails app, the above HTTP request is handled by destroy action in the UploadsController:
# app/controllers/uploads_controller.rb
class UploadsController < ApplicationController
...
def destroy
# Fetch the blob using the signed id
blob = ActiveStorage::Blob.find_signed(params[:id])
if blob
if blob.attachments.any?
# the blob is attached to post record
blob.attachments.each { |attachment| attachment.purge }
else
blob.purge
end
render json: { result: "success" }
else
# blob not found
head :unprocessable_entity
end
end
private
end
In this context, blob.attachments.any? returns false, as the blob is not attached to any Post yet. It then calls blob.purge and remove the blob.
When the success response is received in upload_controller.js, it removes the photo's html element from the form UI:
if (data.result === "success") {
const targetToRemove = this.fileItemTargets.find(
(t) => t.dataset.signedId === signedId
);
if (targetToRemove) {
targetToRemove.remove();
}
}
Handling Photos Upload and Removal for existing Post
For existing Post, in the edit form, user is also able to upload and remove photos. Here's a screenshot of the edit Post form:
Uploading photos for existing Post
To support this scenario, we would need to make some modification to the file upload mechanism described above. In the erb template for the form UI:
# app/views/posts/_form.html.erb
<%= form_with(model: post, class: "contents") do |form| %>
...
<div
...
data-controller="upload"
<% if post.persisted? %>
data-upload-post-id-value="<%= post.id %>"
<% end %>
>
<div>
<%= form.label :photos %>
<div data-upload-target="filesContainer">
<%
photos = post.persisted? ? post.photos : []
blobs = photos.map { |photo| photo.blob }
%>
<% if blobs.present? %>
<%= render partial: "posts/photos_list",
locals: {
blobs: blobs,
hidden_input: false
} %>
<% end %>
</div>
</div>
...
</div>
<div ...>
<%= form.submit ... %>
</div>
<% end %>
For existing Post, post.persisted? returns true. It then adds a data attribute data-upload-post-id-value="<%= post.id %>" to the form, which represents the Post record's id.
The div filesContainer is used to list the existing photos of a Post. In here, it gets the photos list by calling post.photos and turns that into a blobs array by calling blobs = photos.map { |photo| photo.blob }. The blobs array is passed into the posts/photos_list partial to list each of the photo in the form UI.
In the edit Post form, when user adds more photos and click on the upload button, the action is again handled by upload_controller.js in uploadFile():
// app/javascript/controllers/upload_controller.js
export default class extends Controller {
static values = { postId: Number };
...
uploadFile(event) {
...
let formData = new FormData();
if (this.hasPostIdValue) {
formData.set("post_id", this.postIdValue);
}
files.forEach((file) => {
formData.append("photos[]", file);
});
fetch("/uploads", {
...
})
...
}
...
}
Now the Post's id is set in the formData via formData.set("post_id", this.postIdValue).
In Rails, this request is then handled by UploadsController in create:
# app/controllers/uploads_controller.rb
class UploadsController < ApplicationController
def create
...
post_id = params[:post_id]
...
photos.each do |photo|
blob = ActiveStorage::Blob.create_and_upload!(...)
if post_id.present?
post = Post.find(post_id)
post.photos.attach(blob.signed_id)
end
...
end
...
end
...
end
After the photo is uploaded as blob, it checks whether post_id.present?. This returns true now, because for an existing Post, the Post's id is set as a data attribute in the previous erb template. It then find the Post by id and attach the blob to it by calling post.photos.attach(blob.signed_id).
So now the photo blobs are successfully uploaded and attached to the Post.
Removing photos for existing Post
Next we would look into how to handle photos removal for an existing Post.
# app/controllers/uploads_controller.rb
class UploadsController < ApplicationController
...
def destroy
# Fetch the blob using the signed id
blob = ActiveStorage::Blob.find_signed(params[:id])
if blob
if blob.attachments.any?
# the blob is attached to post record
blob.attachments.each { |attachment| attachment.purge }
else
...
end
render json: { result: "success" }
else
# blob not found
head :unprocessable_entity
end
end
...
end
The HTTP delete request is handled by UploadsController in destroy action. Since the blob is now attached to a Post, blob.attachments.any? returns true. It calls blob.attachments.each { |attachment| attachment.purge }, which removes the attachment from the Post, and then the blob itself is purged automatically.
So now the photo is successfully removed from the Post and also deleted from the cloud service.
Conclusion
In conclusion, we have looked into how to handle files upload and removal via ActiveStorage::Blob, and it covers scenarios both before and after a model creation. I hope this walkthrough has been helpful for understanding the ins and outs of file uploads with Active Storage. Happy coding!