From b834fb8d0f6fa679e1b889ec4e063d623e915a50 Mon Sep 17 00:00:00 2001 From: "Kit (OpenClaw)" Date: Tue, 3 Mar 2026 12:28:08 -0500 Subject: [PATCH 1/2] docs: add Dragonfly replacement migration plan --- docs/notes/dragonfly-migration-plan.md | 247 +++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 docs/notes/dragonfly-migration-plan.md diff --git a/docs/notes/dragonfly-migration-plan.md b/docs/notes/dragonfly-migration-plan.md new file mode 100644 index 000000000..cdcd23ffd --- /dev/null +++ b/docs/notes/dragonfly-migration-plan.md @@ -0,0 +1,247 @@ +# WA-PERF-001 — Replace Dragonfly with ActiveStorage or direct libvips + +> Issue: workarea-commerce/workarea#703 + +This document inventories how Workarea currently uses Dragonfly and proposes a migration path to remove it. + +## 1) Current Dragonfly usage inventory + +### Gems / runtime components + +- `dragonfly ~> 1.4` +- `dragonfly-s3_data_store ~> 1.3` +- `dragonfly_libvips ~> 2.4` (optional, based on `vips -v`) +- `image_optim` + `image_optim_pack` (used by `Workarea::ImageOptimProcessor` processor) +- `fastimage` (used for type detection and some logic around JPEG) + +### Initialization (`core/config/initializers/07_dragonfly.rb`) + +**Plugins** +- Chooses image backend: + - `plugin :libvips` when `Workarea::Configuration::ImageProcessing.libvips?` + - else `plugin :imagemagick` +- When using libvips, Workarea still requires ImageMagick command wrapper for `.ico` conversion: + - `require 'dragonfly/image_magick/commands'` + +**Security / signing** +- `verify_urls true` +- `secret Workarea::Configuration::AppSecrets[:dragonfly_secret].presence || Rails.application.secret_key_base` + +**URL format / serving** +- `url_format '/media/:job/:name'` +- `response_header 'Cache-Control'` is customized: + - sitemap paths: `public, max-age=86400` + - everything else: `public, max-age=31536000` + +**Custom processor** +- `processor :optim, Workarea::ImageOptimProcessor` + +**Encode whitelist extension (CVE-2021-33564 mitigation)** +- Workarea extends Dragonfly’s ImageMagick `encode` whitelist: + - `Dragonfly::ImageMagick::Processors::Encode::WHITELISTED_ARGS.concat(%w[interlace set])` + +**Additional processors defined (only if not already defined)** +- Admin: + - `:avatar` (encode jpg + thumb 80x80 + optim) + - `:small` (encode jpg + thumb 55x + optim) (skips SVG) + - `:medium` (encode jpg + thumb 240x + optim) (skips SVG) +- Storefront: + - analyser `:inverse_aspect_ratio` + - `:small_thumb` (60x) + - `:medium_thumb` (120x) + - `:large_thumb` (220x) + - `:detail` (400x) + - `:zoom` (670x) + - `:super_zoom` (1600x) + - `:favicon` (special center-crop behavior differs by backend) + - `:favicon_ico` (ImageMagick convert command) + +### Configuration (`core/lib/workarea/configuration/dragonfly.rb`) + +- Storage backend is derived from `Workarea.config.asset_store`: + - `:s3` (when configured + bucket present) + - default options include region, bucket, credentials, `use_iam_profile` and `storage_headers: { 'x-amz-acl' => 'private' }` + - `:file` / `:file_system` coerced to `:file` with: + - `root_path: public/system/workarea/` + - `server_root: public` +- Enforces CDN/asset host: + - `url_host Rails.application.config.action_controller.asset_host` + +### Image processing configuration (`core/lib/workarea/configuration/image_processing.rb`) + +- Decides libvips support by checking `vips -v` for `vips-8*`. +- Loads `dragonfly_libvips` when available. + +### Model attachment usage + +Dragonfly is used via `extend Dragonfly::Model` + `dragonfly_accessor`. + +**Primary consumer: `Workarea::Content::Asset`** (`core/app/models/workarea/content/asset.rb`) +- Fields mirror Dragonfly “magic attributes” populated by analysers: + - `file_name`, `file_uid`, `file_width`, `file_height`, `file_aspect_ratio`, `file_portrait`, `file_landscape`, `file_format`, `file_image`, `file_inverse_aspect_ratio` +- Attachment: + - `dragonfly_accessor :file, app: :workarea` + - `after_assign` hook: if JPEG (`FastImage.type(...) == :jpeg`) then `file.encode!('jpg', Workarea.config.jpg_encode_options)` +- Delegates unknown methods to `file` attachment (`method_missing` + `respond_to_missing?`), so downstream code often calls Dragonfly attachment APIs directly. + +**Other models using Dragonfly** +- `Workarea::Catalog::ProductImage` (embedded) — `dragonfly_accessor :image` +- `Workarea::Catalog::ProductPlaceholderImage` — `dragonfly_accessor :image` +- `Workarea::User::Avatar` concern — `dragonfly_accessor :avatar`, uses `avatar.process(:avatar).url` +- `Workarea::Sitemap` — `dragonfly_accessor :file` +- `Workarea::Help::Article` — `dragonfly_accessor :thumbnail` +- `Workarea::Help::Asset` — `dragonfly_accessor :file` +- `Workarea::Reports::Export` — `dragonfly_accessor :file` (CSV generated to tmp then assigned) +- `Workarea::DataFile::Operation` concern — `dragonfly_accessor :file` +- `Workarea::Fulfillment::Sku` — `dragonfly_accessor :file` + +**Data import/export integration** (`core/app/models/workarea/data_file/csv.rb`) +- Special-cases Dragonfly models when importing CSV: + - checks `model.class.is_a?(Dragonfly::Model)` + - iterates `model.dragonfly_attachments` + - assigns `*_uid`, `*_name`, etc. fields from CSV + - calls `attachment.save!` to persist attachment even when embedded + +### Serving / routing + +- `core/config/routes.rb` + - Dynamic product images served by `Dragonfly.app(:workarea).endpoint` calling: + - `AssetEndpoints::ProductImages#result` + - `AssetEndpoints::ProductPlaceholderImages#result` +- `storefront/config/routes.rb` + - Favicon(s) served by Dragonfly endpoint calling `Workarea::AssetEndpoints::Favicons` + - Sitemaps served by Dragonfly endpoint calling `Workarea::AssetEndpoints::Sitemaps` + +### Freedom patches (core/lib/workarea/ext/freedom_patches/*) + +- `dragonfly_attachment.rb` + - Prepends module to disable datastore destroy (original content not deleted). +- `dragonfly_callable_url_host.rb` + - Allows `url_host` to be callable (depends on rack request). +- `dragonfly_job_fetch_url.rb` + - Overrides `Dragonfly::Job::FetchUrl#get` to respect `HTTP(S)_PROXY` and supports basic auth. + +## 2) ActiveStorage vs direct libvips (for Workarea) + +### Constraints unique to Workarea + +- Workarea uses **Mongoid** (not ActiveRecord) for most application models. +- Dragonfly provides: + - attachment macros for non-AR models (`Dragonfly::Model`) + - a signed URL/job format and an endpoint/middleware + - “magic attributes” analyzers stored on the document + - on-the-fly processing (`thumb`, `encode`, custom processors) + - flexible datastore support (S3 + filesystem) + +These features do not map 1:1 to ActiveStorage without additional glue. + +### Option A — ActiveStorage + +**Pros** +- Rails standard, long-term maintained. +- Well-supported for S3 + variants + CDN. +- Integrates with `image_processing` and libvips. +- Better ecosystem (direct uploads, analyzers, mirror, etc.). + +**Cons / risks for Workarea** +- ActiveStorage models (`active_storage_blobs`, `active_storage_attachments`) require **ActiveRecord tables**. +- Attaching to **Mongoid documents** is not first-class. You either: + 1) Introduce AR just for ActiveStorage tables, and create custom attachment glue for Mongoid, or + 2) Adopt a third-party bridge gem (adds maintenance risk). +- Existing Workarea code depends heavily on Dragonfly API (e.g. `asset.optim.url`, `avatar.process(:avatar).url`). +- Existing URL format `/media/:job/:name` and signed job semantics are Dragonfly-specific. + +### Option B — direct libvips (custom storage + processing) + +**Pros** +- Keeps Workarea “Mongoid-first”; no AR dependency required. +- Control over URL format, caching, and backward-compat endpoints. +- Can store metadata on Mongoid documents exactly like today. +- Can be built to preserve existing API shape (`#url`, `#process`, `#thumb`-like methods) while removing Dragonfly. + +**Cons / risks** +- Workarea becomes responsible for storage + variant caching + security/signed URLs. +- Re-implementing 80% of Dragonfly may be more work than adopting ActiveStorage. +- Must handle streaming, Range requests, content-type, cache headers, and CDN behavior. + +## 3) Recommendation + +**Recommended approach: phased migration to a Workarea-owned “Media” abstraction implemented on top of libvips + S3/filesystem**, with an eventual option to switch the underlying store to ActiveStorage if Workarea later adopts AR. + +Rationale: +- Immediate removal of Dragonfly without forcing an AR schema into a Mongoid app. +- Lets Workarea preserve API compatibility (or at least provide a compatibility shim) while swapping the backend. +- Supports incremental rollout model-by-model (start with `Content::Asset`). + +## 4) Backward compatibility strategy + +### Public URLs + +- Keep existing Dragonfly-generated URLs working for a deprecation window: + - Leave Dragonfly mounted/available while new attachments generate new URLs. + - Add a compatibility endpoint (or router) that can serve both old `/media/:job/:name` (Dragonfly) and new media URLs. +- Provide an opt-in config switch to generate only new URLs once production confirms parity. + +### Processor ecosystem + +Downstream users may have: +- custom Dragonfly processors defined in initializers +- code that calls Dragonfly attachment APIs (`process`, `thumb`, `encode`, custom processors) + +Plan: +1) Introduce a small compatibility wrapper (e.g. `Workarea::Media::Attachment`) that implements: + - `#url`, `#process(name, *args)`, `#thumb(geometry, options)`, `#encode(format, options)`, and known processors (`optim`, `small`, `medium`, etc.). +2) Provide a mapping layer: + - for Workarea-shipped processors, implement equivalents using libvips / image_optim. + - for unknown processors, allow a hook system (e.g. `Workarea.config.media_processors[name] = ->(io, *args) { ... }`). +3) Deprecate custom Dragonfly processors over time and document replacements. + +### Data import/export + +- `Workarea::DataFile::Csv` currently assumes `Dragonfly::Model` and `dragonfly_attachments`. +- Migration needs a new abstraction: + - `Workarea::Media::Model` (or similar) exposing `media_attachments` and `*_uid` / `*_name` attributes. + +## 5) Migration phases / timeline (suggested) + +### Phase 0 — groundwork (1–2 weeks) +- Add Workarea media abstraction (storage + URL generation + controller endpoint). +- Add signed URL helper (or rely on private S3 + expiring presigned URLs). +- Implement libvips variants for required sizes. + +### Phase 1 — migrate `Content::Asset` (1–2 weeks) +- New backend behind feature flag. +- Implement `optim` path for images. +- Ensure admin upload and direct upload service still work. +- Add a data migration task to copy existing Dragonfly originals into new storage (optional early). + +### Phase 2 — migrate “simple file” models (1–2 weeks) +- `Help::Asset`, `Reports::Export`, `DataFile::Operation`, `Sitemap`, `Fulfillment::Sku` + +### Phase 3 — migrate product image pipeline (2–4 weeks) +- `Catalog::ProductImage` + placeholder, plus endpoints in routes. +- Validate CDN caching behavior and performance. + +### Phase 4 — migrate avatar + favicon/sitemaps (1–2 weeks) +- Requires matching custom processors and special `.ico` conversion. + +### Phase 5 — remove Dragonfly (after deprecation window) +- Remove freedom patches, processors, and gem dependencies. + +## 6) Risk assessment + +- **Mongoid + ActiveStorage mismatch** is the main risk if choosing ActiveStorage. +- **URL compatibility**: existing pages/emails may embed Dragonfly URLs; breaking them is costly. +- **Processing parity**: small differences in crop/resize/encode can affect storefront imagery. +- **Operational**: S3 permissions/ACLs, cache headers, CDN invalidation, and presigned URL lifetimes. +- **Performance**: on-the-fly processing must be cached; otherwise CPU load can spike. + +--- + +## Appendix: current code touchpoints + +- `ContentAssetsHelper#url_to_content_asset` expects: + - images: `asset.optim.url` + - non-images: `asset.url` +- `User::Avatar#avatar_image_url` expects `avatar.process(:avatar).url` +- Storefront/core routes use `Dragonfly.app(:workarea).endpoint` for product images, favicon(s), and sitemaps. From ef2c9f3513a7aca0fb66647263c52522a8d12319 Mon Sep 17 00:00:00 2001 From: "Kit (OpenClaw)" Date: Tue, 3 Mar 2026 12:28:11 -0500 Subject: [PATCH 2/2] proto: add media_v2 storage and migrate Content::Asset behind flag --- .../controllers/workarea/media_controller.rb | 35 ++++ core/app/models/workarea/content/asset.rb | 24 +++ core/config/routes.rb | 4 + core/lib/workarea/core.rb | 3 + core/lib/workarea/media/attachment.rb | 173 ++++++++++++++++++ core/lib/workarea/media/storage.rb | 118 ++++++++++++ core/lib/workarea/media/variant.rb | 28 +++ 7 files changed, 385 insertions(+) create mode 100644 core/app/controllers/workarea/media_controller.rb create mode 100644 core/lib/workarea/media/attachment.rb create mode 100644 core/lib/workarea/media/storage.rb create mode 100644 core/lib/workarea/media/variant.rb diff --git a/core/app/controllers/workarea/media_controller.rb b/core/app/controllers/workarea/media_controller.rb new file mode 100644 index 000000000..a7a037b2b --- /dev/null +++ b/core/app/controllers/workarea/media_controller.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Workarea + class MediaController < ActionController::Base + include Workarea::I18n::DefaultUrlOptions + + # Prototype media endpoint. + # + # GET /media2/:uid/:filename?v=optim + def show + uid = CGI.unescape(params[:uid].to_s) + filename = CGI.unescape(params[:filename].to_s) + variant = params[:v].presence + + storage = Workarea::Media::Storage.build + + if variant == 'optim' + # Optim files are stored alongside originals. + uid = uid.to_s.sub(/\.[^.]+\z/, '') + '.optim.jpg' + filename = filename.to_s.sub(/\.[^.]+\z/, '') + '.jpg' + end + + io = storage.open(uid) + data = io.respond_to?(:read) ? io.read : io.to_s + + # Extremely naive content type inference for prototype + content_type = Rack::Mime.mime_type(File.extname(filename), 'application/octet-stream') + + response.headers['Cache-Control'] = 'public, max-age=31536000' + send_data(data, type: content_type, disposition: 'inline', filename: filename) + rescue Errno::ENOENT, Aws::S3::Errors::NoSuchKey + head :not_found + end + end +end diff --git a/core/app/models/workarea/content/asset.rb b/core/app/models/workarea/content/asset.rb index 19c79d571..3ecbbf883 100644 --- a/core/app/models/workarea/content/asset.rb +++ b/core/app/models/workarea/content/asset.rb @@ -38,6 +38,26 @@ class Content::Asset end end + # --- Prototype: optional non-Dragonfly backend --- + # + # This keeps the existing fields (`file_uid`, `file_name`, magic attributes) + # but allows swapping the runtime implementation for Content::Asset only. + # + # Enable with: WORKAREA_MEDIA_BACKEND=media_v2 + # + alias_method :dragonfly_file, :file + alias_method :dragonfly_file=, :file= + + def file + return dragonfly_file unless use_media_v2? + @media_v2_file ||= Workarea::Media::Attachment.new(self, :file) + end + + def file=(value) + return self.dragonfly_file = value unless use_media_v2? + Workarea::Media::Attachment.assign(self, :file, value) + end + validates :file, presence: true scope :of_type, ->(t) { where(type: t) } @@ -146,5 +166,9 @@ def set_type def skip_type? new_record? && type.present? end + + def use_media_v2? + ENV['WORKAREA_MEDIA_BACKEND'].to_s == 'media_v2' + end end end diff --git a/core/config/routes.rb b/core/config/routes.rb index a78c1acb8..d50bd306d 100644 --- a/core/config/routes.rb +++ b/core/config/routes.rb @@ -1,5 +1,9 @@ module Workarea Core::Engine.routes.draw do + # Prototype replacement endpoint for non-Dragonfly media. + # NOTE: uses a different prefix to avoid conflicting with Dragonfly's `/media/:job/:name` middleware. + get 'media2/:uid/:filename' => 'media#show', as: :media_v2 + get 'product_images/:slug(/:option)/:image_id/:job.jpg' => Dragonfly.app(:workarea).endpoint { |*args| AssetEndpoints::ProductImages.new(*args).result }, as: :dynamic_product_image diff --git a/core/lib/workarea/core.rb b/core/lib/workarea/core.rb index ee00b3b29..cac634fa7 100644 --- a/core/lib/workarea/core.rb +++ b/core/lib/workarea/core.rb @@ -215,6 +215,9 @@ module Core require 'workarea/plugin' require 'workarea/plugin/asset_appends_helper' require 'workarea/image_optim_processor' +require 'workarea/media/storage' +require 'workarea/media/attachment' +require 'workarea/media/variant' require 'workarea/url_token' require 'workarea/paged_array' require 'workarea/geolocation' diff --git a/core/lib/workarea/media/attachment.rb b/core/lib/workarea/media/attachment.rb new file mode 100644 index 000000000..8d6a14062 --- /dev/null +++ b/core/lib/workarea/media/attachment.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +require 'cgi' +require 'fastimage' + +module Workarea + module Media + class Attachment + attr_reader :model, :name + + def self.assign(model, name, value) + new(model, name).assign(value) + end + + def initialize(model, name) + @model = model + @name = name.to_sym + @storage = Workarea::Media::Storage.build + end + + # Dragonfly-like API surface + def url(variant: nil, args: []) + uid = model.public_send("#{name}_uid") + filename = model.public_send("#{name}_name") + return if uid.blank? || filename.blank? + + params = {} + params[:v] = variant.to_s if variant.present? + params[:a] = args.join(',') if args.present? + + query = params.any? ? "?#{params.to_query}" : '' + "/media2/#{CGI.escape(uid)}/#{CGI.escape(filename)}#{query}" + end + + def present? + model.public_send("#{name}_uid").present? + end + + def optim + Workarea::Media::Variant.new(self, :optim) + end + + def process(processor_name, *args) + Workarea::Media::Variant.new(self, processor_name, *args) + end + + def assign(value) + io, original_filename = normalize_to_io(value) + + uid = @storage.generate_uid(original_filename) + @storage.put(uid, io) + + model.public_send("#{name}_uid=", uid) + model.public_send("#{name}_name=", original_filename) + + populate_image_metadata(io) + + uid + ensure + io.close if io.respond_to?(:close) && !(value.is_a?(String) || value.is_a?(Pathname)) + end + + def open_original + @storage.open(model.public_send("#{name}_uid")) + end + + def ensure_variant!(processor_name, *_args) + return unless processor_name.to_sym == :optim + + # Optim variant is stored as a sibling key: .optim.jpg + uid = model.public_send("#{name}_uid") + return if uid.blank? + + variant_uid = optim_uid + return if @storage.exist?(variant_uid) + + original = open_original + tmp = Tempfile.new(['workarea-optim', '.jpg']) + tmp.binmode + + OptimProcessor.new.call(original, tmp) + tmp.rewind + @storage.put(variant_uid, tmp) + ensure + tmp.close! if tmp + end + + def optim_uid + uid = model.public_send("#{name}_uid") + base = uid.to_s.sub(/\.[^.]+\z/, '') + "#{base}.optim.jpg" + end + + class OptimProcessor + def call(input_io, output_io) + data = input_io.read + + # If input is already jpeg, preserve; otherwise convert using vips if available. + converted = maybe_convert_to_jpeg(data) + + # Run through image_optim if available. + optimized = maybe_image_optim(converted) + + output_io.write(optimized) + end + + private + + def maybe_convert_to_jpeg(data) + type = FastImage.type(StringIO.new(data)) rescue nil + return data if type == :jpeg + + begin + require 'vips' + image = Vips::Image.new_from_buffer(data, '') + image.jpegsave_buffer(interlace: true, strip: true, Q: 85) + rescue LoadError, Vips::Error + data + end + end + + def maybe_image_optim(data) + require 'image_optim' + ImageOptim.new.optimize_image_data(data) || data + rescue LoadError + data + end + end + + private + + def normalize_to_io(value) + if value.respond_to?(:path) && value.respond_to?(:original_filename) + [File.open(value.path, 'rb'), value.original_filename] + elsif value.respond_to?(:path) + [File.open(value.path, 'rb'), File.basename(value.path.to_s)] + elsif value.is_a?(String) || value.is_a?(Pathname) + [File.open(value.to_s, 'rb'), File.basename(value.to_s)] + elsif value.respond_to?(:read) + [value, "upload"] + else + raise ArgumentError, "Unsupported attachment assignment: #{value.class}" + end + end + + def populate_image_metadata(io) + return unless model.respond_to?("#{name}_width=") + + io.rewind if io.respond_to?(:rewind) + bytes = io.read + io.rewind if io.respond_to?(:rewind) + + type = FastImage.type(StringIO.new(bytes)) rescue nil + + if type.present? + size = FastImage.size(StringIO.new(bytes)) rescue nil + if size + model.public_send("#{name}_width=", size[0]) + model.public_send("#{name}_height=", size[1]) + model.public_send("#{name}_aspect_ratio=", (size[0].to_f / size[1].to_f)) if model.respond_to?("#{name}_aspect_ratio=") + model.public_send("#{name}_portrait=", size[1] > size[0]) if model.respond_to?("#{name}_portrait=") + model.public_send("#{name}_landscape=", size[0] > size[1]) if model.respond_to?("#{name}_landscape=") + end + + model.public_send("#{name}_format=", type.to_s) if model.respond_to?("#{name}_format=") + model.public_send("#{name}_image=", true) if model.respond_to?("#{name}_image=") + else + model.public_send("#{name}_image=", false) if model.respond_to?("#{name}_image=") + end + end + end + end +end diff --git a/core/lib/workarea/media/storage.rb b/core/lib/workarea/media/storage.rb new file mode 100644 index 000000000..94a36b6ed --- /dev/null +++ b/core/lib/workarea/media/storage.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +# A lightweight replacement for the subset of Dragonfly Workarea relies on. +# +# Goals (prototype): +# - Store originals on S3 or filesystem using the existing Workarea.asset_store config +# - Provide stable keys compatible with the existing *_uid fields +# - Offer a simple URL + download interface for serving from Rails +# +# Non-goals (prototype): +# - full Dragonfly job DSL +# - full processor compatibility +# - background variant generation + +require 'securerandom' +require 'fileutils' +require 'aws-sdk-s3' + +module Workarea + module Media + class Storage + def self.build + type, options = *Workarea.config.asset_store + options = (options || {}).with_indifferent_access + + case type + when :s3 + S3.new(options) + when :file, :file_system + FileSystem.new(options) + else + # Default to filesystem in development/test. + FileSystem.new(options) + end + end + + def generate_uid(filename) + ext = File.extname(filename.to_s) + date = Time.now.utc.strftime('%Y/%m/%d') + token = SecureRandom.hex(10) + "#{date}/#{token}#{ext}" + end + end + + class FileSystem < Storage + def initialize(options) + @root_path = options[:root_path].presence || Rails.root.join('public/system/workarea', Rails.env).to_s + end + + def put(uid, io) + path = path_for(uid) + FileUtils.mkdir_p(File.dirname(path)) + + io = File.open(io, 'rb') if io.is_a?(String) || io.is_a?(Pathname) + File.open(path, 'wb') { |f| IO.copy_stream(io, f) } + ensure + io.close if io.respond_to?(:close) && !(io.is_a?(String) || io.is_a?(Pathname)) + end + + def open(uid) + File.open(path_for(uid), 'rb') + end + + def exist?(uid) + File.exist?(path_for(uid)) + end + + def path_for(uid) + File.join(@root_path, uid) + end + end + + class S3 < Storage + def initialize(options) + @options = options + # dragonfly config lives in Workarea::Configuration::Dragonfly.s3_defaults + @region = options[:region] || Workarea::Configuration::S3.region + @bucket = options[:bucket_name] || Workarea::Configuration::S3.bucket + @access_key_id = options[:access_key_id] || Workarea::Configuration::S3.access_key_id + @secret_access_key = options[:secret_access_key] || Workarea::Configuration::S3.secret_access_key + end + + def client + @client ||= begin + creds = if @access_key_id.present? && @secret_access_key.present? + Aws::Credentials.new(@access_key_id, @secret_access_key) + end + + Aws::S3::Client.new(region: @region, credentials: creds) + end + end + + def put(uid, io) + io = File.open(io, 'rb') if io.is_a?(String) || io.is_a?(Pathname) + client.put_object( + bucket: @bucket, + key: uid, + body: io, + acl: 'private' + ) + ensure + io.close if io.respond_to?(:close) && !(io.is_a?(String) || io.is_a?(Pathname)) + end + + def open(uid) + resp = client.get_object(bucket: @bucket, key: uid) + resp.body # responds to #read + end + + def exist?(uid) + client.head_object(bucket: @bucket, key: uid) + true + rescue Aws::S3::Errors::NotFound + false + end + end + end +end diff --git a/core/lib/workarea/media/variant.rb b/core/lib/workarea/media/variant.rb new file mode 100644 index 000000000..5f2c90643 --- /dev/null +++ b/core/lib/workarea/media/variant.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'tempfile' + +module Workarea + module Media + # Very small variant/processing implementation for the prototype. + # + # Implemented processors: + # - :optim (jpeg encode + image_optim) for images + class Variant + def initialize(attachment, processor_name, *args) + @attachment = attachment + @processor_name = processor_name.to_sym + @args = args + end + + def url + ensure_generated! + @attachment.url(variant: @processor_name, args: @args) + end + + def ensure_generated! + @attachment.ensure_variant!(@processor_name, *@args) + end + end + end +end