mirror of
https://github.com/discourse/discourse.git
synced 2024-11-25 09:35:14 +08:00
142571bba0
* `rescue nil` is a really bad pattern to use in our code base. We should rescue errors that we expect the code to throw and not rescue everything because we're unsure of what errors the code would throw. This would reduce the amount of pain we face when debugging why something isn't working as expexted. I've been bitten countless of times by errors being swallowed as a result during debugging sessions.
331 lines
9.0 KiB
Ruby
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)
|
|
@hostname ||= `hostname`.strip rescue "unknown"
|
|
# note, the extra lock here ensures we only optimize one image per machine
|
|
# this can very easily lead to runaway CPU so slowing it down is beneficial
|
|
DistributedMutex.synchronize("optimized_image_host_#{@hostname}") 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&.close
|
|
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)
|
|
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&.unlink
|
|
file&.close
|
|
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 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
|
|
#
|