mirror of
https://github.com/discourse/discourse.git
synced 2025-05-24 03:36:18 +08:00
REFACTOR: upload workflow creation into UploadCreator
- Automatically convert large-ish PNG/BMP to JPEG - Updated fast_image to latest version
This commit is contained in:
255
lib/upload_creator.rb
Normal file
255
lib/upload_creator.rb
Normal file
@ -0,0 +1,255 @@
|
||||
require "fastimage"
|
||||
require_dependency "image_sizer"
|
||||
|
||||
class UploadCreator
|
||||
|
||||
TYPES_CONVERTED_TO_JPEG ||= %i{bmp png}
|
||||
|
||||
WHITELISTED_SVG_ELEMENTS ||= %w{
|
||||
circle clippath defs ellipse g line linearGradient path polygon polyline
|
||||
radialGradient rect stop svg text textpath tref tspan use
|
||||
}
|
||||
|
||||
# Available options
|
||||
# - type (string)
|
||||
# - content_type (string)
|
||||
# - origin (string)
|
||||
# - is_attachment_for_group_message (boolean)
|
||||
# - for_theme (boolean)
|
||||
def initialize(file, filename, opts = {})
|
||||
@upload = Upload.new
|
||||
@file = file
|
||||
@filename = filename
|
||||
@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!
|
||||
else
|
||||
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]
|
||||
|
||||
if FileHelper.is_image?(@filename)
|
||||
@upload.width, @upload.height = ImageSizer.resize(*@image_info.size)
|
||||
end
|
||||
|
||||
if @opts[:is_attachment_for_group_message]
|
||||
@upload.is_attachment_for_group_message = true
|
||||
end
|
||||
|
||||
if @opts[:for_theme]
|
||||
@upload.for_theme = true
|
||||
end
|
||||
|
||||
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! rescue nil
|
||||
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
|
||||
|
||||
def should_convert_to_jpeg?
|
||||
TYPES_CONVERTED_TO_JPEG.include?(@image_info.type) &&
|
||||
@image_info.size.min > 720 &&
|
||||
SiteSetting.png_to_jpg_quality < 100
|
||||
end
|
||||
|
||||
def convert_to_jpeg!
|
||||
jpeg_tempfile = Tempfile.new(["image", ".jpg"])
|
||||
|
||||
OptimizedImage.ensure_safe_paths!(@file.path, jpeg_tempfile.path)
|
||||
Discourse::Utils.execute_command('convert', @file.path, '-quality', SiteSetting.png_to_jpg_quality.to_s, jpeg_tempfile.path)
|
||||
|
||||
# keep the JPEG if it's at least 15% smaller
|
||||
if File.size(jpeg_tempfile.path) < filesize * 0.85
|
||||
@image_info = FastImage.new(jpeg_tempfile)
|
||||
@file = jpeg_tempfile
|
||||
@filename = (File.basename(@filename, ".*").presence || I18n.t("image").presence || "image") + ".jpg"
|
||||
@opts[:content_type] = "image/jpeg"
|
||||
else
|
||||
jpeg_tempfile.close! rescue nil
|
||||
end
|
||||
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_crop?
|
||||
Upload::CROPPED_TYPES.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
|
||||
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)
|
||||
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)
|
||||
ImageOptim.new.optimize_image!(@file.path)
|
||||
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
|
Reference in New Issue
Block a user