When I started this blog, I knew embedding photos and a serverless file upload solution (likely S3) for screenshots and custom photos was a feature I definitely needed. And two months ago at the Boulder Ruby Meetup, Kyle Brackman gave an excellent talk on using S3 in Rails, which piqued my interest and pointed me in the right direction.

In this post, I'll cover:

  1. Why Serverless, why S3?
  2. Design
  3. Implementation

Why Serverless, Why S3?


Serverless means you, the developer, don't have to worry about server management. It's a pay-as-you-go cloud service, so you can focus on the fun stuff, like app building. While my small blog uploads could be hosted server-side, I thought implementing S3 would be a great learning opportunity to gain hands-on experience with an industry-standard tool and avoid server-side rendering for static content.

S3 is surprisingly simple to integrate with Rails, even for small apps. Again, while server-side rendering would work well enough for my low-scale blog, solutions like S3 become essential when scaling to millions of users with big-boy companies like YouTube or Twitter.

🔦 Side note: The growing Anti Cloud movement in late 2024 is fascinating and I understand that there are reasons to not lock into S3. Still, the value of gaining experience with widely used tools like S3 can't be overstated.

Design


The way I designed this blog was without a WYSIWYG editor. I do almost all my writing in Notion and implementing a WYSIWYG editor in Rails didn’t feel as rewarding. I wanted to go deeper, ya know? Also, I did consider ActionText, but it doesn't appear to support code blocks but rather rich text content (a lighter-weight MD solution) based on the Trix editor. And even though there were some other JavaScript editors that did, they weren’t well maintained. The last thing I wanted to do was get to this point in the build, have some abstracted problem with the editor, check the repo, and see an untouched open PR or Issue about it from a year ago. Plus, Rails has a wonderful Markdown to HTML renderer called Redcarpet that I will be hyping up later in this post!

👨‍💻 Current Davis: I just googled JS WYSIWYG editors and found one called called Quill that looks pretty awesome. I still like the current design choice, it’s taught me a lot. But perhaps if I migrate the Frontend to a separate JS app, I’ll give Quill a shot!

The final decision for the design was settled, post.content would be a simple MD string that I would render through a custom Redcarpet/Rouge method. I will talk about wrapping Rouge into Redcarpet in another post and using it for code block syntax highlighting. It's awesome! Below is a snapshot of what the post.content string looks like.

aws-s3-post-content.png

Back to photo embedding! In Redcarpet, photos can be embedded with standard MD syntax: ![Alt text here](<https://www.example-picture.com/example>) and uploaded through ActiveStorage to S3! This takes some basic setup and if you’re curious about that, check out this blog post where I walk through the process.

The Problem

For images already hosted somewhere on the web, Redcarpet has got it covered. For the images I want to upload for Posts, I had an interesting problem to solve. The Post create/edit text area is just a plain text area and the Post content is just a large MD string. This means I don’t have the S3 URL until that upload takes place.

So how do I reference a photo not immediately uploaded to S3 without a URL..?

idk

The Solution

As long as I keep file names unique, I can use them as placeholders in my posts like this: ![its-my-photo-1.png]! But we still need to give S3 time to upload AND somehow replace the placeholder with the URL for that process. So by extending Redcarpet (something they encourage you to do), we can clear this hurdle!

Typically when you haven’t extended Redcarpet, you just call it’s markdown method in your view and pass it the Markdown content like this: <%= markdown(content) %> but in order to extract the filename from an otherwise lengthy S3 URL, we can extend the markdown render method with this custom banger:

app/helpers/application_helper.rb

ruby
def markdown(content)
  renderer = HTMLWithRouge.new(hard_wrap: true)
  options = {
    fenced_code_blocks: true,
    tables: true,
    autolink: true,
    strikethrough: true,
    footnotes: true,
    superscript: true,
    underline: true,
    highlight: true,
    quote: true
 }
  Redcarpet::Markdown.new(renderer, options).render(content).html_safe
end

def render_post_content(post)
  text = post.content.dup

 post.images.each do |image|
    filename = image.blob.filename.to_s
    pattern = /!\\[#{Regexp.escape(filename)}\\]/i
    if text.match?(pattern)
      replacement = %(
      <div class="post-content-image-wrapper">
        <img
        src="#{url_for(image)}"
        alt="#{filename}"
        class="post-content-image"
        data-action="click->lightbox#open"
        data-controller="lightbox"
        />
      </div>
      ).strip
      text.gsub!(pattern, replacement)
    end
  end

  markdown(text)
end

We need to replace placeholders in the post.content with the corresponding S3 URL formatted as an <img>.

  1. Firstly, we dupe the actual post.content to make sure we don’t mutate the original data.
  2. Then for each associated image, we get the filename
  3. Define a Regex pattern to match our placeholder ![filename]
  4. Sub it for a custom HTML block containing the <img> block with the S3 URL
  5. Finally, after all that, we render the enter markdown content string and can call it in the view with: <%= render_post_content(@post) %>!

(+) Add Photos

Within the posts form, we can use methods within ActiveStorage for file uploading. Rails makes this very easy per usual!

app/views/admin/posts/_form.html.erb

erb
 <%= form.label :images, "Upload Images", class: "add-post-form__label" %>
 <%= form.file_field :images, multiple: true, direct_upload: true, class: "add-post-form__file-upload-input" %>

The most important line here is the form.file_field which directs uploads to the post.images attribute. Also, notable:

  • multiple: true: Allows the user to select and upload multiple files at once.
  • direct_upload: true: Integrates with ActiveStorage direct upload feature, enabling files to be uploaded directly to S3 without passing through the Rails server!
aws-s3-file-upload-field.png

Controller logic is relatively indifferent from other creation logic

app/controllers/admin/posts_controller.rb

ruby
def create
  @post = Post.new(post_params)
  @post.user = current_user

  if @post.save
    redirect_to admin_posts_path, notice: "Post created."
  else
    render :new, status: :unprocessable_entity
  end
end

And finally within the model, all we have is one simple line!

app/models/post.rb

ruby
has_many_attached :images, dependent: :purge_later

Notably, dependent: :purge_later is a callback similar to dependent: :destroy. However, it does photo removal in the background rather than immediately.

(-) Delete Photos

Now when it comes to removing photos, initially I hadn’t considered the behavior of resubmitting a post form for updating when there are photos ALREADY uploaded. It’s a rather large oversight on my part, can you spot the issue??

app/controllers/admin/posts_controller.rb

ruby
def update
  @post.assign_attributes(post_params) # 👀 Do you see it?!

  if @post.save
    redirect_to admin_posts_path, notice: "Post updated."
  else
    render :edit, status: :unprocessable_entity
  end
end

Nice, you’re correct! We are assigning ALL attributes including :images which will overwrite the current images at @post.save with a blank array effectively deleting any photos we had already uploaded! Oop..

So to fix this, we skip assigning the :images param directly and then check two things.

app/controllers/admin/posts_controller.rb

ruby
def update
  @post.assign_attributes(post_params.except(:images))

  if post_params[:images].present?
    @post.images.attach(post_params[:images])
  end

  if params[:remove_images].present?
    params[:remove_images].each do |blob_id|
      attachment = @post.images.attachments.find_by(blob_id: blob_id)
      attachment&.purge
    end
  end

  if @post.save
    redirect_to admin_posts_path, notice: "Post updated."
  else
    render :edit, status: :unprocessable_entity
  end
end
  1. post_params[:images].present?: Checks and attaches new :images. @post.images.attach is not a clobber action and will append to the existing array. Nice!
  2. params[:remove_images].present?: Checks and removes :images in the :remove_images array with the .purge method. We will get to how this is implemented next!

Let’s get to viewing and removing photos already attached to a post when editing. This is where the params[:remove_images] array will come from. We add an additional field to the form with a basic conditional to display only if images exist.

app/views/admin/posts/_form.html.erb

erb
<% if @post.images.attached? %>
  <div class="add-post-form__field add-post-form__existing-images">
    <%= form.label :images, "Existing Images", class: "add-post-form__label" %>
    <% @post.images.each do |img| %>
      <div class="existing-image-wrapper">
        <%= image_tag img, width: 100 %>
        <span class="add-post-form__checkbox-label"><%= img.blob.filename.to_s %></span>
        <label class="add-post-form__checkbox-item">
          <%= check_box_tag "remove_images[]", img.blob.id, false, class: "add-post-form__checkbox" %>
          <span class="add-post-form__checkbox-label">Remove?</span>
        </label>
      </div>
    <% end %>
  </div>
<% end %>
aws-s3-existing-files-field.png

We iterate through all the @post.images displaying all attached images at a fixed 100px width with a checkbox to add ‘em to the params[:remove_images] array and delete them on @post.save if we want! Oh and of course we also need to remove or update the placeholder in the editor too.

💫 That’s it! ⭐️

Closing Thoughts


Building this blog has been a rewarding learning experience, especially implementing S3 for serverless file uploads. It reinforced why I love Rails so much, if you think it’s possible to build in Rails, it likely is!

I hope this post provided some helpful insights or inspiration. A comment section for feedback is on my to-do list, but until then, thanks for reading, and happy coding/uploading!

bye

Thanks for reading, remember to commit early and often! 🐒