agentsclimarketplace

Rails activestorage

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

Agent skills for working with Ruby on Rails 8+

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.

What its author says it does

Copied from the file, not written here

Use when handling file uploads, variants, direct uploads, and rich text attachments

SKILL.md

11.5 KB, 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).

Keep looking

Skills are one crate of 328,083. 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.