discourse/lib/upload_creator.rb

685 lines
20 KiB
Ruby

# frozen_string_literal: true
require "fastimage"
class UploadCreator
TYPES_TO_CROP = %w[avatar card_background custom_emoji profile_background].each(&:freeze)
ALLOWED_SVG_ELEMENTS = %w[
circle
clipPath
defs
ellipse
feGaussianBlur
filter
g
line
linearGradient
marker
path
polygon
polyline
radialGradient
rect
stop
style
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)
# - for_gravatar (boolean)
# - skip_validations (boolean)
def initialize(file, filename, opts = {})
@file = file
@filename = (filename || "").gsub(/[^[:print:]]/, "")
@upload = Upload.new(original_filename: @filename, filesize: 0)
@opts = opts
@filesize = @opts[:filesize] if @opts[:external_upload_too_big]
@opts[:validate] = (
if opts[:skip_validations].present?
!ActiveRecord::Type::Boolean.new.cast(opts[:skip_validations])
else
true
end
)
end
def create_for(user_id)
if filesize <= 0
@upload.errors.add(:base, I18n.t("upload.empty"))
return @upload
end
@image_info =
begin
FastImage.new(@file)
rescue StandardError
nil
end
is_image = FileHelper.is_supported_image?(@filename)
is_image ||= @image_info && FileHelper.is_supported_image?("test.#{@image_info.type}")
is_image = false if @opts[:for_theme]
is_thumbnail = SiteSetting.video_thumbnails_enabled && @opts[:type] == "thumbnail"
# If this is present then it means we are creating an upload record from
# an external_upload_stub and the file is > ExternalUploadManager::DOWNLOAD_LIMIT,
# so we have not downloaded it to a tempfile. no modifications can be made to the
# file in this case because it does not exist; we simply move it to its new location
# in S3
#
# FIXME: I've added a bunch of external_upload_too_big checks littered
# throughout the UploadCreator code. It would be better to have two separate
# classes with shared methods, rather than doing all these checks all over the
# place. Needs a refactor.
external_upload_too_big = @opts[:external_upload_too_big]
sha1_before_changes = Upload.generate_digest(@file) if @file
DistributedMutex.synchronize("upload_#{user_id}_#{@filename}") do
# We need to convert HEIFs early because FastImage does not consider them as images
if convert_heif_to_jpeg? && !external_upload_too_big
convert_heif!
is_image = FileHelper.is_supported_image?("test.#{@image_info.type}")
end
if is_image && !external_upload_too_big
extract_image_info!
return @upload if @upload.errors.present?
if @image_info.type == :svg
clean_svg!
elsif @image_info.type == :ico
convert_favicon_to_png!
elsif !Rails.env.test? || @opts[:force_optimize]
convert_to_jpeg! if convert_png_to_jpeg? || should_alter_quality?
fix_orientation! if should_fix_orientation?
crop! if should_crop?
optimize! if should_optimize?
downsize! if should_downsize?
return @upload if is_still_too_big?
end
# conversion may have switched the type
image_type = @image_info.type.to_s
end
# compute the sha of the file and generate a unique hash
# which is only used for secure uploads
sha1 = Upload.generate_digest(@file) if !external_upload_too_big
unique_hash = generate_fake_sha1_hash if SiteSetting.secure_uploads ||
external_upload_too_big || is_thumbnail
# we do not check for duplicate uploads if secure uploads is
# enabled because we use a unique access hash to differentiate
# between uploads instead of the sha1, and to get around various
# access/permission issues for uploads
# We do not check for duplicate uploads for video thumbnails because
# their filename needs to match with their corresponding video. This also
# enables rebuilding the html on a topic to regenerate a thumbnail.
if !SiteSetting.secure_uploads && !external_upload_too_big && !is_thumbnail
# 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
if @upload
add_metadata!
UserUpload.find_or_create_by!(user_id: user_id, upload_id: @upload.id) if user_id
return @upload
end
end
fixed_original_filename = nil
if is_image && !external_upload_too_big
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).presence || "image"
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 =
(
if (SiteSetting.secure_uploads? || external_upload_too_big || is_thumbnail)
unique_hash
else
sha1
end
)
@upload.original_sha1 = SiteSetting.secure_uploads? ? sha1 : nil
@upload.url = ""
@upload.origin = @opts[:origin][0...1000] if @opts[:origin]
@upload.extension = image_type || File.extname(@filename)[1..10]
if is_image && !external_upload_too_big
if @image_info.type.to_s == "svg"
w, h = [0, 0]
# identify can behave differently depending on how it's compiled and
# what programs (e.g. inkscape) are installed on your system.
# 'MSVG:' forces ImageMagick to use internal routines and behave
# consistently whether it's running from our docker container or not
begin
w, h =
Discourse::Utils
.execute_command(
"identify",
"-ping",
"-format",
"%w %h",
"MSVG:#{@file.path}",
timeout: Upload::MAX_IDENTIFY_SECONDS,
)
.split(" ")
.map(&:to_i)
rescue StandardError
# use default 0, 0
end
else
w, h = @image_info.size
end
@upload.thumbnail_width, @upload.thumbnail_height = ImageSizer.resize(w, h)
@upload.width, @upload.height = w, h
@upload.animated = animated?
@upload.calculate_dominant_color!(@file.path)
end
add_metadata!
if SiteSetting.secure_uploads
secure, reason =
UploadSecurity.new(@upload, @opts.merge(creating: true)).should_be_secure_with_reason
attrs = @upload.secure_params(secure, reason, "upload creator")
@upload.assign_attributes(attrs)
end
# Callbacks using this event to validate the upload or the file must add errors to the
# upload errors object.
#
# Can't do any validation of the file if external_upload_too_big because we don't have
# the actual file.
if !external_upload_too_big
DiscourseEvent.trigger(:before_upload_creation, @file, is_image, @upload, @opts[:validate])
end
return @upload unless @upload.errors.empty? && @upload.save(validate: @opts[:validate])
should_move = false
upload_changed =
if external_upload_too_big
false
else
Upload.generate_digest(@file) != sha1_before_changes
end
store = Discourse.store
if @opts[:existing_external_upload_key] && store.external?
should_move = external_upload_too_big || !upload_changed
end
if should_move
# move the file in the store instead of reuploading
url =
store.move_existing_stored_upload(
existing_external_upload_key: @opts[:existing_external_upload_key],
upload: @upload,
)
else
# store the file and update its url
File.open(@file.path) { |f| url = store.store_upload(f, @upload) }
if @opts[:existing_external_upload_key]
store.delete_file(@opts[:existing_external_upload_key])
end
end
if url.present?
@upload.url = url
@upload.save!(validate: @opts[:validate])
else
@upload.errors.add(
:url,
I18n.t("upload.store_failure", upload_id: @upload.id, user_id: user_id),
)
end
if @upload.errors.empty? && is_image && @opts[:type] == "avatar" && @upload.extension != "svg"
Jobs.enqueue(:create_avatar_thumbnails, upload_id: @upload.id)
end
if @upload.errors.empty?
UserUpload.find_or_create_by!(user_id: user_id, upload_id: @upload.id) if user_id
end
@upload
end
ensure
if @file
@file.respond_to?(:close!) ? @file.close! : @file.close
end
end
def extract_image_info!
@image_info =
begin
FastImage.new(@file)
rescue StandardError
nil
end
@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 && @image_info.type.to_s != "svg"
@upload.errors.add(:base, I18n.t("upload.images.size_not_found"))
elsif 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,
),
)
end
end
MIN_PIXELS_TO_CONVERT_TO_JPEG = 1280 * 720
def convert_png_to_jpeg?
return false unless @image_info.type == :png
return true if @opts[:pasted]
return false if SiteSetting.png_to_jpg_quality == 100
pixels > MIN_PIXELS_TO_CONVERT_TO_JPEG
end
MIN_CONVERT_TO_JPEG_BYTES_SAVED = 75_000
MIN_CONVERT_TO_JPEG_SAVING_RATIO = 0.70
def convert_favicon_to_png!
png_tempfile = Tempfile.new(%w[image .png])
from = @file.path
to = png_tempfile.path
OptimizedImage.ensure_safe_paths!(from, to)
from = OptimizedImage.prepend_decoder!(from, nil, filename: "image.#{@image_info.type}")
to = OptimizedImage.prepend_decoder!(to)
from = "#{from}[-1]" # We only want the last(largest) image of the .ico file
opts = { flatten: false } # Preserve transparency
begin
execute_convert(from, to, opts)
rescue StandardError
# retry with debugging enabled
execute_convert(from, to, opts.merge(debug: true))
end
@file.respond_to?(:close!) ? @file.close! : @file.close
@file = png_tempfile
extract_image_info!
end
def convert_to_jpeg!
return if @opts[:for_site_setting]
return if filesize < MIN_CONVERT_TO_JPEG_BYTES_SAVED
jpeg_tempfile = Tempfile.new(%w[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)
opts = {}
desired_quality = [
SiteSetting.png_to_jpg_quality,
SiteSetting.recompress_original_jpg_quality,
].compact.min
target_quality = @upload.target_image_quality(from, desired_quality)
opts = { quality: target_quality } if target_quality
begin
execute_convert(from, to, opts)
rescue StandardError
# retry with debugging enabled
execute_convert(from, to, opts.merge(debug: true))
end
new_size = File.size(jpeg_tempfile.path)
keep_jpeg = new_size < filesize * MIN_CONVERT_TO_JPEG_SAVING_RATIO
keep_jpeg &&= (filesize - new_size) > MIN_CONVERT_TO_JPEG_BYTES_SAVED
if keep_jpeg
@file.respond_to?(:close!) ? @file.close! : @file.close
@file = jpeg_tempfile
extract_image_info!
else
jpeg_tempfile.close!
end
end
def convert_heif_to_jpeg?
File.extname(@filename).downcase.match?(/\.hei(f|c)\z/)
end
def convert_heif!
jpeg_tempfile = Tempfile.new(%w[image .jpg])
from = @file.path
to = jpeg_tempfile.path
OptimizedImage.ensure_safe_paths!(from, to)
begin
execute_convert(from, to)
rescue StandardError
# retry with debugging enabled
execute_convert(from, to, { debug: true })
end
@file.respond_to?(:close!) ? @file.close! : @file.close
@file = jpeg_tempfile
extract_image_info!
end
MAX_CONVERT_FORMAT_SECONDS = 20
def execute_convert(from, to, opts = {})
command = ["magick", from, "-auto-orient", "-background", "white", "-interlace", "none"]
command << "-flatten" unless opts[:flatten] == false
command << "-debug" << "all" if opts[:debug]
command << "-quality" << opts[:quality].to_s if opts[:quality]
command << to
Discourse::Utils.execute_command(
*command,
failure_message: I18n.t("upload.png_to_jpg_conversion_failure_message"),
timeout: MAX_CONVERT_FORMAT_SECONDS,
)
end
def should_alter_quality?
return false if animated?
desired_quality =
(
if @image_info.type == :png
SiteSetting.png_to_jpg_quality
else
SiteSetting.recompress_original_jpg_quality
end
)
@upload.target_image_quality(@file.path, desired_quality).present?
end
def should_downsize?
max_image_size > 0 && filesize >= max_image_size && !animated?
end
def downsize!
3.times do
original_size = filesize
down_tempfile = Tempfile.new(["down", ".#{@image_info.type}"])
from = @file.path
to = down_tempfile.path
OptimizedImage.ensure_safe_paths!(from, to)
OptimizedImage.downsize(from, to, "50%", scale_image: true, raise_on_error: true)
@file.respond_to?(:close!) ? @file.close! : @file.close
@file = down_tempfile
extract_image_info!
return if filesize >= original_size || pixels == 0 || !should_downsize?
end
rescue StandardError
@upload.errors.add(:base, I18n.t("upload.optimize_failure_message"))
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_humanized",
max_size: ActiveSupport::NumberHelper.number_to_human_size(max_image_size),
),
)
true
else
false
end
end
def clean_svg!
doc = Nokogiri.XML(@file)
doc.xpath(svg_allowlist_xpath).remove
doc.xpath("//@*[starts-with(name(), 'on')]").remove
doc
.css("use")
.each do |use_el|
if use_el.attr("href")
use_el.remove_attribute("href") unless use_el.attr("href").starts_with?("#")
end
use_el.remove_attribute("xlink:href")
end
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
MAX_FIX_ORIENTATION_TIME = 5
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(
"magick",
path,
"-auto-orient",
path,
timeout: MAX_FIX_ORIENTATION_TIME,
)
extract_image_info!
end
def should_crop?
if %w[profile_background card_background custom_emoji].include?(@opts[:type]) && animated?
return false
end
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,
)
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,
)
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,
)
when "custom_emoji"
OptimizedImage.downsize(
@file.path,
@file.path,
"100x100\>",
filename: filename_with_correct_ext,
)
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)\z/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!
end
def filesize
@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 svg_allowlist_xpath
@@svg_allowlist_xpath ||=
"//*[#{ALLOWED_SVG_ELEMENTS.map { |e| "name()!='#{e}'" }.join(" and ")}]"
end
def add_metadata!
@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]
@upload.for_site_setting = true if @opts[:for_site_setting]
@upload.for_gravatar = true if @opts[:for_gravatar]
end
private
def animated?
return @animated if @animated != nil
@animated ||=
begin
is_animated = FastImage.animated?(@file)
type = @image_info.type.to_s
if is_animated != nil
# FastImage will return nil if it cannot determine if animated
is_animated
elsif %w[gif webp avif].include?(type)
# Only GIFs, WEBPs and a few other unsupported image types can be animated
OptimizedImage.ensure_safe_paths!(@file.path)
command = ["identify", "-ping", "-format", "%n\\n", @file.path]
frames =
begin
Discourse::Utils.execute_command(*command, timeout: Upload::MAX_IDENTIFY_SECONDS).to_i
rescue StandardError
1
end
frames > 1
else
false
end
end
end
def generate_fake_sha1_hash
SecureRandom.hex(20)
end
end