require 'scrivito/binary' require 'mini_magick' module Fiona7 module BinaryHandling class ParamEncoder def initialize self.verifier = ActiveSupport::MessageVerifier.new( Rails.application.secrets.secret_key_base, serializer: ::JSON) end def encode(params) self.verifier.generate(params) end def decode(string) self.verifier.verify(string) rescue ActiveSupport::MessageVerifier::InvalidSignature => e {} end protected attr_accessor :verifier end class MetaBinary class ActualBinary def initialize(binary_id, transformation=false) self.binary_id = binary_id.to_i self.transformation = transformation self.obj = self.load_obj end protected attr_accessor :binary_id, :transformation, :obj def cache(key, &block) # TODO: make path this configurable @@cache = ActiveSupport::Cache::FileStore.new(Rails.root + '/tmp/cache') unless defined?(@@cache) @@cache.fetch("#{self.binary_id}-#{key}", &block) end def load_obj if Fiona7.mode == :legacy Fiona7::EditedObj.find(self.binary_id) else Fiona7::InternalReleasedObj.find(self.binary_id) end rescue ActiveRecord::RecordNotFound nil end end class UnmodifiedBinary < ActualBinary def valid? self.binary_id >= 0 && self.obj.try(:binary?) end def present? !self.obj.nil? end def filename self.obj.filename end def filepath self.obj.body_data_path end def length self.obj.body_length end def image? self.obj.image? end def width self.cache("#{self.last_changed}-width") do if self.image self.image[:width] else 0 end end rescue 0 end def height self.cache("#{self.last_changed}-height") do if self.image self.image[:height] else 0 end end rescue 0 end def mime_type self.obj.mime_type end def last_changed self.obj.last_changed.utc end protected def image return @image if defined?(@image) if self.filepath @image = MiniMagick::Image.new(self.filepath) else @image = nil end end end class TransformedBinary < UnmodifiedBinary def valid? super && valid_transformation? end def filename ::File.basename(self.transformed_filepath) end alias_method :original_filepath, :filepath def filepath self.transformed_filepath end def legnth ::File.size(self.transformed_filepath) end def last_changed ::File.mtime(self.transformed_filepath).utc end protected def valid_transformation? true && (self.mime_type =~ /image\//) && (!self.width.present? || (1..4096).include?(self.width.to_i)) && (!self.height.present? || (1..4096).include?(self.height.to_i)) && ((0..100).include?(self.quality.to_i)) && (!self.fit.present? || ( (self.fit == 'clip' || self.fit == 'crop' || self.fit == 'resize') && (self.width.to_i + self.height.to_i < 4096) && (self.fit == 'clip' || (self.width.present? && self.height.present?)) )) end def transformed_filepath return @transformed_filepath if @transformed_filepath return nil if self.original_filepath.nil? output_filepath = original_filepath + self.transformed_filename if ::File.exists?(output_filepath) Rails.logger.debug("Transformed image #{output_filepath} already generated, serving") return @transformed_filepath = output_filepath else Rails.logger.debug("Transforming image") end image = MiniMagick::Image.open(original_filepath) fit = self.fit_with_default if fit.blank? image.combine_options do |b| b.quality self.quality.to_i end elsif fit == 'clip' || fit == 'resize' image.combine_options do |b| b.resize "#{self.width}x#{self.height}>" b.quality self.quality.to_i end elsif fit == 'crop' image.combine_options do |b| b.resize "#{self.width}x#{self.height}^" b.gravity self.gravity b.extent "#{self.width}x#{self.height}>" b.quality self.quality.to_i end else raise 'invalid fit' end image.write(output_filepath) @transformed_filepath = output_filepath end def transformed_filename ext = ::File.extname(self.original_filepath) "#{self.fit}_#{self.crop}_#{self.width}_#{self.height}_#{self.quality}#{ext}" end def transformation_with_fallback @transformation_with_fallback ||= (self.transformation || {}).with_indifferent_access end CROP_TO_GRAVITY = { 'center' => 'Center', 'top' => 'North', 'left' => 'West', 'right' => 'East', 'bottom' => 'South', }.freeze def crop self.transformation_with_fallback[:crop].presence || 'center' end def gravity CROP_TO_GRAVITY[self.crop] || 'Center' end def width self.transformation_with_fallback[:width].to_s end def height self.transformation_with_fallback[:height].to_s end def quality self.transformation_with_fallback[:quality] || '75' end def fit self.transformation_with_fallback[:fit] end def fit_with_default self.fit || ((width.present? || height.present?) && 'resize') || nil end end delegate :valid?, :present?, :filename, :filepath, :mime_type, :length, :last_changed, :image?, :width, :height, :to => :implementation def initialize(binary_id, transformation=false) if transformation self.implementation = TransformedBinary.new(binary_id, transformation) else self.implementation = UnmodifiedBinary.new(binary_id, transformation) end end protected attr_accessor :implementation end module DeliveryMixin # GET def show binary_id = binary_id_from_params transformation = transformation_from_params meta_binary = MetaBinary.new(binary_id, transformation) if !meta_binary.present? if Fiona7.mode == :standalone && (current_binary = get_current_binary_url(binary_id)) Rails.logger.info("Redirect to #{current_binary}") redirect_to current_binary, status: 301 else not_found end elsif !meta_binary.valid? bad_request elsif stale?(:last_modified => meta_binary.last_changed) && true filename = meta_binary.filename filepath = meta_binary.filepath mime_type = meta_binary.mime_type send_file(File.expand_path(filepath), { :type => mime_type, :filename => filename, :disposition => 'inline' }) end end # HEAD def query binary_id = binary_id_from_params transformation = transformation_from_params meta_binary = BinaryHandling::MetaBinary.new(binary_id, transformation) if !meta_binary.valid? bad_request elsif !meta_binary.present? not_found else simple_set_header('Content-Type', meta_binary.mime_type) simple_set_header('Content-Length', meta_binary.length) simple_set_header('Cache-Control', 'no-transform,public,max-age=300,s-maxage=900') head_ok end end protected def bad_request head 400 end def not_found head 404 end def head_ok head 200 end def get_current_binary_url(binary_id) current_binary_url = nil blob_obj = Fiona7::WriteObj.find(binary_id.to_i) rescue nil if blob_obj binary_obj = blob_obj.parent.parent rescue nil if binary_obj current_binary_url = Obj.find(binary_obj.obj_id).blob.url rescue nil end end current_binary_url end end class EmbeddedServer class << self attr_accessor :enable end self.enable = false HOST = 'localhost' PORT = 7104 extend MonitorMixin require 'webrick' class BinaryServerlet < ::WEBrick::HTTPServlet::AbstractServlet class RequestHandler include Fiona7::BinaryHandling::DeliveryMixin def initialize(request, response) self.request = request self.response = response end protected attr_accessor :request, :response def binary_id_from_params # TODO: handle malformed input match = /\/_b\/([0-9]+)(\/(.*)(\.[a-z0-9A-Z]+)?)?/.match(self.request.request_uri.to_s) if match match[1] else 0 end end def transformation_from_params if self.request.query_string ParamEncoder.new.decode( ::CGI.parse(self.request.query_string)["t"].try(:first) ) end end def simple_set_header(name, value) self.response[name] = value end # minimal stubs for Rails API below def stale?(*args) true end def head(status) self.response.status = status end def send_file(filepath, options={}) self.response['Content-Type'] = options[:type] self.response['Content-Length'] = ::File.size(filepath) self.response['Content-Disposition'] = "#{options[:disposition]}; filename=\"#{options[:filename]}\"" self.response.body = File.read(filepath) self.response.status = 200 end end def do_GET(request, response) RequestHandler.new(request, response).show end def do_HEAD(request, response) RequestHandler.new(request, response).query end end def self.running_instance if !self.enable return HOST, PORT end self.synchronize do @server_thread ||= self.wait_for_server Kernel.at_exit do Process.kill("INT", @server_thread) Process.kill("KILL", @server_thread) end return HOST, PORT end end def self.wait_for_server pid = Process.fork do self.run_server end # wait for webrick! sleep 1 pid end def self.run_server server = ::WEBrick::HTTPServer.new({ :Port => PORT, :DocumentRoot => '/dev/null', #Logger: WEBrick::Log.new(Logger.new(nil)), AccessLog: [] }) server.mount '/', BinaryServerlet Signal.trap('INT') do server.shutdown exit! end server.start end end class UrlGenerator attr_reader :blob_id, :access_type, :verb, :transformation def initialize(blob_id, access_type, verb, transformation) self.blob_id = blob_id self.access_type = access_type self.verb = verb self.transformation = transformation end def generate if server_detected? hosted_server_url else embedded_server_url end end protected attr_writer :blob_id, :access_type, :verb, :transformation def blob if Fiona7.mode == :legacy @blob ||= Fiona7::EditedObj.find(blob_id.to_i) else @blob ||= Fiona7::InternalReleasedObj.find(blob_id.to_i) end end def filename self.blob.filename rescue ActiveRecord::RecordNotFound "null.null" end def server_detected? Fiona7::Middleware::ServerDetectionMiddleware.server_detected? end def hosted_server_url # TODO: use middleware for storing url host = Fiona7::Middleware::ServerDetectionMiddleware.server_name port = Fiona7::Middleware::ServerDetectionMiddleware.server_port generate_url(host, port) end def embedded_server_url host, port = EmbeddedServer.running_instance generate_url(host, port) end def generate_url(host, port=80) options = {} options[:host] = host options[:port] = port options[:path] = "/_b/#{self.blob_id}/#{self.filename}" if self.transformation.present? self.validate_transformation! options[:query] = self.encode_query_string end if port == 443 URI::HTTPS.build(options).to_s else URI::HTTP.build(options).to_s end end def encode_query_string token = ParamEncoder.new.encode(self.transformation) return "t=#{token}" end def validate_transformation! MetaBinary.new(self.blob_id, self.transformation).valid? || raise(Scrivito::TransformationDefinitionError.new("Invalid transformation", "binary.unprocessable.image.transform.config.invalid")) end end end end module Scrivito # This class had been reworked to support uploads directly # into CMS and not into S3 class Binary end end