discourse/lib/upload_creator.rb
Sam 9ab1fb7dfc FEATURE: correctly store width and height on uploads
Previously we used width and height for thumbnails, new code ensures

1. We auto correct width and height
2. We added extra columns for thumbnail_width and height, this is determined
 by actual upload and no longer passed in as a side effect
3. Optimized Image now stores filesize which can be used for analysis, decisions

Also

- fixes Android image manifest as a side effect
- fixes issue where a thumbnail generated that is smaller than the upload is no longer used
2018-08-28 12:59:22 +10:00

337 lines
10 KiB
Ruby

require "fastimage"
require_dependency "image_sizer"
class UploadCreator
TYPES_CONVERTED_TO_JPEG ||= %i{bmp png}
TYPES_TO_CROP ||= %w{avatar card_background custom_emoji profile_background}.each(&:freeze)
WHITELISTED_SVG_ELEMENTS ||= %w{
circle clippath defs ellipse g line linearGradient path polygon polyline
radialGradient rect stop svg text textpath tref tspan use
}.each(&:freeze)
# Available options
# - type (string)
# - origin (string)
# - for_group_message (boolean)
# - for_theme (boolean)
# - for_private_message (boolean)
# - pasted (boolean)
# - for_export (boolean)
def initialize(file, filename, opts = {})
@file = file
@filename = (filename || "").gsub(/[^[:print:]]/, "")
@upload = Upload.new(original_filename: @filename, filesize: 0)
@opts = opts
end
def create_for(user_id)
if filesize <= 0
@upload.errors.add(:base, I18n.t("upload.empty"))
return @upload
end
DistributedMutex.synchronize("upload_#{user_id}_#{@filename}") do
# test for image regardless of input
@image_info = FastImage.new(@file) rescue nil
is_image = FileHelper.is_image?(@filename)
is_image ||= @image_info && FileHelper.is_image?("test.#{@image_info.type}")
if is_image
extract_image_info!
return @upload if @upload.errors.present?
if @filename[/\.svg$/i]
whitelist_svg!
elsif !Rails.env.test? || @opts[:force_optimize]
convert_to_jpeg! if should_convert_to_jpeg?
downsize! if should_downsize?
return @upload if is_still_too_big?
fix_orientation! if should_fix_orientation?
crop! if should_crop?
optimize! if should_optimize?
end
# conversion may have switched the type
image_type = @image_info.type.to_s
end
# compute the sha of the file
sha1 = Upload.generate_digest(@file)
# do we already have that upload?
@upload = Upload.find_by(sha1: sha1)
# make sure the previous upload has not failed
if @upload && @upload.url.blank?
@upload.destroy
@upload = nil
end
# return the previous upload if any
return @upload unless @upload.nil?
fixed_original_filename = nil
if is_image
current_extension = File.extname(@filename).downcase.sub("jpeg", "jpg")
expected_extension = ".#{image_type}".downcase.sub("jpeg", "jpg")
# we have to correct original filename here, no choice
# otherwise validation will fail and we can not save
# TODO decide if we only run the validation on the extension
if current_extension != expected_extension
basename = File.basename(@filename, current_extension)
if basename.length == 0
basename = "image"
end
fixed_original_filename = "#{basename}#{expected_extension}"
end
end
# create the upload otherwise
@upload = Upload.new
@upload.user_id = user_id
@upload.original_filename = fixed_original_filename || @filename
@upload.filesize = filesize
@upload.sha1 = sha1
@upload.url = ""
@upload.origin = @opts[:origin][0...1000] if @opts[:origin]
@upload.extension = image_type || File.extname(@filename)[1..10]
if is_image
@upload.thumbnail_width, @upload.thumbnail_height = ImageSizer.resize(*@image_info.size)
@upload.width, @upload.height = @image_info.size
end
@upload.for_private_message = true if @opts[:for_private_message]
@upload.for_group_message = true if @opts[:for_group_message]
@upload.for_theme = true if @opts[:for_theme]
@upload.for_export = true if @opts[:for_export]
return @upload unless @upload.save
# store the file and update its url
File.open(@file.path) do |f|
url = Discourse.store.store_upload(f, @upload)
if url.present?
@upload.update!(url: url)
else
@upload.errors.add(:url, I18n.t("upload.store_failure", upload_id: @upload.id, user_id: user_id))
end
end
if @upload.errors.empty? && is_image && @opts[:type] == "avatar"
Jobs.enqueue(:create_avatar_thumbnails, upload_id: @upload.id, user_id: user_id)
end
@upload
end
ensure
@file&.close
end
def extract_image_info!
@image_info = FastImage.new(@file) rescue nil
@file.rewind
if @image_info.nil?
@upload.errors.add(:base, I18n.t("upload.images.not_supported_or_corrupted"))
elsif filesize <= 0
@upload.errors.add(:base, I18n.t("upload.empty"))
elsif pixels == 0
@upload.errors.add(:base, I18n.t("upload.images.size_not_found"))
end
end
MIN_PIXELS_TO_CONVERT_TO_JPEG ||= 1280 * 720
def should_convert_to_jpeg?
return false if !TYPES_CONVERTED_TO_JPEG.include?(@image_info.type)
return true if @opts[:pasted]
return false if SiteSetting.png_to_jpg_quality == 100
pixels > MIN_PIXELS_TO_CONVERT_TO_JPEG
end
def convert_to_jpeg!
jpeg_tempfile = Tempfile.new(["image", ".jpg"])
from = @file.path
to = jpeg_tempfile.path
OptimizedImage.ensure_safe_paths!(from, to)
from = OptimizedImage.prepend_decoder!(from, nil, filename: "image.#{@image_info.type}")
to = OptimizedImage.prepend_decoder!(to)
begin
execute_convert(from, to)
rescue
# retry with debugging enabled
execute_convert(from, to, true)
end
# keep the JPEG if it's at least 15% smaller
if File.size(jpeg_tempfile.path) < filesize * 0.85
@file = jpeg_tempfile
extract_image_info!
else
jpeg_tempfile&.close
end
end
def execute_convert(from, to, debug = false)
command = [
"convert",
from,
"-auto-orient",
"-background", "white",
"-interlace", "none",
"-flatten",
"-quality", SiteSetting.png_to_jpg_quality.to_s
]
command << "-debug" << "all" if debug
command << to
Discourse::Utils.execute_command(*command, failure_message: I18n.t("upload.png_to_jpg_conversion_failure_message"))
end
def should_downsize?
max_image_size > 0 && filesize >= max_image_size
end
def downsize!
3.times do
original_size = filesize
downsized_pixels = [pixels, max_image_pixels].min / 2
OptimizedImage.downsize(
@file.path,
@file.path,
"#{downsized_pixels}@",
filename: @filename,
allow_animation: allow_animation,
raise_on_error: true
)
extract_image_info!
return if filesize >= original_size || pixels == 0 || !should_downsize?
end
end
def is_still_too_big?
if max_image_pixels > 0 && pixels >= max_image_pixels
@upload.errors.add(:base, I18n.t("upload.images.larger_than_x_megapixels", max_image_megapixels: SiteSetting.max_image_megapixels))
true
elsif max_image_size > 0 && filesize >= max_image_size
@upload.errors.add(:base, I18n.t("upload.images.too_large", max_size_kb: SiteSetting.max_image_size_kb))
true
else
false
end
end
def whitelist_svg!
doc = Nokogiri::XML(@file)
doc.xpath(svg_whitelist_xpath).remove
File.write(@file.path, doc.to_s)
@file.rewind
end
def should_fix_orientation?
# orientation is between 1 and 8, 1 being the default
# cf. http://www.daveperrett.com/articles/2012/07/28/exif-orientation-handling-is-a-ghetto/
@image_info.orientation.to_i > 1
end
def fix_orientation!
path = @file.path
OptimizedImage.ensure_safe_paths!(path)
path = OptimizedImage.prepend_decoder!(path, nil, filename: "image.#{@image_info.type}")
Discourse::Utils.execute_command('convert', path, '-auto-orient', path)
extract_image_info!
end
def should_crop?
TYPES_TO_CROP.include?(@opts[:type])
end
def crop!
max_pixel_ratio = Discourse::PIXEL_RATIOS.max
filename_with_correct_ext = "image.#{@image_info.type}"
case @opts[:type]
when "avatar"
width = height = Discourse.avatar_sizes.max
OptimizedImage.resize(@file.path, @file.path, width, height, filename: filename_with_correct_ext, allow_animation: allow_animation)
when "profile_background"
max_width = 850 * max_pixel_ratio
width, height = ImageSizer.resize(@image_info.size[0], @image_info.size[1], max_width: max_width, max_height: max_width)
OptimizedImage.downsize(@file.path, @file.path, "#{width}x#{height}\>", filename: filename_with_correct_ext, allow_animation: allow_animation)
when "card_background"
max_width = 590 * max_pixel_ratio
width, height = ImageSizer.resize(@image_info.size[0], @image_info.size[1], max_width: max_width, max_height: max_width)
OptimizedImage.downsize(@file.path, @file.path, "#{width}x#{height}\>", filename: filename_with_correct_ext, allow_animation: allow_animation)
when "custom_emoji"
OptimizedImage.downsize(@file.path, @file.path, "100x100\>", filename: filename_with_correct_ext, allow_animation: allow_animation)
end
extract_image_info!
end
def should_optimize?
# GIF is too slow (plus, we'll soon be converting them to MP4)
# Optimizing SVG is useless
return false if @file.path =~ /\.(gif|svg)$/i
# Safeguard for large PNGs
return pixels < 2_000_000 if @file.path =~ /\.png/i
# Everything else is fine!
true
end
def optimize!
OptimizedImage.ensure_safe_paths!(@file.path)
FileHelper.optimize_image!(@file.path)
extract_image_info!
rescue ImageOptim::TimeoutExceeded
Rails.logger.warn("ImageOptim timed out while optimizing #{@filename}")
end
def filesize
File.size?(@file.path).to_i
end
def max_image_size
@max_image_size ||= SiteSetting.max_image_size_kb.kilobytes
end
def max_image_pixels
@max_image_pixels ||= SiteSetting.max_image_megapixels * 1_000_000
end
def pixels
@image_info.size&.reduce(:*).to_i
end
def allow_animation
@allow_animation ||= @opts[:type] == "avatar" ? SiteSetting.allow_animated_avatars : SiteSetting.allow_animated_thumbnails
end
def svg_whitelist_xpath
@@svg_whitelist_xpath ||= "//*[#{WHITELISTED_SVG_ELEMENTS.map { |e| "name()!='#{e}'" }.join(" and ") }]"
end
end