Ruby on Rails: Unusual ActionText stuff.
Pexels/Irina Iriser

Ruby on Rails: Unusual ActionText stuff.

ActionText acts as a bridge, seamlessly integrating Trix, a WYSIWYG text editor, with ActiveStorage, Rails' solution for file uploads. This integration is crucial for developers looking to enhance text editing capabilities in their Rails applications. I ended up using this component extensively in a project, and due to business needs, I had to customize much of it. In this article, I will detail what was done, and I hope it serves not only as a reference for me in future projects but also for others who use this component.

ActionText and Multitenancy

The project was a software-as-a-service (SaaS) platform designed to support multiple tenants, meaning it could serve different customers with a single application instance. The challenge here was to reconcile the use of the Apartment gem with ActionText, especially with its ActiveStorage part.

Think of ActiveStorage as a library system. The library's catalog, which lives in a database table, contains metadata about the books (attachments), while the books themselves are stored on shelves (like an S3 bucket or Azure blob container).

Apartment has more than one way to deal with tenants. When using PostgreSQL, we can separate tenants by schemas in the database. You can understand Schemas as directories of the database, and the data stored in these directories are isolated from data in other schemas.

Apartment simplifies accessing these schemas. It does this by altering the database's search path to point directly to the needed table within a schema. For instance, if we have a tenant called dev_tenant, and a table called blobs, to access the records of this specific table, Apartment configures the search path in Postgres to dev_tenant.blobs. Another interesting thing to note here is that the tables are replicated for all schemas, so this blobs table exists in all tenant schemas and also in Postgres' public schema.

Here's where the problem arose. There are different options to configure the active tenant in Apartment, and we do this through the gem's Elevators. The method used in this project was through a parameter in the URL path. Some parts of the ActiveStorage controller code could not find this parameter, hence the tenant was not configured. This happened because Rails was not initially designed to be multitenant as Apartment was designed, so the standard code does not take into account the specific situation of each project.

The result was that whenever the application needed to find the data of an ActionText attachment, whether to render the text as HTML or to render thumbnails at the time of editing the text in Trix, it looked for the attachment's metadata in the ActiveStorage table that existed in the public schema, but the metadata was saved in the ActiveStorage table of a specific tenant's schema, and thus the blob was not found. Thumbnails were broken, attachments seemed lost, and I ended up with more gray hair.

The solution involved two key steps:

  1. Understanding the interaction between Apartment and ActiveStorage;
  2. Overriding the default ActiveStorage controller to ensure the correct tenant was accessed.

What happened at the codebase level was that I created the file app/controllers/active_storage/blobs_controller.rb with a few modifications for the set_blob method:

class ActiveStorage::BlobsController < ActiveStorage::BaseController
  include ApplicationHelper
  before_action :set_blob
  
# ...

  private

  def set_blob
    switch_tenant(request)
    @blob = ActiveStorage::Blob.find_signed(params[:signed_blob_id] || params[:signed_id])
  rescue ActiveSupport::MessageVerifier::InvalidSignature
    flash[:error] = I18n.t('helpers.link.blob.not_found')
  end
end
        

Upload and download of blobs via app

Another problem that surfaced was a restriction to access the blobs directly from any browser, and also to upload to the blob storage container from anywhere. To meet a compliance requirement, the app's blob storage could only be accessed from the same network it was located on, which would require going through the Kubernetes pod that served the application. This raised some problems with the default behavior of Trix.

Trix does what it calls a "direct upload" when a file is added to the editor's content. This means that the file is processed by the Trix JS library and sent to where it will be stored directly from the browser. Since this would not be possible, I would need to send the file to the application, and the application would have to be responsible for uploading that file to storage.

This issue was also resolved by overriding ActionText functionality, more specifically by changing the URL of the direct upload to a URL of the application itself:

def direct_upload_json(blob)
  blob.as_json(root: false, methods: :signed_id)
  .merge(direct_upload: { 
url: upload_to_azure_url(signed_id: blob.signed_id),
headers: blob.service_headers_for_direct_upload.merge("X-CSRF-Token": session[:_csrf_token])                                                       })        

I wrote a piece of code in the app/controllers/active_storage/blobs_controller.rb to receive the file from Trix, adjust the metadata, and upload it to storage. It ended up looking like this:

def upload_to_azure
  # ...  
  content = request.body.read
  @client = Azure::Storage::Blob::BlobService.create(
    storage_account_name: ENV['AZURE_STORAGE_ACCOUNT_NAME'],
    storage_access_key: ENV['AZURE_STORAGE_ACCOUNT_KEY']
  )

  @client.create_block_blob(container, @blob.key, content, {
   :content_type => @blob.content_type }
  ) if @blob
  render json: I18n.t('helpers.message.blob.created'), status: :ok
rescue Azure::Core::Http::HTTPError => e
  Rails.logger.error(e)
  render json: I18n.t('helpers.message.blob.error_when_creating'),
         status: :internal_server_error
end        

For the download, we did something similar. Instead of placing a direct link to the stored blob in the HTML of the blob's partial, we went through the application to download the blob from there. We did this through a form with the information we needed in hidden inputs. The form was created by JavaScript on top of this HTML:

<div class="d-flex flex-column justify-content-center">
  <p><%= blob.filename %></p>
  <p>
    <span><%= number_to_human_size(blob.byte_size) %> - </span>
    <span class="add-download-form">
      <span class="download_label">
        <%= I18n.t('helpers.link.blob.download') %>
      </span>
      <span class="blob_id"><%= blob.id.to_s %></span>
      <span class="form_action"><%= download_action %></span>
    </span>
  </p>
</div>        

The JS looked like this:

const spansForForm = document.querySelectorAll('.add-download-form');

    if(spansForForm.length > 0) {
        spansForForm.forEach((item) => {
            var form = document.createElement('form');
            form.setAttribute('id', 'download_file');
            form.setAttribute('method', 'post');

            var action = item.querySelector('.form_action');
            form.setAttribute('action', action.innerHTML);
            action.remove();

            // Append various form attributes
            
            var submitInput = document.createElement('input');
            submitInput.setAttribute('type', 'submit');
            var download_label = item.querySelector('.download_label')
            submitInput.setAttribute('value', download_label.innerHTML);
            download_label.remove();
            form.append(submitInput);
            item.append(form);
        })
    }        

When the user submits the form to the backend, we identify the blob, download it on the server, and send it back to the front using the controllers' send_data method:

def show
  expires_in ActiveStorage.service_urls_expire_in
  send_data(@blob.download, :filename => @blob.filename.to_s)
end        

An interesting addendum to app-based download

In addition to the attachments sent by users in all fields that used ActionText, we also stored the images used on various views of the application in the same place as these blobs. This implied needing to download these images on the server as well, as we couldn't simply use the blob's URL in Azure as src in the img tags.

But, how could we download this asset to a server folder and serve it directly (that is, without needing to process the request in the application) without the file existing in the first place? We would have 404 errors all over.

We could download all the images to this folder beforehand, but these files could be modified by the product's admin users in the blob container, and then we would have different files until the temporary folder was updated with the new images.

We could try to generate dynamic links from a temporary folder, create an action in some controller that would be responsible for handling these demands, and change the Rails server settings to serve these assets directly when they were requested and already existed, but surely it would not be as easy as the other solution we found.

This solution was to download the assets and convert them into Base64 encoded strings to use as the source of the images. This processing occurs along with the rendering of the templates and layouts of the pages just like any other server-side rendered application built with Rails, and it was not as difficult to implement as the other ideas that came up. The code ended up looking like this :

@variant = @blob_image_on_azure.variant(resize_to_limit: [nil, 48])
@encoded_variant = Base64.encode64(@variant.blob.download)        

And on the view:

image_tag("data:#{@variant.content_type};base64,#{@encoded_variant}")        

Expired Signed Global ID

We needed to fill in an ActionText rich text field for a specific model programatically.

At the moment of implementing this feature, I ended up discovering that the ActionText attachments were not stored completely as records in an ActiveStorage table, but rather as a set of special HTML tags within the text generated in Trix and in the ActiveStorage table.

These tags had the attachment's Signed Global ID as an attribute. When one of these ActionText fields needed to be rendered, the string was processed, these special tags were separated, and their Signed Global IDs read and used to find the records in the ActiveStorage tables. From there, the blob's partial was rendered with the data of the found record and inserted into the ActionText text.

What I did then was to create the ActiveStorage record before saving the Trix text, replicate the writing of the attachments in the pattern in which Trix wrote with these special tags, and then store the text with the reference to the ActiveStorage blob (Signed Global ID). This code looked like this (not proud of it, found a better approach later):

def trix_text(blob)
  "<div>#{I18n.t('activerecord.attributes.model.standard_text')}<br>
  <figure data-trix-attachment='{\"contentType\":\"#{blob.content_type}\",
  \"filename\":\"#{blob.filename}\",\"filesize\":#{blob.byte_size},
  \"sgid\":\"#{blob.to_sgid(for: "attachable")}\",
  \"url\":\"#{root_url}rails/active_storage/blobs/#{blob.signed_id}/#{blob.filename}\"}'></figure></div>"
end        

What I didn't know at the time was that in this way, programmatically, I needed to specify that the Signed Global ID should not expire by setting:

blob.to_sgid(expires_in: nil, for: "attachable")        

Apparently, Trix solves this by itself when an attachment is uploaded through it, but since programatically it didn't go through Trix, the Signed Global IDs were generated with the default configuration to expire after 30 days.

After 1 month in production, all attachments disappeared. They were not deleted, but they were not found because the identifier had expired. Imagine the shock! More than 14,000 records in the application disappearing overnight. Anyway, we discovered this problem, defined the solution, and executed it. I solved the problem for new records, and for the existing ones I created a rake task to replace the SGIDs. This time I was a bit more clever and used Nokogiri to help me manipulate the HTML:

def replace_sgid(model_with_action_text)
  # ...
  html = Nokogiri::HTML(model_with_action_text.body.to_html)
  html.css("action-text-attachment").each do |attachment|
    blob = ActiveStorage::Blob.find_signed(
      attachment.attributes["url"].value.split("/")[6]
    )
    unless blob.nil?
      attachment.attributes["sgid"].value = blob.to_sgid(
        expires_in: nil, for: "attachable"
      ).to_s
    end
  end
  # ...
end        

Lack of renderer

The last unusual thing I'm going to write about was the lack of a renderer for ActionText. The processing of the string saved as HTML in any model using ActionText is done by a Rails controller instance. If we check the code of the most recent version of ActionText on GitHub, we'll see that ActionText looks for a controller already defined in a variable, and if the variable is null, it creates a new instance of that controller. Well, in the version that the application was using of ActionText, this check did not exist, and if there was nothing in the variable, an error would simply burst and the text would not be rendered.

It took me a while to understand this problem, but thanks to this PR, I was able to define a solution. I decided to override the ActionText rendering class with code from the new version. This is the code that solved the problem:

# frozen_string_literal: true

require "active_support/concern"
require "active_support/core_ext/module/attribute_accessors_per_thread"

module ActionText
  module Rendering # :nodoc:
    extend ActiveSupport::Concern

    included do
      thread_cattr_accessor :renderer, instance_accessor: false
      delegate :render, to: :class
    end

    class_methods do
      def action_controller_renderer
        @action_controller_renderer ||= Class.new(ActionController::Base).renderer
      end

      def with_renderer(renderer)
        previous_renderer = self.renderer
        self.renderer = renderer
        yield
      ensure
        self.renderer = previous_renderer
      end

      def render(*args, &block)
        (renderer || action_controller_renderer).render_to_string(*args, &block)
      end
    end
  end
end
        

Would it be easier to upgrade the version of ActionText? If it were just to solve this specific problem, yes, but it was not possible to upgrade just ActionText.

I would have had to upgrade the Rails' version, and because of that, the version of practically all dependencies as well.

This had already caused me problems with a gem called OmniAuth before. As time and budget for solving the problem were short, I ended up pulling the newer code to the older version.

Final considerations

The challenges described here were intriguing and provided valuable lessons. They deepened my understanding of how Rails and ActionText function and offered a unique experience in problem-solving without the aid of advanced documentation or solutions from others who had faced similar issues.

I spent hours reading the ActionText code, testing things in the Rails console, and, thank God, I found a way to solve all the problems presented. I hope sharing these obstacles and learnings can serve someone besides me.

Thanks for reading!

P.S.: I will update this article some time in the future with code snippets.

P.S.2: I did update the article with code snippets.

William Pavei Antero

Senior Manager | Technical Lead, CTO, Solutions Architect | AI-900 | DP-900 | DP-100 | DP-203 | Workday Integration

11 个月

We've had a bundle of new gray hair in this challenge ??

要查看或添加评论,请登录

Lucas Vidal的更多文章

社区洞察

其他会员也浏览了