discourse/app/models/optimized_image.rb
Sam bfd052a317 PERF: guard so we only create one optimized image per process
This protectd sidekiq and other cases where optimized images are created on
demand so they do not dominate the machine.
2018-01-24 08:22:28 +11:00

331 lines
9.0 KiB
Ruby

require_dependency "file_helper"
require_dependency "url_helper"
require_dependency "db_helper"
require_dependency "file_store/local_store"
class OptimizedImage < ActiveRecord::Base
belongs_to :upload
# BUMP UP if optimized image algorithm changes
VERSION = 1
def self.lock(upload_id, width, height)
# note, the extra lock here ensures we only optimize one image per process
# this can very easily lead to runaway CPU so slowing it down is beneficial
@mutex ||= Mutex.new
@mutex.synchronize do
DistributedMutex.synchronize("optimized_image_#{upload_id}_#{width}_#{height}") do
yield
end
end
end
def self.create_for(upload, width, height, opts = {})
return unless width > 0 && height > 0
return if upload.try(:sha1).blank?
lock(upload.id, width, height) do
# do we already have that thumbnail?
thumbnail = find_by(upload_id: upload.id, width: width, height: height)
# make sure we have an url
if thumbnail && thumbnail.url.blank?
thumbnail.destroy
thumbnail = nil
end
# return the previous thumbnail if any
return thumbnail unless thumbnail.nil?
# create the thumbnail otherwise
original_path = Discourse.store.path_for(upload)
if original_path.blank?
external_copy = Discourse.store.download(upload) rescue nil
original_path = external_copy.try(:path)
end
if original_path.blank?
Rails.logger.error("Could not find file in the store located at url: #{upload.url}")
else
# create a temp file with the same extension as the original
extension = File.extname(original_path)
temp_file = Tempfile.new(["discourse-thumbnail", extension])
temp_path = temp_file.path
if extension =~ /\.svg$/i
FileUtils.cp(original_path, temp_path)
resized = true
elsif opts[:crop]
resized = crop(original_path, temp_path, width, height, opts)
else
resized = resize(original_path, temp_path, width, height, opts)
end
if resized
thumbnail = OptimizedImage.create!(
upload_id: upload.id,
sha1: Upload.generate_digest(temp_path),
extension: extension,
width: width,
height: height,
url: "",
)
# store the optimized image and update its url
File.open(temp_path) do |file|
url = Discourse.store.store_optimized_image(file, thumbnail)
if url.present?
thumbnail.url = url
thumbnail.save
end
end
end
# close && remove temp file
temp_file.close!
end
# make sure we remove the cached copy from external stores
if Discourse.store.external?
external_copy.try(:close!) rescue nil
end
thumbnail
end
end
def destroy
OptimizedImage.transaction do
Discourse.store.remove_optimized_image(self)
super
end
end
def local?
!(url =~ /^(https?:)?\/\//)
end
def self.safe_path?(path)
# this matches instructions which call #to_s
path = path.to_s
return false if path != File.expand_path(path)
return false if path !~ /\A[\w\-\.\/]+\z/m
true
end
def self.ensure_safe_paths!(*paths)
paths.each do |path|
raise Discourse::InvalidAccess unless safe_path?(path)
end
end
def self.thumbnail_or_resize
SiteSetting.strip_image_metadata ? "thumbnail" : "resize"
end
def self.resize_instructions(from, to, dimensions, opts = {})
ensure_safe_paths!(from, to)
# NOTE: ORDER is important!
%W{
convert
#{from}[0]
-auto-orient
-gravity center
-background transparent
-#{thumbnail_or_resize} #{dimensions}^
-extent #{dimensions}
-interpolate bicubic
-unsharp 2x0.5+0.7+0
-interlace none
-quality 98
-profile #{File.join(Rails.root, 'vendor', 'data', 'RT_sRGB.icm')}
#{to}
}
end
def self.resize_instructions_animated(from, to, dimensions, opts = {})
ensure_safe_paths!(from, to)
%W{
gifsicle
--colors=256
--resize-fit #{dimensions}
--optimize=3
--output #{to}
#{from}
}
end
def self.crop_instructions(from, to, dimensions, opts = {})
ensure_safe_paths!(from, to)
%W{
convert
#{from}[0]
-auto-orient
-gravity north
-background transparent
-#{thumbnail_or_resize} #{opts[:width]}
-crop #{dimensions}+0+0
-unsharp 2x0.5+0.7+0
-interlace none
-quality 98
-profile #{File.join(Rails.root, 'vendor', 'data', 'RT_sRGB.icm')}
#{to}
}
end
def self.crop_instructions_animated(from, to, dimensions, opts = {})
ensure_safe_paths!(from, to)
%W{
gifsicle
--crop 0,0+#{dimensions}
--colors=256
--optimize=3
--output #{to}
#{from}
}
end
def self.downsize_instructions(from, to, dimensions, opts = {})
ensure_safe_paths!(from, to)
%W{
convert
#{from}[0]
-auto-orient
-gravity center
-background transparent
-interlace none
-resize #{dimensions}
-profile #{File.join(Rails.root, 'vendor', 'data', 'RT_sRGB.icm')}
#{to}
}
end
def self.downsize_instructions_animated(from, to, dimensions, opts = {})
resize_instructions_animated(from, to, dimensions, opts)
end
def self.resize(from, to, width, height, opts = {})
optimize("resize", from, to, "#{width}x#{height}", opts)
end
def self.crop(from, to, width, height, opts = {})
opts[:width] = width
optimize("crop", from, to, "#{width}x#{height}", opts)
end
def self.downsize(from, to, dimensions, opts = {})
optimize("downsize", from, to, dimensions, opts)
end
def self.optimize(operation, from, to, dimensions, opts = {})
method_name = "#{operation}_instructions"
if !!opts[:allow_animation] && (from =~ /\.GIF$/i || opts[:filename] =~ /\.GIF$/i)
method_name += "_animated"
end
instructions = self.send(method_name.to_sym, from, to, dimensions, opts)
convert_with(instructions, to)
end
def self.convert_with(instructions, to)
begin
Discourse::Utils.execute_command(*instructions)
rescue
return false
end
FileHelper.optimize_image!(to)
true
rescue
Rails.logger.error("Could not optimize image: #{to}")
false
end
def self.migrate_to_new_scheme(limit = nil)
problems = []
if SiteSetting.migrate_to_new_scheme
max_file_size_kb = SiteSetting.max_image_size_kb.kilobytes
local_store = FileStore::LocalStore.new
scope = OptimizedImage.includes(:upload)
.where("url NOT LIKE '%/optimized/_X/%'")
.order(id: :desc)
scope.limit(limit) if limit
scope.each do |optimized_image|
begin
# keep track of the url
previous_url = optimized_image.url.dup
# where is the file currently stored?
external = previous_url =~ /^\/\//
# download if external
if external
url = SiteSetting.scheme + ":" + previous_url
file = FileHelper.download(
url,
max_file_size: max_file_size_kb,
tmp_file_name: "discourse",
follow_redirect: true
) rescue nil
path = file.path
else
path = local_store.path_for(optimized_image)
file = File.open(path)
end
# compute SHA if missing
if optimized_image.sha1.blank?
optimized_image.sha1 = Upload.generate_digest(path)
end
# optimize if image
FileHelper.optimize_image!(path)
# store to new location & update the filesize
File.open(path) do |f|
optimized_image.url = Discourse.store.store_optimized_image(f, optimized_image)
optimized_image.save
end
# remap the URLs
DbHelper.remap(UrlHelper.absolute(previous_url), optimized_image.url) unless external
DbHelper.remap(previous_url, optimized_image.url)
# remove the old file (when local)
unless external
FileUtils.rm(path, force: true) rescue nil
end
rescue => e
problems << { optimized_image: optimized_image, ex: e }
# just ditch the optimized image if there was any errors
optimized_image.destroy
ensure
file.try(:unlink) rescue nil
file.try(:close) rescue nil
end
end
end
problems
end
end
# == Schema Information
#
# Table name: optimized_images
#
# id :integer not null, primary key
# sha1 :string(40) not null
# extension :string(10) not null
# width :integer not null
# height :integer not null
# upload_id :integer not null
# url :string(255) not null
#
# Indexes
#
# index_optimized_images_on_upload_id (upload_id)
# index_optimized_images_on_upload_id_and_width_and_height (upload_id,width,height) UNIQUE
#