Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions core/app/controllers/workarea/media_controller.rb
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions core/app/models/workarea/content/asset.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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
4 changes: 4 additions & 0 deletions core/config/routes.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 3 additions & 0 deletions core/lib/workarea/core.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
173 changes: 173 additions & 0 deletions core/lib/workarea/media/attachment.rb
Original file line number Diff line number Diff line change
@@ -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: <uid>.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
118 changes: 118 additions & 0 deletions core/lib/workarea/media/storage.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading