discourse/app/models/optimized_image.rb
Gerhard Schlager 241bf48497 DEV: Allow rebakes to generate optimized images at the same time
Previously only Sidekiq was allowed to generate more than one optimized image at the same time per machine. This adds an easy mechanism to allow the same in rake tasks and other tools.
2024-01-16 14:33:16 +01:00

389 lines
10 KiB
Ruby

# frozen_string_literal: true
class OptimizedImage < ActiveRecord::Base
include HasUrl
belongs_to :upload
# BUMP UP if optimized image algorithm changes
VERSION = 2
URL_REGEX ||= %r{(/optimized/\dX[/\.\w]*/([a-zA-Z0-9]+)[\.\w]*)}
def self.lock(upload_id, width, height)
@hostname ||= Discourse.os_hostname
# note, the extra lock here ensures we only optimize one image per machine on webs
# this can very easily lead to runaway CPU so slowing it down is beneficial and it is hijacked
#
# we can not afford this blocking in Sidekiq cause it can lead to starvation
if lock_per_machine?
DistributedMutex.synchronize("optimized_image_host_#{@hostname}") do
DistributedMutex.synchronize("optimized_image_#{upload_id}_#{width}_#{height}") { yield }
end
else
DistributedMutex.synchronize("optimized_image_#{upload_id}_#{width}_#{height}") { yield }
end
end
def self.lock_per_machine?
return @lock_per_machine if defined?(@lock_per_machine)
@lock_per_machine = !Sidekiq.server?
end
def self.lock_per_machine=(value)
@lock_per_machine = value
end
def self.create_for(upload, width, height, opts = {})
return if width <= 0 || height <= 0
return if upload.try(:sha1).blank?
# no extension so try to guess it
upload.fix_image_extension if (!upload.extension)
if !upload.extension.match?(IM_DECODERS) && upload.extension != "svg"
if !opts[:raise_on_error]
# nothing to do ... bad extension, not an image
return
else
raise InvalidAccess
end
end
# prefer to look up the thumbnail without grabbing any locks
thumbnail = find_by(upload_id: upload.id, width: width, height: height)
# correct bad thumbnail if needed
if thumbnail && (thumbnail.url.blank? || thumbnail.version != VERSION)
thumbnail.destroy!
thumbnail = nil
end
return thumbnail if thumbnail
store = Discourse.store
# create the thumbnail otherwise
original_path = store.path_for(upload)
if original_path.blank?
# download is protected with a DistributedMutex
external_copy = store.download_safe(upload)
original_path = external_copy&.path
end
lock(upload.id, width, height) do
# may have been generated since we got the lock
thumbnail = find_by(upload_id: upload.id, width: width, height: height)
# return the previous thumbnail if any
return thumbnail if thumbnail
if original_path.blank?
Rails.logger.error("Could not find file in the store located at url: #{upload.url}")
else
# create a temp file with the same extension as the original
extension = ".#{opts[:format] || upload.extension}"
return nil if extension.length == 1
temp_file = Tempfile.new(["discourse-thumbnail", extension])
temp_path = temp_file.path
target_quality =
upload.target_image_quality(original_path, SiteSetting.image_preview_jpg_quality)
opts = opts.merge(quality: target_quality) if target_quality
opts = opts.merge(upload_id: upload.id)
if upload.extension == "svg"
FileUtils.cp(original_path, temp_path)
resized = true
elsif opts[:crop]
resized = crop(original_path, temp_path, width, height, opts)
else
resized = resize(original_path, temp_path, width, height, opts)
end
if resized
thumbnail =
OptimizedImage.create!(
upload_id: upload.id,
sha1: Upload.generate_digest(temp_path),
extension: extension,
width: width,
height: height,
url: "",
filesize: File.size(temp_path),
version: VERSION,
)
# store the optimized image and update its url
File.open(temp_path) do |file|
url = store.store_optimized_image(file, thumbnail, nil, secure: upload.secure?)
if url.present?
thumbnail.url = url
thumbnail.save
else
Rails.logger.error(
"Failed to store optimized image of size #{width}x#{height} from url: #{upload.url}\nTemp image path: #{temp_path}",
)
end
end
end
# close && remove temp file
temp_file.close!
end
# make sure we remove the cached copy from external stores
external_copy&.close if store.external?
thumbnail
end
end
def destroy
OptimizedImage.transaction do
Discourse.store.remove_optimized_image(self) if self.upload
super
end
end
def local?
!(url =~ %r{\A(https?:)?//})
end
def calculate_filesize
path =
if local?
Discourse.store.path_for(self)
else
Discourse.store.download!(self).path
end
File.size(path)
end
def filesize
if size = read_attribute(:filesize)
size
else
size = calculate_filesize
write_attribute(:filesize, size)
update_columns(filesize: size) if !new_record?
size
end
end
def self.safe_path?(path)
# this matches instructions which call #to_s
path = path.to_s
return false if path != File.expand_path(path)
return false if path !~ %r{\A[\w\-\./]+\z}m
true
end
def self.ensure_safe_paths!(*paths)
paths.each { |path| raise Discourse::InvalidAccess unless safe_path?(path) }
end
IM_DECODERS ||= /\A(jpe?g|png|ico|gif|webp|avif)\z/i
def self.prepend_decoder!(path, ext_path = nil, opts = nil)
opts ||= {}
# This logic is a little messy but the result of using mocks for most
# of the image tests. The idea here is you shouldn't trust the "original"
# path of a file to figure out its extension. However, in certain cases
# such as generating the loading upload thumbnail, we force the format,
# and this allows us to use the forced format in that case.
extension = nil
if (opts[:format] && path != ext_path)
extension = File.extname(path)[1..-1]
else
extension = File.extname(opts[:filename] || ext_path || path)[1..-1]
end
if !extension || !extension.match?(IM_DECODERS)
raise Discourse::InvalidAccess.new("Unsupported extension: #{extension}")
end
"#{extension}:#{path}"
end
def self.thumbnail_or_resize
SiteSetting.strip_image_metadata ? "thumbnail" : "resize"
end
def self.resize_instructions(from, to, dimensions, opts = {})
ensure_safe_paths!(from, to)
# note FROM my not be named correctly
from = prepend_decoder!(from, to, opts)
to = prepend_decoder!(to, to, opts)
instructions = ["convert", "#{from}[0]"]
instructions << "-colors" << opts[:colors].to_s if opts[:colors]
instructions << "-quality" << opts[:quality].to_s if opts[:quality]
# NOTE: ORDER is important!
instructions.concat(
%W[
-auto-orient
-gravity
center
-background
transparent
-#{thumbnail_or_resize}
#{dimensions}^
-extent
#{dimensions}
-interpolate
catrom
-unsharp
2x0.5+0.7+0
-interlace
none
-profile
#{File.join(Rails.root, "vendor", "data", "RT_sRGB.icm")}
#{to}
],
)
end
def self.crop_instructions(from, to, dimensions, opts = {})
ensure_safe_paths!(from, to)
from = prepend_decoder!(from, to, opts)
to = prepend_decoder!(to, to, opts)
instructions = %W{
convert
#{from}[0]
-auto-orient
-gravity
north
-background
transparent
-#{thumbnail_or_resize}
#{dimensions}^
-crop
#{dimensions}+0+0
-unsharp
2x0.5+0.7+0
-interlace
none
-profile
#{File.join(Rails.root, "vendor", "data", "RT_sRGB.icm")}
}
instructions << "-quality" << opts[:quality].to_s if opts[:quality]
instructions << to
end
def self.downsize_instructions(from, to, dimensions, opts = {})
ensure_safe_paths!(from, to)
from = prepend_decoder!(from, to, opts)
to = prepend_decoder!(to, to, opts)
%W{
convert
#{from}[0]
-auto-orient
-gravity
center
-background
transparent
-interlace
none
-resize
#{dimensions}
-profile
#{File.join(Rails.root, "vendor", "data", "RT_sRGB.icm")}
#{to}
}
end
def self.resize(from, to, width, height, opts = {})
optimize("resize", from, to, "#{width}x#{height}", opts)
end
def self.crop(from, to, width, height, opts = {})
optimize("crop", from, to, "#{width}x#{height}", opts)
end
def self.downsize(from, to, dimensions, opts = {})
optimize("downsize", from, to, dimensions, opts)
end
def self.optimize(operation, from, to, dimensions, opts = {})
method_name = "#{operation}_instructions"
instructions = self.public_send(method_name.to_sym, from, to, dimensions, opts)
convert_with(instructions, to, opts)
end
MAX_PNGQUANT_SIZE = 500_000
MAX_CONVERT_SECONDS = 20
def self.convert_with(instructions, to, opts = {})
Discourse::Utils.execute_command(
"nice",
"-n",
"10",
*instructions,
timeout: MAX_CONVERT_SECONDS,
)
allow_pngquant = to.downcase.ends_with?(".png") && File.size(to) < MAX_PNGQUANT_SIZE
FileHelper.optimize_image!(to, allow_pngquant: allow_pngquant)
true
rescue => e
if opts[:raise_on_error]
raise e
else
error = +"Failed to optimize image:"
if e.message =~ /\Aconvert:([^`]+)/
error << $1
else
error << " unknown reason"
end
Discourse.warn(
error,
upload_id: opts[:upload_id],
location: to,
error_message: e.message,
instructions: instructions,
)
false
end
end
end
# == Schema Information
#
# Table name: optimized_images
#
# id :integer not null, primary key
# sha1 :string(40) not null
# extension :string(10) not null
# width :integer not null
# height :integer not null
# upload_id :integer not null
# url :string not null
# filesize :integer
# etag :string
# version :integer
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_optimized_images_on_etag (etag)
# index_optimized_images_on_upload_id (upload_id)
# index_optimized_images_on_upload_id_and_width_and_height (upload_id,width,height) UNIQUE
#