safe_image 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -12
  3. data/README.md +140 -287
  4. data/SECURITY.md +22 -11
  5. data/docs/architecture.md +63 -0
  6. data/ext/safe_image_vips_helper/extconf.rb +43 -0
  7. data/ext/safe_image_vips_helper/safe_image_vips_helper.c +1007 -0
  8. data/lib/safe_image/api/metadata.rb +85 -0
  9. data/lib/safe_image/api/transform.rb +152 -0
  10. data/lib/safe_image/backend_label.rb +24 -0
  11. data/lib/safe_image/formats.rb +96 -0
  12. data/lib/safe_image/ico.rb +42 -40
  13. data/lib/safe_image/image_magick_backend.rb +219 -162
  14. data/lib/safe_image/jpegli_backend.rb +64 -44
  15. data/lib/safe_image/metadata_operations.rb +155 -0
  16. data/lib/safe_image/native.rb +96 -290
  17. data/lib/safe_image/native_helper.rb +281 -0
  18. data/lib/safe_image/operation_backends/base.rb +83 -0
  19. data/lib/safe_image/operation_backends/image_magick.rb +123 -0
  20. data/lib/safe_image/operation_backends/vips.rb +251 -0
  21. data/lib/safe_image/operation_backends.rb +27 -0
  22. data/lib/safe_image/operation_set.rb +22 -0
  23. data/lib/safe_image/optimizer.rb +225 -98
  24. data/lib/safe_image/path_safety.rb +11 -0
  25. data/lib/safe_image/processor.rb +122 -85
  26. data/lib/safe_image/quality_defaults.rb +23 -0
  27. data/lib/safe_image/remote.rb +248 -144
  28. data/lib/safe_image/result.rb +71 -23
  29. data/lib/safe_image/runner.rb +60 -23
  30. data/lib/safe_image/sandbox.rb +44 -218
  31. data/lib/safe_image/staged_output.rb +44 -0
  32. data/lib/safe_image/svg_metadata.rb +74 -69
  33. data/lib/safe_image/transform_operations.rb +139 -0
  34. data/lib/safe_image/version.rb +1 -1
  35. data/lib/safe_image/vips_backend.rb +13 -12
  36. data/lib/safe_image.rb +62 -306
  37. metadata +43 -37
  38. data/lib/safe_image/discourse_compat.rb +0 -441
  39. data/lib/safe_image/svg_css.rb +0 -314
  40. data/lib/safe_image/svg_sanitizer.rb +0 -583
  41. data/lib/safe_image/vips_glue.rb +0 -361
  42. data/lib/safe_image/zygote.rb +0 -619
@@ -1,441 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fileutils"
4
- require "pathname"
5
- require "tempfile"
6
-
7
- module SafeImage
8
- # Compatibility-shaped API for the operations Discourse currently performs in
9
- # OptimizedImage, UploadCreator, ShrinkUploadedImage and FileHelper. The
10
- # backend is decided once by SafeImage.configure!; these methods only
11
- # dispatch to it.
12
- module DiscourseCompat
13
- module_function
14
-
15
- def resize(from, to, width, height, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
16
- max_pixels = SafeImage.resolved_max_pixels(max_pixels)
17
- case SafeImage.config.backend
18
- when :vips
19
- vips_resize(from, to, width, height, quality: quality, optimize: optimize, max_pixels: max_pixels, chroma_subsampling: chroma_subsampling)
20
- when :imagemagick
21
- imagemagick_resize(from, to, width, height, quality: quality, optimize: optimize, max_pixels: max_pixels)
22
- end
23
- end
24
-
25
- def vips_resize(from, to, width, height, quality:, optimize:, max_pixels:, chroma_subsampling:)
26
- SafeImage.thumbnail(
27
- input: from,
28
- output: to,
29
- width: width,
30
- height: height,
31
- quality: quality || 85,
32
- optimize: optimize,
33
- max_pixels: max_pixels,
34
- chroma_subsampling: chroma_subsampling
35
- )
36
- end
37
-
38
- def imagemagick_resize(from, to, width, height, quality:, optimize:, max_pixels:)
39
- probe = compat_probe(from, max_pixels: max_pixels)
40
- output = PathSafety.ensure_safe_output_path!(to).to_s
41
- info = ImageMagickBackend.thumbnail(
42
- input: probe.input,
43
- output: output,
44
- width: width,
45
- height: height,
46
- format: File.extname(output).delete_prefix(".").downcase,
47
- quality: quality
48
- )
49
- optimize_output(output, quality) if optimize
50
- result_from_info(probe.input, output, info, "imagemagick")
51
- end
52
-
53
- def crop(from, to, width, height, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
54
- max_pixels = SafeImage.resolved_max_pixels(max_pixels)
55
- case SafeImage.config.backend
56
- when :vips
57
- vips_crop(from, to, width, height, quality: quality, optimize: optimize, max_pixels: max_pixels, chroma_subsampling: chroma_subsampling)
58
- when :imagemagick
59
- imagemagick_crop(from, to, width, height, quality: quality, optimize: optimize, max_pixels: max_pixels)
60
- end
61
- end
62
-
63
- def vips_crop(from, to, width, height, quality:, optimize:, max_pixels:, chroma_subsampling:)
64
- probe = compat_probe(from, max_pixels: max_pixels)
65
- output = PathSafety.ensure_safe_output_path!(to).to_s
66
- format = File.extname(output).delete_prefix(".").downcase
67
-
68
- info =
69
- if use_jpegli_for_generated_jpeg?(format)
70
- with_temp_png(output) do |tmp_path|
71
- VipsBackend.crop_north(
72
- input: probe.input,
73
- output: tmp_path,
74
- width: width,
75
- height: height,
76
- format: "png",
77
- quality: 100,
78
- max_pixels: max_pixels
79
- )
80
- JpegliBackend.encode(
81
- input: tmp_path,
82
- output: output,
83
- quality: quality || JpegliBackend::DEFAULT_QUALITY,
84
- chroma_subsampling: JpegliBackend.validate_chroma_subsampling!(chroma_subsampling, input_format: probe.input_format),
85
- input_format: probe.input_format
86
- )
87
- end
88
- else
89
- VipsBackend.crop_north(
90
- input: probe.input,
91
- output: output,
92
- width: width,
93
- height: height,
94
- format: format,
95
- quality: quality || 85,
96
- max_pixels: max_pixels
97
- )
98
- end
99
- optimize_output(output, quality) if optimize
100
- result_from_info(probe.input, output, info, compat_backend_name(:vips, info))
101
- end
102
-
103
- def imagemagick_crop(from, to, width, height, quality:, optimize:, max_pixels:)
104
- probe = compat_probe(from, max_pixels: max_pixels)
105
- output = PathSafety.ensure_safe_output_path!(to).to_s
106
- info = ImageMagickBackend.resize_like(
107
- input: probe.input,
108
- output: output,
109
- width: width,
110
- height: height,
111
- format: File.extname(output).delete_prefix(".").downcase,
112
- quality: quality,
113
- crop: :north
114
- )
115
- optimize_output(output, quality) if optimize
116
- result_from_info(probe.input, output, info, "imagemagick")
117
- end
118
-
119
- def downsize(from, to, dimensions, optimize: true, max_pixels: nil, quality: 85, chroma_subsampling: :auto)
120
- max_pixels = SafeImage.resolved_max_pixels(max_pixels)
121
- case SafeImage.config.backend
122
- when :vips
123
- vips_downsize(from, to, dimensions, quality: quality, optimize: optimize, max_pixels: max_pixels, chroma_subsampling: chroma_subsampling)
124
- when :imagemagick
125
- imagemagick_downsize(from, to, dimensions, optimize: optimize, max_pixels: max_pixels)
126
- end
127
- end
128
-
129
- def vips_downsize(from, to, dimensions, quality:, optimize:, max_pixels:, chroma_subsampling:)
130
- probe = compat_probe(from, max_pixels: max_pixels)
131
- output = PathSafety.ensure_safe_output_path!(to).to_s
132
- format = File.extname(output).delete_prefix(".").downcase
133
- info =
134
- if use_jpegli_for_generated_jpeg?(format)
135
- with_temp_png(output) do |tmp_path|
136
- VipsBackend.downsize(
137
- input: probe.input,
138
- output: tmp_path,
139
- dimensions: dimensions,
140
- format: "png",
141
- quality: 100,
142
- max_pixels: max_pixels
143
- )
144
- JpegliBackend.encode(
145
- input: tmp_path,
146
- output: output,
147
- quality: quality,
148
- chroma_subsampling: JpegliBackend.validate_chroma_subsampling!(chroma_subsampling, input_format: probe.input_format),
149
- input_format: probe.input_format
150
- )
151
- end
152
- else
153
- VipsBackend.downsize(
154
- input: probe.input,
155
- output: output,
156
- dimensions: dimensions,
157
- format: format,
158
- quality: quality,
159
- max_pixels: max_pixels
160
- )
161
- end
162
- optimize_output(output, nil) if optimize
163
- result_from_info(probe.input, output, info, compat_backend_name(:vips, info))
164
- end
165
-
166
- def imagemagick_downsize(from, to, dimensions, optimize:, max_pixels:)
167
- probe = compat_probe(from, max_pixels: max_pixels)
168
- output = PathSafety.ensure_safe_output_path!(to).to_s
169
- info = ImageMagickBackend.downsize(
170
- input: probe.input,
171
- output: output,
172
- dimensions: dimensions,
173
- format: File.extname(output).delete_prefix(".").downcase
174
- )
175
- optimize_output(output, nil) if optimize
176
- result_from_info(probe.input, output, info, "imagemagick")
177
- end
178
-
179
- # Post-processing applies only to the formats the optimizer tools
180
- # understand; other outputs (gif, jxl, ...) skip the pass.
181
- def optimize_output(output, quality)
182
- format = File.extname(output).delete_prefix(".").downcase
183
- format = "jpg" if format == "jpeg"
184
- return unless Processor::OPTIMIZABLE_OUTPUTS.include?(format)
185
- Optimizer.optimize(output, mode: :lossless, strip_metadata: true, quality: quality, assume_upright: true)
186
- end
187
-
188
- # JPEG default when the caller passes no quality: matches what ImageMagick
189
- # uses for sources without quality tables, rather than libvips' Q75.
190
- NATIVE_CONVERT_DEFAULT_QUALITY = 92
191
-
192
- def convert(from, to, format:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
193
- max_pixels = SafeImage.resolved_max_pixels(max_pixels)
194
- output = PathSafety.ensure_safe_output_path!(to).to_s
195
-
196
- case SafeImage.config.backend
197
- when :vips
198
- native_convert(from, output, format: format, quality: quality, optimize: optimize, max_pixels: max_pixels, chroma_subsampling: chroma_subsampling)
199
- when :imagemagick
200
- imagemagick_convert(from, output, format: format, quality: quality, optimize: optimize, max_pixels: max_pixels)
201
- end
202
- end
203
-
204
- def imagemagick_convert(from, output, format:, quality:, optimize:, max_pixels:)
205
- probe = compat_probe(from, max_pixels: max_pixels)
206
- normalized_format = format.to_s.downcase == "jpeg" ? "jpg" : format.to_s.downcase
207
- info = ImageMagickBackend.convert(input: probe.input, output: output, format: format, quality: quality)
208
- optimize_output(output, normalized_format == "jpg" ? quality : nil) if optimize
209
- result_from_info(probe.input, output, info, "imagemagick")
210
- end
211
-
212
- def native_convert(from, output, format:, quality:, optimize:, max_pixels:, chroma_subsampling:)
213
- input = PathSafety.ensure_regular_file!(from).to_s
214
- normalized_format = format.to_s.downcase == "jpeg" ? "jpg" : format.to_s.downcase
215
-
216
- if use_jpegli_for_convert?(input, normalized_format)
217
- info = JpegliBackend.convert(
218
- input: input,
219
- output: output,
220
- quality: quality || JpegliBackend::DEFAULT_QUALITY,
221
- chroma_subsampling: chroma_subsampling
222
- )
223
- return result_from_info(input, output, info, "cjpegli")
224
- end
225
-
226
- info = write_through_tempfile(output) do |tmp_path|
227
- Native.convert(input, tmp_path, normalized_format, quality || NATIVE_CONVERT_DEFAULT_QUALITY, max_pixels)
228
- end
229
- optimize_output(output, normalized_format == "jpg" ? quality : nil) if optimize
230
- result_from_info(input, output, info, "libvips-direct")
231
- end
232
-
233
- def use_jpegli_for_convert?(input, normalized_format)
234
- normalized_format == "jpg" && JpegliBackend.available? && JpegliBackend.suitable_direct_input?(input)
235
- end
236
-
237
- # cjpegli is an output-quality tool, not a configuration choice: installed
238
- # means used for JPEG output on the native path. It encodes only pixels
239
- # this gem already decoded, so it is not part of the untrusted-input
240
- # surface the backend choice controls.
241
- def use_jpegli_for_generated_jpeg?(format)
242
- normalized_format = format.to_s.downcase == "jpeg" ? "jpg" : format.to_s.downcase
243
- normalized_format == "jpg" && JpegliBackend.available?
244
- end
245
-
246
- def with_temp_png(output)
247
- output_path = Pathname.new(output)
248
- output_path.dirname.mkpath
249
- Tempfile.create([output_path.basename(".*").to_s, ".safe-image.png"], output_path.dirname.to_s) do |tmp|
250
- tmp_path = Pathname.new(tmp.path)
251
- tmp.close
252
- yield tmp_path
253
- ensure
254
- FileUtils.rm_f(tmp_path) if defined?(tmp_path) && tmp_path
255
- end
256
- end
257
-
258
- def compat_backend_name(backend, info)
259
- base = backend.to_sym == :vips ? "libvips-direct" : "imagemagick"
260
- info[:encoder] == "cjpegli" ? "#{base}+cjpegli" : base
261
- end
262
-
263
- def convert_to_jpeg(from, to, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
264
- convert(from, to, format: "jpg", quality: quality, optimize: optimize, max_pixels: max_pixels, chroma_subsampling: chroma_subsampling)
265
- end
266
-
267
- def fix_orientation(from, to = from, max_pixels: nil, quality: nil)
268
- max_pixels = SafeImage.resolved_max_pixels(max_pixels)
269
- output = PathSafety.ensure_safe_output_path!(to).to_s
270
-
271
- case SafeImage.config.backend
272
- when :vips
273
- native_fix_orientation(from, output, max_pixels: max_pixels, quality: quality)
274
- when :imagemagick
275
- imagemagick_fix_orientation(from, output, max_pixels: max_pixels)
276
- end
277
- end
278
-
279
- def imagemagick_fix_orientation(from, output, max_pixels:)
280
- probe = compat_probe(from, max_pixels: max_pixels)
281
- info = ImageMagickBackend.fix_orientation(input: probe.input, output: output)
282
- result_from_info(probe.input, output, info, "imagemagick")
283
- end
284
-
285
- def native_fix_orientation(from, output, max_pixels:, quality:)
286
- input = PathSafety.ensure_regular_file!(from).to_s
287
- format = File.extname(input).delete_prefix(".").downcase
288
- format = "jpg" if format == "jpeg"
289
- # Validates the format against the native loader allowlist and enforces
290
- # the pixel cap before any pixel decode.
291
- orient = VipsBackend.orientation(input, max_pixels: max_pixels)
292
-
293
- # Lossless tier: jpegtran transforms JPEG DCT coefficients directly, so
294
- # there is no generation loss. -perfect refuses when the dimensions are
295
- # not MCU-aligned; fall through to the re-encode tier.
296
- if format == "jpg" && orient > 1 && Runner.available?("jpegtran")
297
- begin
298
- return jpegtran_fix_orientation(input, output, orient)
299
- rescue CommandError
300
- nil
301
- end
302
- end
303
-
304
- quality = quality.nil? ? 95 : Integer(quality)
305
- raise ArgumentError, "quality must be 1..100" unless (1..100).cover?(quality)
306
- info = write_through_tempfile(output) do |tmp_path|
307
- Native.resize(input, tmp_path, 1.0, format, quality, max_pixels)
308
- end
309
- result_from_info(input, output, info, "libvips-direct")
310
- end
311
-
312
- def jpegtran_fix_orientation(input, output, orient)
313
- started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
314
- info = write_through_tempfile(output) do |tmp_path|
315
- Runner.run!(["jpegtran", "-copy", "none", "-perfect", *Optimizer::JPEGTRAN_OPERATIONS.fetch(orient), "-outfile", tmp_path, input])
316
- Native.probe(tmp_path)
317
- end
318
- result_from_info(
319
- input,
320
- output,
321
- {
322
- input_format: "jpg",
323
- output_format: "jpg",
324
- width: info.fetch(:width),
325
- height: info.fetch(:height),
326
- duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
327
- },
328
- "jpegtran"
329
- )
330
- end
331
-
332
- # Writes via a sibling tempfile and renames into place, so in-place calls
333
- # (to == from) never feed an output path that libvips is still reading
334
- # from as input.
335
- def write_through_tempfile(output)
336
- tmp_path = File.join(File.dirname(output), ".safe-image-#{Process.pid}-#{output.object_id}#{File.extname(output)}")
337
- PathSafety.ensure_safe_output_path!(tmp_path)
338
- result = yield tmp_path
339
- FileUtils.mv(tmp_path, output)
340
- result
341
- ensure
342
- FileUtils.rm_f(tmp_path)
343
- end
344
-
345
- def convert_favicon_to_png(from, to, optimize: true, max_pixels: nil)
346
- max_pixels = SafeImage.resolved_max_pixels(max_pixels)
347
- output = PathSafety.ensure_safe_output_path!(to).to_s
348
-
349
- case SafeImage.config.backend
350
- when :vips
351
- # Pure-Ruby ICO parse; libvips only encodes the extracted pixels.
352
- info = Ico.convert_to_png(from, output, max_pixels: max_pixels)
353
- backend_name = "ico-ruby+libvips"
354
- when :imagemagick
355
- info = ImageMagickBackend.convert_ico_to_png(input: Pathname.new(from).expand_path.to_s, output: output)
356
- backend_name = "imagemagick"
357
- end
358
- Optimizer.optimize(output, mode: :lossless, strip_metadata: true) if optimize
359
- result_from_info(from, output, info, backend_name)
360
- end
361
-
362
- def frame_count(path, max_pixels: nil)
363
- max_pixels = SafeImage.resolved_max_pixels(max_pixels)
364
- # ico directories are counted by the pure-Ruby parser on either backend;
365
- # everything else is a header-only count.
366
- return Ico.frame_count(path, max_pixels: max_pixels) if File.extname(PathSafety.local_path(path)).downcase == ".ico"
367
-
368
- case SafeImage.config.backend
369
- when :vips
370
- VipsBackend.frame_count(path, max_pixels: max_pixels)
371
- when :imagemagick
372
- ImageMagickBackend.frame_count(path, max_pixels: max_pixels)
373
- end
374
- end
375
-
376
- def animated?(path, max_pixels: nil)
377
- frame_count(path, max_pixels: max_pixels).to_i > 1
378
- end
379
-
380
- def letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "DejaVu-Sans")
381
- output = PathSafety.ensure_safe_output_path!(output).to_s
382
- request = { output: output, size: size, background_rgb: background_rgb, letter: letter, pointsize: pointsize, font: font }
383
-
384
- info, backend_name =
385
- case SafeImage.config.backend
386
- when :vips
387
- [VipsBackend.letter_avatar(**request), "libvips-direct"]
388
- when :imagemagick
389
- [ImageMagickBackend.letter_avatar(**request), "imagemagick"]
390
- end
391
-
392
- result_from_info("generated", output, info, backend_name)
393
- end
394
-
395
- def optimize_image!(path, allow_lossy_png: false, strip_metadata: true, quality: nil, strict: true)
396
- Optimizer.optimize(
397
- path,
398
- mode: allow_lossy_png ? :lossy : :lossless,
399
- strip_metadata: strip_metadata,
400
- quality: quality,
401
- strict: strict
402
- )
403
- end
404
-
405
- def compat_probe(path, max_pixels: nil)
406
- path = Pathname.new(path).expand_path.to_s
407
- if SafeImage.config.backend == :vips
408
- SafeImage.probe(path, max_pixels: max_pixels)
409
- else
410
- info = ImageMagickBackend.probe(path, max_pixels: max_pixels)
411
- Result.new(
412
- input: path,
413
- output: nil,
414
- input_format: info.fetch(:input_format),
415
- output_format: nil,
416
- width: info.fetch(:width),
417
- height: info.fetch(:height),
418
- filesize: File.size(path),
419
- backend: "imagemagick",
420
- duration_ms: info.fetch(:duration_ms),
421
- optimizer: nil
422
- )
423
- end
424
- end
425
-
426
- def result_from_info(input, output, info, backend)
427
- Result.new(
428
- input: input.to_s,
429
- output: output.to_s,
430
- input_format: info.fetch(:input_format),
431
- output_format: info.fetch(:output_format),
432
- width: info.fetch(:width),
433
- height: info.fetch(:height),
434
- filesize: File.size(output),
435
- backend: backend,
436
- duration_ms: info.fetch(:duration_ms),
437
- optimizer: nil
438
- )
439
- end
440
- end
441
- end