Class: OptimizedImage
- Inherits:
-
ActiveRecord::Base
- Object
- ActiveRecord::Base
- OptimizedImage
- Includes:
- HasUrl
- Defined in:
- app/models/optimized_image.rb
Constant Summary collapse
- VERSION =
BUMP UP if optimized image algorithm changes
2
- URL_REGEX =
%r{(/optimized/\dX[/\.\w]*/([a-zA-Z0-9]+)[\.\w]*)}
- IM_DECODERS =
/\A(jpe?g|png|ico|gif|webp|avif)\z/i
- MAX_PNGQUANT_SIZE =
500_000
- MAX_CONVERT_SECONDS =
20
Class Method Summary collapse
- .convert_with(instructions, to, opts = {}) ⇒ Object
- .create_for(upload, width, height, opts = {}) ⇒ Object
- .crop(from, to, width, height, opts = {}) ⇒ Object
- .crop_instructions(from, to, dimensions, opts = {}) ⇒ Object
- .downsize(from, to, dimensions, opts = {}) ⇒ Object
- .downsize_instructions(from, to, dimensions, opts = {}) ⇒ Object
- .ensure_safe_paths!(*paths) ⇒ Object
- .lock(upload_id, width, height) ⇒ Object
- .lock_per_machine=(value) ⇒ Object
- .lock_per_machine? ⇒ Boolean
- .optimize(operation, from, to, dimensions, opts = {}) ⇒ Object
- .prepend_decoder!(path, ext_path = nil, opts = nil) ⇒ Object
- .resize(from, to, width, height, opts = {}) ⇒ Object
- .resize_instructions(from, to, dimensions, opts = {}) ⇒ Object
- .safe_path?(path) ⇒ Boolean
- .thumbnail_or_resize ⇒ Object
Instance Method Summary collapse
Class Method Details
.convert_with(instructions, to, opts = {}) ⇒ Object
330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 |
# File 'app/models/optimized_image.rb', line 330 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. =~ /\Aconvert:([^`]+)/ error << $1 else error << " unknown reason" end Discourse.warn( error, upload_id: opts[:upload_id], location: to, error_message: e., instructions: instructions, ) false end end |
.create_for(upload, width, height, opts = {}) ⇒ Object
35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
# File 'app/models/optimized_image.rb', line 35 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 |
.crop(from, to, width, height, opts = {}) ⇒ Object
312 313 314 |
# File 'app/models/optimized_image.rb', line 312 def self.crop(from, to, width, height, opts = {}) optimize("crop", from, to, "#{width}x#{height}", opts) end |
.crop_instructions(from, to, dimensions, opts = {}) ⇒ Object
253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 |
# File 'app/models/optimized_image.rb', line 253 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 |
.downsize(from, to, dimensions, opts = {}) ⇒ Object
316 317 318 |
# File 'app/models/optimized_image.rb', line 316 def self.downsize(from, to, dimensions, opts = {}) optimize("downsize", from, to, dimensions, opts) end |
.downsize_instructions(from, to, dimensions, opts = {}) ⇒ Object
284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 |
# File 'app/models/optimized_image.rb', line 284 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 |
.ensure_safe_paths!(*paths) ⇒ Object
184 185 186 |
# File 'app/models/optimized_image.rb', line 184 def self.ensure_safe_paths!(*paths) paths.each { |path| raise Discourse::InvalidAccess unless safe_path?(path) } end |
.lock(upload_id, width, height) ⇒ Object
11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
# File 'app/models/optimized_image.rb', line 11 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 |
.lock_per_machine=(value) ⇒ Object
31 32 33 |
# File 'app/models/optimized_image.rb', line 31 def self.lock_per_machine=(value) @lock_per_machine = value end |
.lock_per_machine? ⇒ Boolean
26 27 28 29 |
# File 'app/models/optimized_image.rb', line 26 def self.lock_per_machine? return @lock_per_machine if defined?(@lock_per_machine) @lock_per_machine = !Sidekiq.server? end |
.optimize(operation, from, to, dimensions, opts = {}) ⇒ Object
320 321 322 323 324 325 |
# File 'app/models/optimized_image.rb', line 320 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 |
.prepend_decoder!(path, ext_path = nil, opts = nil) ⇒ Object
190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 |
# File 'app/models/optimized_image.rb', line 190 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 |
.resize(from, to, width, height, opts = {}) ⇒ Object
308 309 310 |
# File 'app/models/optimized_image.rb', line 308 def self.resize(from, to, width, height, opts = {}) optimize("resize", from, to, "#{width}x#{height}", opts) end |
.resize_instructions(from, to, dimensions, opts = {}) ⇒ Object
215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 |
# File 'app/models/optimized_image.rb', line 215 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 |
.safe_path?(path) ⇒ Boolean
176 177 178 179 180 181 182 |
# File 'app/models/optimized_image.rb', line 176 def self.safe_path?(path) # this matches instructions which call #to_s path = path.to_s return false if path != File.(path) return false if path !~ %r{\A[\w\-\./]+\z}m true end |
.thumbnail_or_resize ⇒ Object
211 212 213 |
# File 'app/models/optimized_image.rb', line 211 def self.thumbnail_or_resize SiteSetting. ? "thumbnail" : "resize" end |
Instance Method Details
#calculate_filesize ⇒ Object
154 155 156 157 158 159 160 161 162 |
# File 'app/models/optimized_image.rb', line 154 def calculate_filesize path = if local? Discourse.store.path_for(self) else Discourse.store.download!(self).path end File.size(path) end |
#destroy ⇒ Object
143 144 145 146 147 148 |
# File 'app/models/optimized_image.rb', line 143 def destroy OptimizedImage.transaction do Discourse.store.remove_optimized_image(self) if self.upload super end end |
#filesize ⇒ Object
164 165 166 167 168 169 170 171 172 173 174 |
# File 'app/models/optimized_image.rb', line 164 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 |
#local? ⇒ Boolean
150 151 152 |
# File 'app/models/optimized_image.rb', line 150 def local? !(url =~ %r{\A(https?:)?//}) end |