safe_image 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +212 -10
- data/README.md +176 -168
- data/SECURITY.md +22 -11
- data/docs/architecture.md +63 -0
- data/ext/safe_image_vips_helper/extconf.rb +43 -0
- data/ext/safe_image_vips_helper/safe_image_vips_helper.c +1007 -0
- data/lib/safe_image/api/metadata.rb +85 -0
- data/lib/safe_image/api/transform.rb +152 -0
- data/lib/safe_image/backend_label.rb +24 -0
- data/lib/safe_image/formats.rb +96 -0
- data/lib/safe_image/ico.rb +43 -41
- data/lib/safe_image/image_magick_backend.rb +219 -162
- data/lib/safe_image/jpegli_backend.rb +64 -44
- data/lib/safe_image/metadata_operations.rb +155 -0
- data/lib/safe_image/native.rb +96 -281
- data/lib/safe_image/native_helper.rb +281 -0
- data/lib/safe_image/operation_backends/base.rb +83 -0
- data/lib/safe_image/operation_backends/image_magick.rb +123 -0
- data/lib/safe_image/operation_backends/vips.rb +251 -0
- data/lib/safe_image/operation_backends.rb +27 -0
- data/lib/safe_image/operation_set.rb +22 -0
- data/lib/safe_image/optimizer.rb +250 -48
- data/lib/safe_image/path_safety.rb +11 -0
- data/lib/safe_image/processor.rb +122 -85
- data/lib/safe_image/quality_defaults.rb +23 -0
- data/lib/safe_image/remote.rb +367 -97
- data/lib/safe_image/result.rb +71 -23
- data/lib/safe_image/runner.rb +69 -24
- data/lib/safe_image/sandbox.rb +44 -191
- data/lib/safe_image/staged_output.rb +44 -0
- data/lib/safe_image/svg_metadata.rb +186 -55
- data/lib/safe_image/transform_operations.rb +139 -0
- data/lib/safe_image/version.rb +1 -1
- data/lib/safe_image/vips_backend.rb +13 -12
- data/lib/safe_image.rb +62 -294
- metadata +48 -26
- data/lib/safe_image/discourse_compat.rb +0 -452
- data/lib/safe_image/svg_sanitizer.rb +0 -102
- data/lib/safe_image/vips_glue.rb +0 -361
|
@@ -1,452 +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)
|
|
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
|
-
# EXIF orientation values mapped onto jpegtran's lossless transforms.
|
|
268
|
-
JPEGTRAN_OPERATIONS = {
|
|
269
|
-
2 => ["-flip", "horizontal"],
|
|
270
|
-
3 => ["-rotate", "180"],
|
|
271
|
-
4 => ["-flip", "vertical"],
|
|
272
|
-
5 => ["-transpose"],
|
|
273
|
-
6 => ["-rotate", "90"],
|
|
274
|
-
7 => ["-transverse"],
|
|
275
|
-
8 => ["-rotate", "270"]
|
|
276
|
-
}.freeze
|
|
277
|
-
|
|
278
|
-
def fix_orientation(from, to = from, max_pixels: nil, quality: nil)
|
|
279
|
-
max_pixels = SafeImage.resolved_max_pixels(max_pixels)
|
|
280
|
-
output = PathSafety.ensure_safe_output_path!(to).to_s
|
|
281
|
-
|
|
282
|
-
case SafeImage.config.backend
|
|
283
|
-
when :vips
|
|
284
|
-
native_fix_orientation(from, output, max_pixels: max_pixels, quality: quality)
|
|
285
|
-
when :imagemagick
|
|
286
|
-
imagemagick_fix_orientation(from, output, max_pixels: max_pixels)
|
|
287
|
-
end
|
|
288
|
-
end
|
|
289
|
-
|
|
290
|
-
def imagemagick_fix_orientation(from, output, max_pixels:)
|
|
291
|
-
probe = compat_probe(from, max_pixels: max_pixels)
|
|
292
|
-
info = ImageMagickBackend.fix_orientation(input: probe.input, output: output)
|
|
293
|
-
result_from_info(probe.input, output, info, "imagemagick")
|
|
294
|
-
end
|
|
295
|
-
|
|
296
|
-
def native_fix_orientation(from, output, max_pixels:, quality:)
|
|
297
|
-
input = PathSafety.ensure_regular_file!(from).to_s
|
|
298
|
-
format = File.extname(input).delete_prefix(".").downcase
|
|
299
|
-
format = "jpg" if format == "jpeg"
|
|
300
|
-
# Validates the format against the native loader allowlist and enforces
|
|
301
|
-
# the pixel cap before any pixel decode.
|
|
302
|
-
orient = VipsBackend.orientation(input, max_pixels: max_pixels)
|
|
303
|
-
|
|
304
|
-
# Lossless tier: jpegtran transforms JPEG DCT coefficients directly, so
|
|
305
|
-
# there is no generation loss. -perfect refuses when the dimensions are
|
|
306
|
-
# not MCU-aligned; fall through to the re-encode tier.
|
|
307
|
-
if format == "jpg" && orient > 1 && Runner.available?("jpegtran")
|
|
308
|
-
begin
|
|
309
|
-
return jpegtran_fix_orientation(input, output, orient)
|
|
310
|
-
rescue CommandError
|
|
311
|
-
nil
|
|
312
|
-
end
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
quality = quality.nil? ? 95 : Integer(quality)
|
|
316
|
-
raise ArgumentError, "quality must be 1..100" unless (1..100).cover?(quality)
|
|
317
|
-
info = write_through_tempfile(output) do |tmp_path|
|
|
318
|
-
Native.resize(input, tmp_path, 1.0, format, quality, max_pixels)
|
|
319
|
-
end
|
|
320
|
-
result_from_info(input, output, info, "libvips-direct")
|
|
321
|
-
end
|
|
322
|
-
|
|
323
|
-
def jpegtran_fix_orientation(input, output, orient)
|
|
324
|
-
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
325
|
-
info = write_through_tempfile(output) do |tmp_path|
|
|
326
|
-
Runner.run!(["jpegtran", "-copy", "none", "-perfect", *JPEGTRAN_OPERATIONS.fetch(orient), "-outfile", tmp_path, input])
|
|
327
|
-
Native.probe(tmp_path)
|
|
328
|
-
end
|
|
329
|
-
result_from_info(
|
|
330
|
-
input,
|
|
331
|
-
output,
|
|
332
|
-
{
|
|
333
|
-
input_format: "jpg",
|
|
334
|
-
output_format: "jpg",
|
|
335
|
-
width: info.fetch(:width),
|
|
336
|
-
height: info.fetch(:height),
|
|
337
|
-
duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
|
|
338
|
-
},
|
|
339
|
-
"jpegtran"
|
|
340
|
-
)
|
|
341
|
-
end
|
|
342
|
-
|
|
343
|
-
# Writes via a sibling tempfile and renames into place, so in-place calls
|
|
344
|
-
# (to == from) never feed an output path that libvips is still reading
|
|
345
|
-
# from as input.
|
|
346
|
-
def write_through_tempfile(output)
|
|
347
|
-
tmp_path = File.join(File.dirname(output), ".safe-image-#{Process.pid}-#{output.object_id}#{File.extname(output)}")
|
|
348
|
-
PathSafety.ensure_safe_output_path!(tmp_path)
|
|
349
|
-
result = yield tmp_path
|
|
350
|
-
FileUtils.mv(tmp_path, output)
|
|
351
|
-
result
|
|
352
|
-
ensure
|
|
353
|
-
FileUtils.rm_f(tmp_path)
|
|
354
|
-
end
|
|
355
|
-
|
|
356
|
-
def convert_favicon_to_png(from, to, optimize: true, max_pixels: nil)
|
|
357
|
-
max_pixels = SafeImage.resolved_max_pixels(max_pixels)
|
|
358
|
-
output = PathSafety.ensure_safe_output_path!(to).to_s
|
|
359
|
-
|
|
360
|
-
case SafeImage.config.backend
|
|
361
|
-
when :vips
|
|
362
|
-
# Pure-Ruby ICO parse; libvips only encodes the extracted pixels.
|
|
363
|
-
info = Ico.convert_to_png(from, output, max_pixels: max_pixels)
|
|
364
|
-
backend_name = "ico-ruby+libvips"
|
|
365
|
-
when :imagemagick
|
|
366
|
-
info = ImageMagickBackend.convert_ico_to_png(input: Pathname.new(from).expand_path.to_s, output: output)
|
|
367
|
-
backend_name = "imagemagick"
|
|
368
|
-
end
|
|
369
|
-
Optimizer.optimize(output, mode: :lossless, strip_metadata: true) if optimize
|
|
370
|
-
result_from_info(from, output, info, backend_name)
|
|
371
|
-
end
|
|
372
|
-
|
|
373
|
-
def frame_count(path, max_pixels: nil)
|
|
374
|
-
max_pixels = SafeImage.resolved_max_pixels(max_pixels)
|
|
375
|
-
# ico directories are counted by the pure-Ruby parser on either backend;
|
|
376
|
-
# everything else is a header-only count.
|
|
377
|
-
return Ico.frame_count(path, max_pixels: max_pixels) if File.extname(PathSafety.local_path(path)).downcase == ".ico"
|
|
378
|
-
|
|
379
|
-
case SafeImage.config.backend
|
|
380
|
-
when :vips
|
|
381
|
-
VipsBackend.frame_count(path, max_pixels: max_pixels)
|
|
382
|
-
when :imagemagick
|
|
383
|
-
ImageMagickBackend.frame_count(path, max_pixels: max_pixels)
|
|
384
|
-
end
|
|
385
|
-
end
|
|
386
|
-
|
|
387
|
-
def animated?(path, max_pixels: nil)
|
|
388
|
-
frame_count(path, max_pixels: max_pixels).to_i > 1
|
|
389
|
-
end
|
|
390
|
-
|
|
391
|
-
def letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "DejaVu-Sans")
|
|
392
|
-
output = PathSafety.ensure_safe_output_path!(output).to_s
|
|
393
|
-
request = { output: output, size: size, background_rgb: background_rgb, letter: letter, pointsize: pointsize, font: font }
|
|
394
|
-
|
|
395
|
-
info, backend_name =
|
|
396
|
-
case SafeImage.config.backend
|
|
397
|
-
when :vips
|
|
398
|
-
[VipsBackend.letter_avatar(**request), "libvips-direct"]
|
|
399
|
-
when :imagemagick
|
|
400
|
-
[ImageMagickBackend.letter_avatar(**request), "imagemagick"]
|
|
401
|
-
end
|
|
402
|
-
|
|
403
|
-
result_from_info("generated", output, info, backend_name)
|
|
404
|
-
end
|
|
405
|
-
|
|
406
|
-
def optimize_image!(path, allow_lossy_png: false, strip_metadata: true, quality: nil, strict: true)
|
|
407
|
-
Optimizer.optimize(
|
|
408
|
-
path,
|
|
409
|
-
mode: allow_lossy_png ? :lossy : :lossless,
|
|
410
|
-
strip_metadata: strip_metadata,
|
|
411
|
-
quality: quality,
|
|
412
|
-
strict: strict
|
|
413
|
-
)
|
|
414
|
-
end
|
|
415
|
-
|
|
416
|
-
def compat_probe(path, max_pixels: nil)
|
|
417
|
-
path = Pathname.new(path).expand_path.to_s
|
|
418
|
-
if SafeImage.config.backend == :vips
|
|
419
|
-
SafeImage.probe(path, max_pixels: max_pixels)
|
|
420
|
-
else
|
|
421
|
-
info = ImageMagickBackend.probe(path, max_pixels: max_pixels)
|
|
422
|
-
Result.new(
|
|
423
|
-
input: path,
|
|
424
|
-
output: nil,
|
|
425
|
-
input_format: info.fetch(:input_format),
|
|
426
|
-
output_format: nil,
|
|
427
|
-
width: info.fetch(:width),
|
|
428
|
-
height: info.fetch(:height),
|
|
429
|
-
filesize: File.size(path),
|
|
430
|
-
backend: "imagemagick",
|
|
431
|
-
duration_ms: info.fetch(:duration_ms),
|
|
432
|
-
optimizer: nil
|
|
433
|
-
)
|
|
434
|
-
end
|
|
435
|
-
end
|
|
436
|
-
|
|
437
|
-
def result_from_info(input, output, info, backend)
|
|
438
|
-
Result.new(
|
|
439
|
-
input: input.to_s,
|
|
440
|
-
output: output.to_s,
|
|
441
|
-
input_format: info.fetch(:input_format),
|
|
442
|
-
output_format: info.fetch(:output_format),
|
|
443
|
-
width: info.fetch(:width),
|
|
444
|
-
height: info.fetch(:height),
|
|
445
|
-
filesize: File.size(output),
|
|
446
|
-
backend: backend,
|
|
447
|
-
duration_ms: info.fetch(:duration_ms),
|
|
448
|
-
optimizer: nil
|
|
449
|
-
)
|
|
450
|
-
end
|
|
451
|
-
end
|
|
452
|
-
end
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "rexml/document"
|
|
4
|
-
require "rexml/formatters/default"
|
|
5
|
-
require "pathname"
|
|
6
|
-
require "tempfile"
|
|
7
|
-
|
|
8
|
-
module SafeImage
|
|
9
|
-
module SvgSanitizer
|
|
10
|
-
ALLOWED_ELEMENTS = %w[
|
|
11
|
-
svg g defs title desc path rect circle ellipse line polyline polygon text tspan
|
|
12
|
-
linearGradient radialGradient stop clipPath mask pattern use symbol
|
|
13
|
-
].freeze
|
|
14
|
-
|
|
15
|
-
ALLOWED_ATTRIBUTES = %w[
|
|
16
|
-
id class x y x1 y1 x2 y2 cx cy r rx ry d points width height viewBox
|
|
17
|
-
fill stroke stroke-width stroke-linecap stroke-linejoin stroke-miterlimit
|
|
18
|
-
fill-rule clip-rule opacity fill-opacity stroke-opacity transform
|
|
19
|
-
gradientUnits gradientTransform offset stop-color stop-opacity clip-path
|
|
20
|
-
mask href xlink:href xmlns xmlns:xlink version preserveAspectRatio
|
|
21
|
-
font-family font-size font-weight text-anchor
|
|
22
|
-
].freeze
|
|
23
|
-
|
|
24
|
-
module_function
|
|
25
|
-
|
|
26
|
-
def sanitize!(path, max_pixels: nil)
|
|
27
|
-
path = Pathname.new(SvgMetadata.safe_svg_path(path))
|
|
28
|
-
begin
|
|
29
|
-
SvgMetadata.dimensions(path.to_s, max_pixels: max_pixels)
|
|
30
|
-
rescue InvalidImageError => e
|
|
31
|
-
raise unless e.message.include?("dimensions are missing")
|
|
32
|
-
end
|
|
33
|
-
doc = SvgMetadata.parse(path.to_s)
|
|
34
|
-
|
|
35
|
-
clean = REXML::Document.new
|
|
36
|
-
clean.add_element(sanitize_element!(doc.root.deep_clone))
|
|
37
|
-
|
|
38
|
-
out = +""
|
|
39
|
-
formatter = REXML::Formatters::Default.new
|
|
40
|
-
formatter.write(clean, out)
|
|
41
|
-
atomic_write(path, out)
|
|
42
|
-
{ format: "svg", sanitized: true, filesize: File.size(path.to_s) }
|
|
43
|
-
rescue REXML::ParseException => e
|
|
44
|
-
raise InvalidImageError, "invalid SVG: #{e.message}"
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def sanitize_element!(element)
|
|
48
|
-
element.children.to_a.each do |child|
|
|
49
|
-
case child
|
|
50
|
-
when REXML::Element
|
|
51
|
-
if ALLOWED_ELEMENTS.include?(child.name)
|
|
52
|
-
sanitize_element!(child)
|
|
53
|
-
else
|
|
54
|
-
child.remove
|
|
55
|
-
end
|
|
56
|
-
when REXML::CData
|
|
57
|
-
child.replace_with(REXML::Text.new(child.value.to_s))
|
|
58
|
-
when REXML::Text
|
|
59
|
-
# Text is serialized escaped by REXML::Formatters::Default.
|
|
60
|
-
else
|
|
61
|
-
child.remove
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
attributes_to_delete = []
|
|
66
|
-
element.attributes.each_attribute do |attr|
|
|
67
|
-
name = attr.name.to_s
|
|
68
|
-
value = attr.value.to_s
|
|
69
|
-
allowed = ALLOWED_ATTRIBUTES.include?(name) || name.start_with?("aria-")
|
|
70
|
-
if !allowed || name.downcase.start_with?("on") || dangerous_value?(value)
|
|
71
|
-
attributes_to_delete << name
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
attributes_to_delete.each { |name| element.delete_attribute(name) }
|
|
75
|
-
|
|
76
|
-
%w[href xlink:href].each do |href|
|
|
77
|
-
next unless element.attributes[href]
|
|
78
|
-
element.delete_attribute(href) unless element.attributes[href].to_s.start_with?("#")
|
|
79
|
-
end
|
|
80
|
-
element
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def dangerous_value?(value)
|
|
84
|
-
normalized = value.to_s.gsub(/[\u0000-\u0020\u007f]+/, "")
|
|
85
|
-
return true if normalized.match?(/(?:javascript|data):/i)
|
|
86
|
-
|
|
87
|
-
normalized.scan(/url\(([^)]*)\)/i).any? do |match|
|
|
88
|
-
inner = match.first.to_s.delete(%q{'"})
|
|
89
|
-
!inner.match?(/\A#[A-Za-z][\w.-]*\z/)
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def atomic_write(path, content)
|
|
94
|
-
Tempfile.create([path.basename.to_s, ".tmp"], path.dirname.to_s, binmode: false) do |tmp|
|
|
95
|
-
tmp.write(content)
|
|
96
|
-
tmp.flush
|
|
97
|
-
tmp.fsync
|
|
98
|
-
File.rename(tmp.path, path.to_s)
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
end
|