discourse/app/models/optimized_image.rb

357 lines
9.9 KiB
Ruby
Raw Normal View History

# frozen_string_literal: true
2013-06-16 03:39:48 -05:00
class OptimizedImage < ActiveRecord::Base
include HasUrl
2013-06-16 03:39:48 -05:00
belongs_to :upload
# BUMP UP if optimized image algorithm changes
VERSION = 2
URL_REGEX ||= /(\/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 Sidekiq.server?
DistributedMutex.synchronize("optimized_image_#{upload_id}_#{width}_#{height}") do
yield
end
else
DistributedMutex.synchronize("optimized_image_host_#{@hostname}") do
DistributedMutex.synchronize("optimized_image_#{upload_id}_#{width}_#{height}") do
yield
end
end
end
end
2017-07-27 20:20:09 -05:00
def self.create_for(upload, width, height, opts = {})
2013-11-05 12:04:47 -06:00
return unless width > 0 && height > 0
return if upload.try(:sha1).blank?
2013-07-07 18:39:08 -05:00
# no extension so try to guess it
if (!upload.extension)
upload.fix_image_extension
end
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
# create the thumbnail otherwise
original_path = Discourse.store.path_for(upload)
if original_path.blank?
# download is protected with a DistributedMutex
external_copy = Discourse.store.download(upload) rescue nil
original_path = external_copy.try(: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}"
if extension.length == 1
return nil
end
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
if upload.extension == "svg"
FileUtils.cp(original_path, temp_path)
resized = true
elsif opts[:crop]
resized = crop(original_path, temp_path, width, height, opts)
2013-11-05 12:04:47 -06:00
else
resized = resize(original_path, temp_path, width, height, opts)
2013-11-05 12:04:47 -06:00
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|
FEATURE: Secure media allowing duplicated uploads with category-level privacy and post-based access rules (#8664) ### General Changes and Duplication * We now consider a post `with_secure_media?` if it is in a read-restricted category. * When uploading we now set an upload's secure status straight away. * When uploading if `SiteSetting.secure_media` is enabled, we do not check to see if the upload already exists using the `sha1` digest of the upload. The `sha1` column of the upload is filled with a `SecureRandom.hex(20)` value which is the same length as `Upload::SHA1_LENGTH`. The `original_sha1` column is filled with the _real_ sha1 digest of the file. * Whether an upload `should_be_secure?` is now determined by whether the `access_control_post` is `with_secure_media?` (if there is no access control post then we leave the secure status as is). * When serializing the upload, we now cook the URL if the upload is secure. This is so it shows up correctly in the composer preview, because we set secure status on upload. ### Viewing Secure Media * The secure-media-upload URL will take the post that the upload is attached to into account via `Guardian.can_see?` for access permissions * If there is no `access_control_post` then we just deliver the media. This should be a rare occurrance and shouldn't cause issues as the `access_control_post` is set when `link_post_uploads` is called via `CookedPostProcessor` ### Removed We no longer do any of these because we do not reuse uploads by sha1 if secure media is enabled. * We no longer have a way to prevent cross-posting of a secure upload from a private context to a public context. * We no longer have to set `secure: false` for uploads when uploading for a theme component.
2020-01-15 21:50:27 -06:00
url = Discourse.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!
2013-11-05 12:04:47 -06:00
end
# make sure we remove the cached copy from external stores
if Discourse.store.external?
external_copy&.close
end
thumbnail
end
2013-06-16 18:00:25 -05:00
end
def destroy
OptimizedImage.transaction do
Discourse.store.remove_optimized_image(self) if self.upload
super
end
end
def local?
2017-07-27 20:20:09 -05:00
!(url =~ /^(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)
if !new_record?
update_columns(filesize: size)
end
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 !~ /\A[\w\-\.\/]+\z/m
true
end
def self.ensure_safe_paths!(*paths)
paths.each do |path|
raise Discourse::InvalidAccess unless safe_path?(path)
end
end
IM_DECODERS ||= /\A(jpe?g|png|ico|gif|webp)\z/i
def self.prepend_decoder!(path, ext_path = nil, opts = nil)
2018-12-18 13:55:09 -06:00
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
raise Discourse::InvalidAccess if !extension || !extension.match?(IM_DECODERS)
"#{extension}:#{path}"
end
def self.thumbnail_or_resize
SiteSetting.strip_image_metadata ? "thumbnail" : "resize"
end
2017-07-27 20:20:09 -05:00
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]"]
if opts[:colors]
instructions << "-colors" << opts[:colors].to_s
end
if opts[:quality]
instructions << "-quality" << opts[:quality].to_s
end
# NOTE: ORDER is important!
instructions.concat(%W{
-auto-orient
-gravity center
-background transparent
-#{thumbnail_or_resize} #{dimensions}^
-extent #{dimensions}
2018-07-17 02:48:59 -05:00
-interpolate catrom
-unsharp 2x0.5+0.7+0
-interlace none
-profile #{File.join(Rails.root, 'vendor', 'data', 'RT_sRGB.icm')}
#{to}
})
2015-02-20 10:24:37 -06:00
end
2017-07-27 20:20:09 -05:00
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} #{opts[:width]}
-crop #{dimensions}+0+0
-unsharp 2x0.5+0.7+0
-interlace none
-profile #{File.join(Rails.root, 'vendor', 'data', 'RT_sRGB.icm')}
}
if opts[:quality]
instructions << "-quality" << opts[:quality].to_s
end
instructions << to
end
2017-07-27 20:20:09 -05:00
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
2017-07-27 20:20:09 -05:00
def self.resize(from, to, width, height, opts = {})
optimize("resize", from, to, "#{width}x#{height}", opts)
2015-02-20 10:24:37 -06:00
end
2017-07-27 20:20:09 -05:00
def self.crop(from, to, width, height, opts = {})
opts[:width] = width
optimize("crop", from, to, "#{width}x#{height}", opts)
end
2017-07-27 20:20:09 -05:00
def self.downsize(from, to, dimensions, opts = {})
optimize("downsize", from, to, dimensions, opts)
end
2017-07-27 20:20:09 -05:00
def self.optimize(operation, from, to, dimensions, opts = {})
method_name = "#{operation}_instructions"
2019-05-06 20:27:05 -05:00
instructions = self.public_send(method_name.to_sym, from, to, dimensions, opts)
2018-07-25 21:17:38 -05:00
convert_with(instructions, to, opts)
end
MAX_PNGQUANT_SIZE = 500_000
2018-07-25 21:17:38 -05:00
def self.convert_with(instructions, to, opts = {})
Discourse::Utils.execute_command("nice", "-n", "10", *instructions)
allow_pngquant = to.downcase.ends_with?(".png") && File.size(to) < MAX_PNGQUANT_SIZE
FileHelper.optimize_image!(to, allow_pngquant: allow_pngquant)
2015-02-20 10:24:37 -06:00
true
rescue => e
2018-07-25 21:17:38 -05:00
if opts[:raise_on_error]
raise e
else
error = +"Failed to optimize image:"
if e.message =~ /^convert:([^`]+)/
error << $1
else
error << " unknown reason"
end
Discourse.warn(error, location: to, error_message: e.message)
false
end
2015-02-20 10:24:37 -06:00
end
2013-06-16 03:39:48 -05:00
end
2013-06-16 19:48:58 -05:00
# == 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
2013-06-16 19:48:58 -05:00
#
# Indexes
#
2019-01-11 11:19:23 -06:00
# index_optimized_images_on_etag (etag)
2013-06-16 19:48:58 -05:00
# index_optimized_images_on_upload_id (upload_id)
# index_optimized_images_on_upload_id_and_width_and_height (upload_id,width,height) UNIQUE
#