mobility_active_storage
Translated Active Storage attachments for Mobility.
Attach a different file per locale under a single attribute name:
class Product < ApplicationRecord
extend Mobility
translates :title, :description, type: :string # ordinary Mobility text
has_one_translated_attached :document
has_many_translated_attached :photos
end
product.document.attach(io: pdf, filename: "manual.pdf", content_type: "application/pdf")
Mobility.with_locale(:fr) do
product.document.attach(io: pdf_fr, filename: "manuel.pdf", content_type: "application/pdf")
end
product.document # => the :en file
Mobility.with_locale(:fr) { product.document } # => the :fr file
product.document_fr # => the :fr file
No migration required
Active Storage's active_storage_attachments.name column is a plain string, so each locale is
stored as an ordinary attachment whose name carries the locale:
| name | record_type | record_id | blob_id |
|---|---|---|---|
document_en |
Product | 1 | 10 |
document_fr |
Product | 1 | 11 |
Each is declared with a real has_one_attached / has_many_attached under the hood, so variants,
previews, direct uploads, purge, dependent: :purge_later on destroy, and the generated
document_en_attachment / document_en_blob / with_attached_document_en all keep working
exactly as Rails defines them.
Installation
# Gemfile
gem "mobility_active_storage"
You need Active Storage installed (bin/rails active_storage:install) and Mobility configured.
Mobility does not need any translation tables for attachments — if attachments are all you
translate, installing Mobility without tables
is enough.
extend Mobility is optional: the macros extend the model for you if it isn't already.
Usage
Reading and writing
Everything Active Storage's own proxy supports works, scoped to the current locale:
product.document.attach(uploaded_file)
product.document.attached? # => true
product.document.filename # => "manual.pdf"
product.document.purge # purges only the current locale's file
product.document = nil # detaches only the current locale's file
product.photos.attach(one, two) # appends, per locale
product.photos = [one] # replaces the current locale's collection
Assignment works on unsaved records and in create, so form params flow through normally:
Product.create!(title: "Widget", document: params[:document])
Generated methods
For has_one_translated_attached :document:
| Method | Description |
|---|---|
document |
ActiveStorage::Attached::One for the current locale |
document= |
Attach/detach in the current locale |
document_en, document_fr |
A specific locale (dashed locales normalize: pt_br) |
document? |
Whether a file is attached in the current locale |
document_locales |
Locales that actually have an attachment, e.g. [:en, :fr] |
Product.with_attached_document |
Eager-loads whichever locale is current |
has_many_translated_attached :photos mirrors these with ActiveStorage::Attached::Many.
document_locales costs one query however many locales the attribute declares, and counts pending
changes the way attached? does: a staged attach is included before it is saved, a staged purge
excluded straight away. It does not memoize, so hold the result if you need it more than once.
Fallbacks
Off by default. Enable per attribute:
has_one_translated_attached :document, fallbacks: true # I18n's chain, ending at the default locale
has_one_translated_attached :document, fallbacks: { fr: :en } # explicit
Reads then resolve through the chain, while writes always stay in the current locale — so attaching, purging or detaching under a fallback never touches the file you fell back to:
product.document.attach(en_file) # :en has a file, :fr does not
Mobility.with_locale(:fr) do
product.document.filename # => the :en file
product.document(fallback: false).attached? # => false
product.document.attach(fr_file) # creates document_fr; document_en untouched
end
Pass fallback: :ja or fallback: [:ja, :en] to override the chain for a single read. That
override only applies where fallbacks are configured — an attribute declaring no fallbacks cannot be
talked into one by a read option, so fallbacks: false is enforceable even if a caller forwards
untrusted options into the reader. This matches Mobility, whose fallbacks plugin is inert when
fallbacks: false.
Options
Both macros accept:
fallbacks:—true, or a hash such as{ fr: :en }. Defaultfalse.locales:— which locales to declare attachments for. Defaults toMobility.available_locales.- any remaining options are forwarded to Rails, e.g.
service:,strict_loading:,dependent:.
has_one_translated_attached :document, locales: %i[en fr], service: :s3, strict_loading: true
Using the backend directly
The macros are sugar over ordinary Mobility backends, which you can also use directly:
translates :document, backend: :active_storage
translates :photos, backend: :active_storage_many
The macros additionally switch off the Mobility plugins that assume a scalar value. Declaring the backend directly means doing that yourself, for whichever of them you have enabled:
translates :document, backend: :active_storage, fallbacks: false, cache: false, dirty: false
Passing an option for a plugin you have not enabled raises Mobility::Pluggable::InvalidOptionKey,
so pass only the ones that apply. Keeping attachments out of attributes is handled by the backend
itself, so it applies on this path too.
Strong parameters
Each locale gets its own set of writers. has_one_translated_attached :document on a four-locale
app defines document=, document_en=, document_fr=, document_ja=, document_pt_br= (plus the
_attachment= / _blob= association writers Rails generates), and they all work through mass
assignment. That is inherent to has_one_attached, but the names are not obvious from the single
macro call in your model.
Permit only the bare attribute name, which routes through Mobility to the current locale:
params.expect(product: [:title, :document, photos: []])
Permit the _<locale> variants only where a locale switcher genuinely needs them. A blanket
permit!, or a filter matching /\Adocument/, would let a user overwrite a locale they were never
editing — and with fallbacks enabled, that changes what other locales serve too.
Rails compatibility
Requires Rails >= 7.2.3.2 and Ruby >= 3.2. Tested in CI against Rails 7.2, 8.0 and latest, on Ruby 3.2 through 4.0.
The floor is deliberately a patch level rather than a minor version. Rails 7.0 and 7.1 are
end-of-life and carry Active Storage advisories with no fix available — path traversal and glob
injection in DiskService, a content-type bypass in direct uploads, and DoS via Range requests in
proxy mode. Since this gem's whole purpose is to serve more attachments through exactly those paths,
it does not support them. 7.2.3.2 is the earliest release that clears every current Active Storage
advisory; the equivalent for the 8.0 series is 8.0.5.1.
Run bundler-audit or Dependabot in your application to stay ahead of new advisories.
Notes and limitations
Locales are read when the class body runs. Attachments are declared for
Mobility.available_locales at that moment, so adding a locale needs a code reload. Pass
locales: to pin the set explicitly.
Mobility's fallbacks plugin is disabled for these attributes, and this gem implements
fallbacks itself. Mobility's plugin triggers on a nil read, but an attachment reader has to
return a proxy even when nothing is attached, or product.document.attach(...) would fail on a
record with no file yet.
The cache plugin is disabled for these attributes, since it would memoize a proxy resolved
through a fallback and keep returning the fallback locale's file after a later attach.
Dirty tracking is not supported. Mobility's dirty plugin compares scalar values; it is
disabled for these attributes. Active Storage's own attachment_changes still works.
Attachment attributes are kept out of attributes. Mobility's attribute_methods plugin
merges every translated attribute into attributes, translated_attributes and
attribute_names_for_serialization. For an attachment that value would be a live
ActiveStorage::Attached proxy holding a reference back to the record, which makes the hash
unserialisable (attributes.to_json and as_json recurse until the stack overflows) and unusable
for mass assignment (Model.new(record.attributes) raises ArgumentError, since Active Storage
rejects a proxy as an attachable). Rails' own has_one_attached puts nothing in attributes, and
neither does this gem — a proxy is not a serialisable value. Translated text attributes are
unaffected and still appear as Mobility intends.
Note that attribute_methods: false is not a workaround: Mobility 1.3.2 accepts the option and
silently ignores it, because the plugin's initialize_hook is gated on dependencies_satisfied?
rather than on the option value.
Querying is not supported. Product.i18n.where(document: ...) is not meaningful for
attachments — there is no comparable column, only rows in active_storage_attachments. The
backend raises MobilityActiveStorage::Error explaining this rather than failing obscurely inside
Arel. Query the attachments directly, filtering on name (for example "document_en").
Development
bin/setup
bundle exec rake # tests + rubocop
# against a specific Rails version
BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile bundle install
BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile bundle exec rake test
The suite boots a minimal Rails::Application in test/test_helper.rb against in-memory SQLite,
so there is no dummy app to maintain.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/barnaclebarnes/mobility_active_storage.
License
Available as open source under the terms of the MIT License.