mirror of
https://github.com/discourse/discourse.git
synced 2024-11-27 17:53:40 +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.
283 lines
8.8 KiB
Ruby
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
|