discourse/app/models/optimized_image.rb
Sam 9628c3cf97 FEATURE: automatically correct extension for bad uploads
This fixes with post thumbnails on the fly
2018-08-17 14:00:27 +10:00

375 lines
10 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?
# no extension so try to guess it
if (!upload.extension)
upload.fix_image_extension
end
if !upload.extension.match?(IM_DECODERS)
if !opts[:raise_on_error]
# nothing to do ... bad extension, not an image
return
else
raise InvalidAccess
end
end
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 = ".#{upload.extension}"
if extension.length == 1
return nil
end
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
IM_DECODERS ||= /\A(jpe?g|png|tiff?|bmp|ico|gif)\z/i
def self.prepend_decoder!(path, ext_path = nil)
extension = File.extname(ext_path || path)[1..-1]
raise Discourse::InvalidAccess if !extension || !extension.match?(IM_DECODERS)
"#{extension}:#{path}"
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 FROM my not be named correctly
from = prepend_decoder!(from, to)
to = prepend_decoder!(to, to)
# NOTE: ORDER is important!
%W{
convert
#{from}[0]
-auto-orient
-gravity center
-background transparent
-#{thumbnail_or_resize} #{dimensions}^
-extent #{dimensions}
-interpolate catrom
-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)
from = prepend_decoder!(from, to)
to = prepend_decoder!(to, 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)
from = prepend_decoder!(from, to)
to = prepend_decoder!(to, 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)
method_name += "_animated"
end
instructions = self.send(method_name.to_sym, from, to, dimensions, opts)
convert_with(instructions, to, opts)
end
def self.convert_with(instructions, to, opts = {})
Discourse::Utils.execute_command(*instructions)
FileHelper.optimize_image!(to)
true
rescue => e
if opts[:raise_on_error]
raise e
else
error = +"Failed to optimize image:"
if e.message =~ /^convert:([^`]+)/
error << $1
else
error << " unknown reason"
end
Discourse.warn(error, location: to, error_message: e.message)
false
end
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
#