agentsclimarketplace

Rails activestorage

Skill mickzijdel/rails-toolkit/skills/rails-activestorage

Use when handling file uploads, variants, direct uploads, and rich text attachmentsFrom its SKILL.md

Install
npx -y skills add mickzijdel/rails-toolkit --skill rails-activestorage

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

11.5 KB, ~2.8k tokens by cl100k_base, as published. Nobody here has run it

Rails ActiveStorage Patterns

1. Attachments Concern — Rich Text Introspection

Access all attachments from a model's rich text content and identify remote images/videos embedded in ActionText:

# app/models/concerns/attachments.rb
module Attachments
  extend ActiveSupport::Concern

  # Variants used by ActionText embeds. Processed immediately on attachment to avoid
  # read replica issues (lazy variants would attempt writes on read replicas).
  #
  # Patched into ActionText::RichText in config/initializers/action_text.rb
  VARIANTS = {
    # The `n: -1` loader option bypasses GIF-incompatible intent filtering in
    # vipsthumbnail, preserving all frames in animated GIFs. Only `n` is accepted
    # as an override; the full parameter name `intent` doesn't work.
    small: { loader: { n: -1 }, resize_to_limit: [ 800, 600 ] },
    large: { loader: { n: -1 }, resize_to_limit: [ 1024, 768 ] }
  }

  def attachments
    rich_text_record&.embeds || []
  end

  def has_attachments?
    attachments.any?
  end

  def remote_images
    @remote_images ||= rich_text_record&.body&.attachables&.grep(ActionText::Attachables::RemoteImage) || []
  end

  def remote_videos
    @remote_videos ||= rich_text_record&.body&.attachables&.grep(ActionText::Attachables::RemoteVideo) || []
  end

  private
    def rich_text_record
      @rich_text_record ||= begin
        association = self.class.reflect_on_all_associations(:has_one).find { it.klass == ActionText::RichText }
        public_send(association.name)
      end
    end
end
class Card < ApplicationRecord
  include Attachments
  has_rich_text :description
end

2. Registering Variants on ActionText Embeds

Wire the VARIANTS above onto every rich-text embed by overriding the :embeds association:

# config/initializers/action_text.rb
module ActionText
  module Extensions
    module RichText
      extend ActiveSupport::Concern

      included do
        # This overrides the default :embeds association!
        has_many_attached :embeds do |attachable|
          ::Attachments::VARIANTS.each do |variant_name, variant_options|
            attachable.variant variant_name, **variant_options, process: :immediately
          end
        end
      end
    end
  end
end

ActiveSupport.on_load(:action_text_rich_text) do
  include ActionText::Extensions::RichText
end

process: :immediately matters: lazy variant processing attempts writes, which raises on read replicas.


3. Storage Tracking for Quotas

Track storage usage per account/board for billing or quotas with a ledger: record attach/detach events via attachment callbacks, materialize totals asynchronously.

Tracked models include a Storage::Tracked concern that defines the trackable-record API the callbacks below rely on: storage_tracked_record (returns self) and board_for_storage_tracking (returns board; override in models where the board is derived differently).

# app/models/storage/attachment_tracking.rb
module Storage::AttachmentTracking
  extend ActiveSupport::Concern

  included do
    before_destroy :snapshot_storage_context
    after_create_commit :record_storage_attach
    after_destroy_commit :record_storage_detach
  end

  private
    def record_storage_attach
      return unless storage_tracked_record

      Storage::Entry.record \
        account: storage_tracked_record.account,
        board: storage_tracked_record.board_for_storage_tracking,
        recordable: storage_tracked_record,
        blob: blob,
        delta: blob.byte_size,
        operation: "attach"
    end
end

# config/initializers/active_storage.rb
ActiveSupport.on_load(:active_storage_attachment) do
  include Storage::AttachmentTracking
end

4. N+1 Prevention with Attachment Preloading

Rails generates preloadable associations for every attachment; use the right names:

Model.preload(:image_attachment)               # has_one_attached
Model.preload(:documents_attachments)          # has_many_attached
Model.preload(image_attachment: :blob)         # with blob data
Model.with_rich_text_description_and_embeds    # ActionText with embedded files

# Bundle into a named scope alongside the attachment declaration
module User::Avatar
  included do
    has_one_attached :avatar do |attachable|
      attachable.variant :thumb, resize_to_fill: [ 256, 256 ], process: :immediately
    end

    scope :with_avatars, -> { preload(:account, :avatar_attachment) }
  end
end

Fold these into the model's preloaded scope — see [[rails-performance]].


5. Avatar Upload with Validation

Content-type and dimension validation for user uploads:

module User::Avatar
  extend ActiveSupport::Concern

  ALLOWED_AVATAR_CONTENT_TYPES = %w[ image/jpeg image/png image/gif image/webp ].freeze
  MAX_AVATAR_DIMENSIONS = { width: 4096, height: 4096 }.freeze

  included do
    has_one_attached :avatar do |attachable|
      attachable.variant :thumb, resize_to_fill: [ 256, 256 ], process: :immediately
    end

    validate :avatar_content_type_allowed, :avatar_dimensions_allowed, if: :avatar_attached?
  end

  def avatar_attached?
    avatar.attached?
  end

  def avatar_thumbnail
    avatar.variable? ? avatar.variant(:thumb) : avatar
  end

  private
    def avatar_content_type_allowed
      if !ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type)
        errors.add(:avatar, "must be a JPEG, PNG, GIF, or WebP image")
      end
    end

    def avatar_dimensions_allowed
      return unless avatar.blob.analyzed? || avatar.blob.analyze

      width = avatar.blob.metadata[:width]
      height = avatar.blob.metadata[:height]

      if width && width > MAX_AVATAR_DIMENSIONS[:width]
        errors.add(:avatar, "width must be less than #{MAX_AVATAR_DIMENSIONS[:width]}px")
      end

      if height && height > MAX_AVATAR_DIMENSIONS[:height]
        errors.add(:avatar, "height must be less than #{MAX_AVATAR_DIMENSIONS[:height]}px")
      end
    end
end

6. Service Configuration (Local vs S3)

Configure multiple services in storage.yml, select via environment variable:

local:
  service: Disk
  root: <%= Rails.root.join("storage", Rails.env, "files") %>

devminio:
  service: S3
  bucket: app-dev-activestorage
  endpoint: "http://minio.localhost:39000"
  force_path_style: true
  request_checksum_calculation: when_required
  response_checksum_validation: when_required
  region: us-east-1
  access_key_id: minioadmin
  secret_access_key: minioadmin

s3:
  service: S3
  access_key_id: <%= ENV["S3_ACCESS_KEY_ID"] %>
  secret_access_key: <%= ENV["S3_SECRET_ACCESS_KEY"] %>
  bucket: <%= ENV["S3_BUCKET"] %>
  endpoint: <%= ENV["S3_ENDPOINT"] %>
  force_path_style: <%= ENV["S3_FORCE_PATH_STYLE"] == "true" %>
  region: <%= ENV.fetch("S3_REGION", "us-east-1") %>
# config/environments/production.rb
config.active_storage.service = ENV.fetch("ACTIVE_STORAGE_SERVICE", "local").to_sym

Key S3 options: force_path_style: true is required for MinIO and some S3-compatible services; request_checksum_calculation: when_required for FlashBlade compatibility; endpoint for any non-AWS service.


7. Direct Upload with Extended Expiry

Large uploads through Cloudflare (or similar proxies) fail because the proxy only forwards the upload once fully buffered — long after the default URL expiry.

# lib/rails_ext/active_storage_blob_service_url_for_direct_upload_expiry.rb
module ActiveStorage
  mattr_accessor :service_urls_for_direct_uploads_expire_in, default: 48.hours
end

module ActiveStorageBlobServiceUrlForDirectUploadExpiry
  # Lengthens direct-upload URL expiry without touching download URL expiry.
  # 48 hours covers a 10GB upload at 0.5Mbps.
  def service_url_for_direct_upload(expires_in: ActiveStorage.service_urls_for_direct_uploads_expire_in)
    super
  end
end

ActiveSupport.on_load :active_storage_blob do
  prepend ::ActiveStorageBlobServiceUrlForDirectUploadExpiry
end

8. Direct Upload Authentication

Extend DirectUploadsController so direct uploads accept both browser sessions and bearer tokens:

# config/initializers/active_storage.rb
module ActiveStorageDirectUploadsControllerExtensions
  extend ActiveSupport::Concern

  included do
    include Authentication
    include Authorization
    skip_forgery_protection if: :authenticate_by_bearer_token
  end
end

Rails.application.config.to_prepare do
  ActiveStorage::DirectUploadsController.include ActiveStorageDirectUploadsControllerExtensions
end

9. Multi-Tenant Blob Isolation

Validate on attach that the blob's account matches the record's account, and that tracked blobs aren't reused across records:

# config/initializers/active_storage_no_reuse.rb
ActiveSupport.on_load(:active_storage_attachment) do
  validate :blob_account_matches_record, on: :create
  validate :no_tracked_blob_reuse, on: :create

  private
    def blob_account_matches_record
      return unless record&.try(:account).present?
      return if whitelisted_for_cross_account?

      unless blob&.account_id == record.account.id
        errors.add(:blob_id, "blob account must match record account")
      end
    end

    def no_tracked_blob_reuse
      tracked_record = record&.try(:storage_tracked_record)
      return unless tracked_record.present?
      return if whitelisted_for_cross_account?

      existing = ActiveStorage::Attachment
        .where(blob_id: blob_id)
        .where(record_type: Storage::TRACKED_RECORD_TYPES)
        .where.not(id: id)
        .exists?

      if existing
        errors.add(:blob_id, "cannot reuse blob in tracked storage context")
      end
    end
end

10. Exporting Attachments from Rich Text

Render rich text for export by walking its attachables and handling each type:

module Card::Exportable
  def export_attachments
    collect_attachments.map do |attachment|
      { path: export_attachment_path(attachment.blob), blob: attachment.blob }
    end
  end

  private
    def export_html(rich_text)
      return "" if rich_text.blank?

      rich_text.body.render_attachments do |attachment|
        attachment_representation(attachment)
      end.to_html
    end

    def attachment_representation(attachment)
      case attachable = attachment.attachable
      when ActiveStorage::Blob
        path = export_attachment_path(attachable)
        if attachable.image?
          tag.img(src: path, alt: attachable.filename)
        else
          tag.a(attachable.filename, href: path)
        end
      when ActionText::Attachables::RemoteImage
        tag.img(src: attachable.url, alt: "Remote image")
      else
        attachment.to_html
      end
    end

    def collect_attachments
      attachments.to_a + comments.flat_map { |c| c.attachments.to_a }
    end
end

Quick Reference

Attachment Declarations

has_one_attached :image
has_one_attached :avatar do |attachable|
  attachable.variant :thumb, resize_to_fill: [256, 256]
end
has_many_attached :documents
has_rich_text :description

Variant Options

{
  resize_to_limit: [800, 600],     # Fit within dimensions
  resize_to_fill: [256, 256],      # Crop to exact dimensions
  loader: { n: -1 },               # Preserve GIF animation
  process: :immediately            # Process on upload (not lazy; required on read replicas)
}

Preloading

See Pattern 4: :image_attachment / :documents_attachments / with_rich_text_*_and_embeds.

Storage Service Selection

ACTIVE_STORAGE_SERVICE=s3 (or local, devminio).

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Keep looking

Skills are one crate of 325,949. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.