Skip to content

Add ActiveStorage/R2 backend for PublicImage alongside CarrierWave - #3969

Draft
sethherr wants to merge 14 commits into
mainfrom
sethherr/keep-small-300x300
Draft

Add ActiveStorage/R2 backend for PublicImage alongside CarrierWave#3969
sethherr wants to merge 14 commits into
mainfrom
sethherr/keep-small-300x300

Conversation

@sethherr

@sethherr sethherr commented Jul 28, 2026

Copy link
Copy Markdown
Member

Groundwork for moving public images off CarrierWave onto ActiveStorage + R2. PublicImage gains a has_one_attached :file that coexists with the legacy image column — image_url dispatches on whichever backend a record uses, so the ~40 existing call sites are untouched and there's no cutover to coordinate. Nothing writes to the new backend yet; that arrives with the direct-upload endpoint.

  • Variants are sized for the layouts we have now rather than 2013's: large 2000x1600 covers the show-page hero at 2x (it renders ~885 CSS px), medium 1000x750, small stays 300x300. small is the one that stays jpeg — it feeds thumb_path, and Outlook desktop is still on the Word engine and won't render webp.
  • track_variants is off, against load_defaults. Untracked variant keys are a digest of the blob key plus the transformation, so BlobUrl.for_variant builds them with no query and no processed existence check — tracking would put active_storage_variant_records plus a nested attachment and blob behind every image URL. The tradeoff is that purging a blob orphans its variants, so they need sweeping alongside unattached blobs.
  • Images::ProcessPublicImageJob strips EXIF from the original in place, then analyzes it and generates the variants. Direct uploads go browser to R2 without passing through Rails, so the original would otherwise keep the GPS coordinates of wherever the bike was photographed — and we serve originals publicly. Rewriting under the same blob key keeps variant keys and CDN URLs stable.
  • The job owns analysis because PublicImage marks the blob analyzed before enqueueing, which stops ActiveStorage from queueing its own AnalyzeJob. Both write blob.metadata by merging into whatever they read at start, so left concurrent, whichever commits second drops the other's keys — losing stripped and forcing a re-strip, or losing the dimensions.
  • open_file handles both backends, so stolen-alert generation works either way. update_alert_images now closes the tempfile it was leaking until GC.

sethherr and others added 5 commits July 27, 2026 21:55
PublicImage gains a has_one_attached :file with small/medium/large variants,
resized for the current layouts rather than 2013's. image_url dispatches on
which backend a record uses, so call sites are unchanged.

track_variants is off: untracked variant keys are a digest of the blob key
plus the transformation, so BlobUrl builds them without a query.

Images::ProcessPublicImageJob strips EXIF from the original in place - direct
uploads never reach Rails, so GPS would otherwise be published with the photo.

Co-Authored-By: Claude <noreply@anthropic.com>
open_file short-circuits to attached_tempfile before consulting it, and
activestorage downloads identically on Disk and S3 - the local-vs-remote
question is carrierwave's alone.

Co-Authored-By: Claude <noreply@anthropic.com>
Blob#delete already delete_prefixed's "variants/#{key}/", so purging cleans up
untracked variants - the comment claimed they'd need sweeping. What's actually
lost is enumerating them from the database.

image_url tested size.to_sym for membership but forwarded the raw value, so a
string passed the guard and then missed the symbol-keyed variant lookup.

Co-Authored-By: Claude <noreply@anthropic.com>
sethherr and others added 9 commits July 27, 2026 22:41
Attaching enqueues ActiveStorage's AnalyzeJob against the same blob the strip job
rewrites, and Blob#analyze merges into metadata from whatever it read when it
started - so whichever commits second drops the other's keys, losing "stripped"
(forcing a pointless re-strip) or the dimensions.

Marking the blob analyzed in memory is enough to stop the attachment's
after_commit from enqueueing it; the job then analyzes off the stripped bytes.
Nothing is persisted claiming analysis happened until the job does it.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Records an upload of a GPS-tagged iPhone photo against the bikeindex-dev R2
bucket, then asserts the stored original has no EXIF at all and that no
identifying tag survives into any variant.

vips copies EXIF through a transform - a variant built from an un-stripped
original carries all 15 GPS fields, jpeg and webp alike - so the variants are
only clean because the original is rewritten before any of them exist. It does
re-synthesize orientation/resolution/colorspace on save, hence matching on
identifying tags rather than expecting no metadata.

Two fixes to make the cassette safe to commit: the filter list named
R2_DEV_ACCESS_KEY twice and never the secret, and aws-sdk addresses R2
virtual-host style, so filtering R2_DEV_ENDPOINT matched nothing and left the
account id in the URLs. Filtering the host instead also lets the cassette
replay against the .env placeholder, which CI needs - the S3 client raises
before WebMock can intercept when no credentials are set.

Co-Authored-By: Claude <noreply@anthropic.com>
"stripped" was doing two jobs: guarding the re-encode and standing in for "this
image is ready". It's written halfway through, so anything reading it as a
completion signal sees a blob whose variants don't exist yet.

Split it. "stripped" still lands right after the rewrite, because that's what it
has to guard - preparing twice would re-encode the original - and a new
"processed" is written last, once every variant is generated.
file_needs_processing? reads that one, so a job that died partway through gets
picked up again: the retry skips the strip, fills in the missing variants and
only then marks it ready.

prepare_image (was strip_metadata) now also rewrites HEIC as webp in the same
decode/encode pass. Safari is the only browser that renders HEIC and the
original is served directly, so an iPhone upload was otherwise undisplayable.
The blob key is untouched, so variant keys and CDN urls still don't move; the
filename is set before the upload, which re-identifies content_type using it.

Co-Authored-By: Claude <noreply@anthropic.com>
TIFF is in the uploader's allowlist and no browser renders it, so it hit the
same problem as HEIC: the original is served directly, undisplayable. Every
format the allowlist accepts is now web-servable once the job has run.

Co-Authored-By: Claude <noreply@anthropic.com>
The trait declared image/jpeg for every image_path, which read as wrong next to
a .tif or .heic fixture. It was harmless - ActiveStorage re-identifies from the
bytes on upload, which is why the tiff spec saw image/tiff regardless - but it
would mislead the next person to add a fixture.

Co-Authored-By: Claude <noreply@anthropic.com>
Running the spec/services suite re-recorded them under record: :new_episodes,
changing only timestamps and Heroku NEL tokens, and `git add -A` picked them up.
Reverted to their prior content - no behaviour change either way.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant