discourse/lib/upload_creator.rb
Guo Xiang Tan 142571bba0 Remove use of rescue nil.
* `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.
2018-04-02 13:52:51 +08:00

283 lines
8.8 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)
# - content_type (string)
# - origin (string)
# - for_group_message (boolean)
# - for_theme (boolean)
# - for_private_message (boolean)
# - pasted (boolean)
def initialize(file, filename, opts = {})
@file = file
@filename = filename || ''
@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
if FileHelper.is_image?(@filename)
extract_image_info!
return @upload if @upload.errors.present?
if @filename[/\.svg$/i]
whitelist_svg!
elsif !Rails.env.test?
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
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?
# create the upload otherwise
@upload = Upload.new
@upload.user_id = user_id
@upload.original_filename = @filename
@upload.filesize = filesize
@upload.sha1 = sha1
@upload.url = ""
@upload.origin = @opts[:origin][0...1000] if @opts[:origin]
@upload.extension = File.extname(@filename)[1..10]
if FileHelper.is_image?(@filename)
@upload.width, @upload.height = ImageSizer.resize(*@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]
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, @opts[:content_type])
if url.present?
@upload.url = url
@upload.save
else
@upload.errors.add(:url, I18n.t("upload.store_failure", upload_id: @upload.id, user_id: user_id))
end
end
if @upload.errors.empty? && FileHelper.is_image?(@filename) && @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"])
OptimizedImage.ensure_safe_paths!(@file.path, jpeg_tempfile.path)
begin
execute_convert(@file, jpeg_tempfile)
rescue
# retry with debugging enabled
execute_convert(@file, jpeg_tempfile, true)
end
# keep the JPEG if it's at least 15% smaller
if File.size(jpeg_tempfile.path) < filesize * 0.85
@file = jpeg_tempfile
@filename = (File.basename(@filename, ".*").presence || I18n.t("image").presence || "image") + ".jpg"
@opts[:content_type] = "image/jpeg"
extract_image_info!
else
jpeg_tempfile&.close
end
end
def execute_convert(input_file, output_file, debug = false)
command = ['convert', input_file.path,
'-auto-orient',
'-background', 'white',
'-interlace', 'none',
'-flatten',
'-quality', SiteSetting.png_to_jpg_quality.to_s]
command << '-debug' << 'all' if debug
command << output_file.path
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)
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!
OptimizedImage.ensure_safe_paths!(@file.path)
Discourse::Utils.execute_command('convert', @file.path, '-auto-orient', @file.path)
extract_image_info!
end
def should_crop?
TYPES_TO_CROP.include?(@opts[:type])
end
def crop!
max_pixel_ratio = Discourse::PIXEL_RATIOS.max
case @opts[:type]
when "avatar"
width = height = Discourse.avatar_sizes.max
OptimizedImage.resize(@file.path, @file.path, width, height, filename: @filename, 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, 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, allow_animation: allow_animation)
when "custom_emoji"
OptimizedImage.downsize(@file.path, @file.path, "100x100\\>", filename: @filename, 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::Worker::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