Skip to content

Instantly share code, notes, and snippets.

@jordancrawfordnz
Last active February 25, 2023 08:07
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jordancrawfordnz/3b6aa87ff9e16931c951b0fc597feec8 to your computer and use it in GitHub Desktop.
Save jordancrawfordnz/3b6aa87ff9e16931c951b0fc597feec8 to your computer and use it in GitHub Desktop.
ActiveStorage S3 - Cacheable direct presigned URLs by storing the issue time in a cookie
# config/application.rb
...
config.active_storage.track_variants = true
config.active_storage.service_urls_expire_in = 7.days
...
# config/storage.yml
#
# I was using Cloudflare R2 but this will equally work with S3.
cloudflare:
service: S3
endpoint: <%= ENV["R2_ENDPOINT"] %>
access_key_id: <%= ENV["R2_ACCESS_KEY_ID"] %>
secret_access_key: <%= ENV["R2_ACCESS_KEY_SECRET"] %>
region: auto
bucket: <%= ENV["R2_BUCKET"] %>
upload:
cache_control: 'max-age=<%= 7.days %>, private'
# app/helpers/media_helper.rb
#
# Example of a helper which uses the presigned URLs
module MediaHelper
def direct_media_photo_url(photo:, variant:, options: {})
variant = photo.variant(variant)
if variant.service.is_a?(ActiveStorage::Service::S3Service)
variant.url(**options.merge(time: fetch_and_update_s3_presigned_url_issue_time))
else
# If we're using another service, fall back to the standard URL.
rails_representation_url(variant, **options)
end
end
end
# app/helpers/s3_presigned_url_issue_time_helper.rb
#
# S3 presigned URLs include the time they're issued.
# This means if we generate a presigned URL as part of a request it'll be a different
# URL each time, making it impossible for the browser to cache.
#
# By default Rails mitigates this issue with the rails_representation_url - a permanent path
# for the file which redirects to the presigned URL (and can be cached just like the S3 resource)
#
# However, this approach results in an additional web request for each file shown on the page.
# If there are lots of files, this can cause noticable server load and latency.
#
# This solution shares a consistent presigned issued at time for all requests (and persists it
# in a cookie). This means the file will use the same presigned URL for the entire period.
#
# The primary downside of this approach is that our entire cache will be busted at once, but the
# impact can be reduced by using a long service URL expiry time (e.g.: 7 days).
module S3PresignedUrlIssueTimeHelper
COOKIE_NAME = :s3_presignerd_url_issue_time
def fetch_and_update_s3_presigned_url_issue_time
current_issue_time = current_cookie_value
expired = current_issue_time.present? && (current_issue_time + service_url_expiry).past?
future = current_issue_time.present? && current_issue_time.future?
current_issue_time = Time.current if current_issue_time.nil? || expired || future
set_cookie_value!(current_issue_time) if current_cookie_value != current_issue_time
current_issue_time
end
private
def current_cookie_value
Time.parse(cookies[COOKIE_NAME]) if cookies[COOKIE_NAME].present?
end
def set_cookie_value!(value)
cookies[COOKIE_NAME] = {
value: value.to_s,
expires: value + service_url_expiry
}
end
def service_url_expiry
Rails.configuration.active_storage.service_urls_expire_in
end
end
# app/views/example.html.erb
<%= image_tag direct_media_photo_url(photo: model.photo, variant: :small) %>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment