safe_image 0.1.0 → 0.2.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 +158 -0
- data/README.md +475 -281
- data/SECURITY.md +27 -7
- data/lib/safe_image/discourse_compat.rb +267 -98
- data/lib/safe_image/fonts/DEJAVU-LICENSE +187 -0
- data/lib/safe_image/fonts/DejaVuSans.ttf +0 -0
- data/lib/safe_image/ico.rb +286 -0
- data/lib/safe_image/image_magick_backend.rb +39 -3
- data/lib/safe_image/imagemagick_policy/policy.xml +8 -1
- data/lib/safe_image/jpegli_backend.rb +3 -1
- data/lib/safe_image/native.rb +371 -1
- data/lib/safe_image/processor.rb +23 -69
- data/lib/safe_image/remote.rb +15 -3
- data/lib/safe_image/runner.rb +1 -1
- data/lib/safe_image/sandbox.rb +42 -16
- data/lib/safe_image/svg_metadata.rb +59 -29
- data/lib/safe_image/version.rb +1 -1
- data/lib/safe_image/vips_backend.rb +57 -0
- data/lib/safe_image/vips_glue.rb +361 -0
- data/lib/safe_image.rb +143 -36
- metadata +30 -14
- data/ext/safe_image_native/extconf.rb +0 -8
- data/ext/safe_image_native/safe_image_native.c +0 -392
data/lib/safe_image/native.rb
CHANGED
|
@@ -1,3 +1,373 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "vips_glue"
|
|
4
|
+
|
|
5
|
+
module SafeImage
|
|
6
|
+
# The libvips fast path, implemented in pure Ruby on top of the VipsGlue
|
|
7
|
+
# Fiddle binding (formerly a compiled C extension; the function surface and
|
|
8
|
+
# messages are unchanged). Loaders are explicit per extension, every decode
|
|
9
|
+
# enforces the pixel cap from the header before pixel data is touched, and
|
|
10
|
+
# all images are released deterministically.
|
|
11
|
+
module Native
|
|
12
|
+
LOADERS = {
|
|
13
|
+
"jpg" => "jpegload",
|
|
14
|
+
"png" => "pngload",
|
|
15
|
+
"webp" => "webpload",
|
|
16
|
+
"gif" => "gifload",
|
|
17
|
+
"heic" => "heifload",
|
|
18
|
+
"avif" => "heifload",
|
|
19
|
+
"jxl" => "jxlload"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def probe(path)
|
|
24
|
+
started = monotime
|
|
25
|
+
VipsGlue.with_images do |track|
|
|
26
|
+
image, format = load_image(track, String(path))
|
|
27
|
+
{
|
|
28
|
+
format: format,
|
|
29
|
+
width: VipsGlue.width(image),
|
|
30
|
+
height: VipsGlue.height(image),
|
|
31
|
+
duration_ms: monotime - started
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def thumbnail(input, output, width, height, format, quality, max_pixels)
|
|
37
|
+
started = monotime
|
|
38
|
+
input = String(input)
|
|
39
|
+
output = String(output)
|
|
40
|
+
width = Integer(width)
|
|
41
|
+
height = Integer(height)
|
|
42
|
+
quality = Integer(quality)
|
|
43
|
+
raise ArgumentError, "width and height must be positive" if width <= 0 || height <= 0
|
|
44
|
+
validate_quality!(quality)
|
|
45
|
+
out_format = output_format!(format)
|
|
46
|
+
|
|
47
|
+
VipsGlue.with_images do |track|
|
|
48
|
+
# Header read through the explicit loader: validates the bytes and
|
|
49
|
+
# enforces the pixel cap before any full decode.
|
|
50
|
+
header, input_format = load_image(track, input)
|
|
51
|
+
check_pixels!(header, max_pixels)
|
|
52
|
+
|
|
53
|
+
# Thumbnail from the file so libvips can shrink on load (e.g.
|
|
54
|
+
# libjpeg DCT downscaling); auto-rotates by default.
|
|
55
|
+
thumb = track.call(
|
|
56
|
+
VipsGlue.operation(
|
|
57
|
+
"thumbnail",
|
|
58
|
+
{ filename: input, width: width, height: height,
|
|
59
|
+
size: "both", crop: "centre", fail_on: "error" }
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
save_image(thumb, output, out_format, quality)
|
|
63
|
+
info(input_format, out_format, thumb, started)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def resize(input, output, scale, format, quality, max_pixels)
|
|
68
|
+
started = monotime
|
|
69
|
+
scale = Float(scale)
|
|
70
|
+
quality = Integer(quality)
|
|
71
|
+
unless scale.finite? && scale.positive? && scale <= 100.0
|
|
72
|
+
raise ArgumentError, "scale must be finite and in 0..100"
|
|
73
|
+
end
|
|
74
|
+
validate_quality!(quality)
|
|
75
|
+
out_format = output_format!(format)
|
|
76
|
+
|
|
77
|
+
VipsGlue.with_images do |track|
|
|
78
|
+
image, input_format = load_image(track, String(input))
|
|
79
|
+
check_pixels!(image, max_pixels)
|
|
80
|
+
rotated = track.call(VipsGlue.operation("autorot", { in: image }))
|
|
81
|
+
resized = track.call(VipsGlue.operation("resize", { in: rotated, scale: scale }))
|
|
82
|
+
save_image(resized, String(output), out_format, quality)
|
|
83
|
+
info(input_format, out_format, resized, started)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def crop_north(input, output, width, height, format, quality, max_pixels)
|
|
88
|
+
started = monotime
|
|
89
|
+
width = Integer(width)
|
|
90
|
+
height = Integer(height)
|
|
91
|
+
quality = Integer(quality)
|
|
92
|
+
raise ArgumentError, "width and height must be positive" if width <= 0 || height <= 0
|
|
93
|
+
validate_quality!(quality)
|
|
94
|
+
out_format = output_format!(format)
|
|
95
|
+
|
|
96
|
+
VipsGlue.with_images do |track|
|
|
97
|
+
image, input_format = load_image(track, String(input))
|
|
98
|
+
check_pixels!(image, max_pixels)
|
|
99
|
+
rotated = track.call(VipsGlue.operation("autorot", { in: image }))
|
|
100
|
+
|
|
101
|
+
scale = [width.fdiv(VipsGlue.width(rotated)), height.fdiv(VipsGlue.height(rotated))].max * 1.0000001
|
|
102
|
+
resized = track.call(VipsGlue.operation("resize", { in: rotated, scale: scale }))
|
|
103
|
+
left = [(VipsGlue.width(resized) - width) / 2, 0].max
|
|
104
|
+
cropped = track.call(
|
|
105
|
+
VipsGlue.operation("extract_area", { input: resized, left: left, top: 0, width: width, height: height })
|
|
106
|
+
)
|
|
107
|
+
save_image(cropped, String(output), out_format, quality)
|
|
108
|
+
info(input_format, out_format, cropped, started)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def convert(input, output, format, quality, max_pixels)
|
|
113
|
+
started = monotime
|
|
114
|
+
quality = Integer(quality)
|
|
115
|
+
validate_quality!(quality)
|
|
116
|
+
out_format = output_format!(format)
|
|
117
|
+
|
|
118
|
+
VipsGlue.with_images do |track|
|
|
119
|
+
image, input_format = load_image(track, String(input))
|
|
120
|
+
check_pixels!(image, max_pixels)
|
|
121
|
+
rotated = track.call(VipsGlue.operation("autorot", { in: image }))
|
|
122
|
+
|
|
123
|
+
# JPEG has no alpha; flatten onto white to match the ImageMagick
|
|
124
|
+
# convert path (libvips composites onto black otherwise).
|
|
125
|
+
final =
|
|
126
|
+
if out_format == "jpg" && VipsGlue.alpha?(rotated)
|
|
127
|
+
track.call(VipsGlue.operation("flatten", { in: rotated, background: [255.0, 255.0, 255.0] }))
|
|
128
|
+
else
|
|
129
|
+
rotated
|
|
130
|
+
end
|
|
131
|
+
save_image(final, String(output), out_format, quality)
|
|
132
|
+
info(input_format, out_format, final, started)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Alpha-weighted average colour as [r, g, b] integers. Premultiplying
|
|
137
|
+
# keeps parity with ImageMagick's resize-based average; per-band means
|
|
138
|
+
# come from vips_stats (row b+1, column 4 of the stats matrix).
|
|
139
|
+
def dominant_color(path, max_pixels)
|
|
140
|
+
VipsGlue.with_images do |track|
|
|
141
|
+
image, = load_image(track, String(path))
|
|
142
|
+
check_pixels!(image, max_pixels)
|
|
143
|
+
|
|
144
|
+
srgb =
|
|
145
|
+
if VipsGlue.colourspace_supported?(image)
|
|
146
|
+
track.call(VipsGlue.operation("colourspace", { in: image, space: "srgb" }))
|
|
147
|
+
else
|
|
148
|
+
image
|
|
149
|
+
end
|
|
150
|
+
has_alpha = VipsGlue.alpha?(srgb)
|
|
151
|
+
work = has_alpha ? track.call(VipsGlue.operation("premultiply", { in: srgb })) : srgb
|
|
152
|
+
|
|
153
|
+
stats = track.call(VipsGlue.operation("stats", { in: work }))
|
|
154
|
+
columns = VipsGlue.width(stats)
|
|
155
|
+
matrix = VipsGlue.image_bytes(stats).unpack("d*")
|
|
156
|
+
mean = ->(band) { matrix[(band + 1) * columns + 4] || 0.0 }
|
|
157
|
+
|
|
158
|
+
bands = VipsGlue.bands(work)
|
|
159
|
+
colour_bands = has_alpha ? bands - 1 : bands
|
|
160
|
+
colour_bands = colour_bands.clamp(1, 3)
|
|
161
|
+
raise InvalidImageError, "image has no colour bands" if colour_bands < 1
|
|
162
|
+
|
|
163
|
+
alpha_mean = has_alpha ? mean.call(bands - 1) : 255.0
|
|
164
|
+
(0...3).map do |band|
|
|
165
|
+
value = mean.call([band, colour_bands - 1].min)
|
|
166
|
+
value = alpha_mean.positive? ? value * 255.0 / alpha_mean : 0.0 if has_alpha
|
|
167
|
+
value.round.clamp(0, 255)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def pages(path, max_pixels)
|
|
173
|
+
VipsGlue.with_images do |track|
|
|
174
|
+
image, = load_image(track, String(path))
|
|
175
|
+
check_pixels!(image, max_pixels)
|
|
176
|
+
VipsGlue.pages(image)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def orientation(path, max_pixels)
|
|
181
|
+
VipsGlue.with_images do |track|
|
|
182
|
+
image, = load_image(track, String(path))
|
|
183
|
+
check_pixels!(image, max_pixels)
|
|
184
|
+
value = VipsGlue.orientation(image)
|
|
185
|
+
(1..8).cover?(value) ? value : 1
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Encodes a raw RGBA buffer (top-down rows) as PNG. Used by the
|
|
190
|
+
# pure-Ruby ICO decoder.
|
|
191
|
+
def png_from_rgba(bytes, width, height, output)
|
|
192
|
+
bytes = String(bytes)
|
|
193
|
+
width = Integer(width)
|
|
194
|
+
height = Integer(height)
|
|
195
|
+
raise ArgumentError, "width and height must be positive" if width <= 0 || height <= 0
|
|
196
|
+
raise LimitError, "rgba buffer dimensions exceed 4096x4096" if width > 4096 || height > 4096
|
|
197
|
+
raise ArgumentError, "rgba buffer must be width*height*4 bytes" if bytes.bytesize != width * height * 4
|
|
198
|
+
|
|
199
|
+
VipsGlue.with_images do |track|
|
|
200
|
+
image = track.call(VipsGlue.image_from_memory(bytes, width, height, 4, 0)) # 0 = uchar
|
|
201
|
+
srgb = track.call(VipsGlue.operation("copy", { in: image, interpretation: "srgb" }))
|
|
202
|
+
save_image(srgb, String(output), "png", 100)
|
|
203
|
+
end
|
|
204
|
+
true
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Renders a letter avatar: a Pango glyph mask blended in white at 80%
|
|
208
|
+
# opacity over a solid background via a single linear transform. The
|
|
209
|
+
# markup string is escaped by the Ruby caller; font and fontfile come
|
|
210
|
+
# from an allowlist.
|
|
211
|
+
def letter_avatar(output, size, red, green, blue, markup, font, fontfile)
|
|
212
|
+
size = Integer(size)
|
|
213
|
+
markup = String(markup)
|
|
214
|
+
font = String(font)
|
|
215
|
+
fontfile = String(fontfile)
|
|
216
|
+
channels = [Integer(red), Integer(green), Integer(blue)]
|
|
217
|
+
raise ArgumentError, "size must be 1..4096" unless (1..4096).cover?(size)
|
|
218
|
+
unless channels.all? { |value| (0..255).cover?(value) }
|
|
219
|
+
raise ArgumentError, "background channels must be 0..255"
|
|
220
|
+
end
|
|
221
|
+
unless VipsGlue.type_find?("text")
|
|
222
|
+
raise UnsupportedFormatError, "this libvips build has no text renderer (Pango support missing)"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
VipsGlue.with_images do |track|
|
|
226
|
+
mask =
|
|
227
|
+
if markup.empty?
|
|
228
|
+
# Blank letter: solid background only.
|
|
229
|
+
track.call(VipsGlue.operation("black", { width: size, height: size }))
|
|
230
|
+
else
|
|
231
|
+
text_inputs = { text: markup, font: font, dpi: 72 }
|
|
232
|
+
text_inputs[:fontfile] = fontfile unless fontfile.empty?
|
|
233
|
+
text = track.call(VipsGlue.operation("text", text_inputs))
|
|
234
|
+
|
|
235
|
+
# vips_text returns the tight ink box; crop to the canvas when
|
|
236
|
+
# the pointsize overflows it, then centre the ink optically.
|
|
237
|
+
text_w = VipsGlue.width(text)
|
|
238
|
+
text_h = VipsGlue.height(text)
|
|
239
|
+
if text_w > size || text_h > size
|
|
240
|
+
crop_w = [text_w, size].min
|
|
241
|
+
crop_h = [text_h, size].min
|
|
242
|
+
text = track.call(
|
|
243
|
+
VipsGlue.operation(
|
|
244
|
+
"extract_area",
|
|
245
|
+
{ input: text, left: (text_w - crop_w) / 2, top: (text_h - crop_h) / 2,
|
|
246
|
+
width: crop_w, height: crop_h }
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
text_w = crop_w
|
|
250
|
+
text_h = crop_h
|
|
251
|
+
end
|
|
252
|
+
track.call(
|
|
253
|
+
VipsGlue.operation(
|
|
254
|
+
"embed",
|
|
255
|
+
{ in: text, x: (size - text_w) / 2, y: (size - text_h) / 2, width: size, height: size }
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# blend = bg + (white - bg) * 0.8 * mask/255, one linear op.
|
|
261
|
+
opacity = 204.0 / 255.0 # FFFFFFCC
|
|
262
|
+
a = channels.map { |value| (255.0 - value) * opacity / 255.0 }
|
|
263
|
+
blended = track.call(VipsGlue.operation("linear", { in: mask, a: a, b: channels.map(&:to_f) }))
|
|
264
|
+
cast = track.call(VipsGlue.operation("cast", { in: blended, format: "uchar" }))
|
|
265
|
+
srgb = track.call(VipsGlue.operation("copy", { in: cast, interpretation: "srgb" }))
|
|
266
|
+
save_image(srgb, String(output), "png", 100)
|
|
267
|
+
end
|
|
268
|
+
true
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
private
|
|
272
|
+
|
|
273
|
+
def monotime
|
|
274
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def info(input_format, output_format, image, started)
|
|
278
|
+
{
|
|
279
|
+
input_format: input_format,
|
|
280
|
+
output_format: output_format,
|
|
281
|
+
width: VipsGlue.width(image),
|
|
282
|
+
height: VipsGlue.height(image),
|
|
283
|
+
duration_ms: monotime - started
|
|
284
|
+
}
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def normalized_format(ext)
|
|
288
|
+
case ext.to_s.downcase
|
|
289
|
+
when "jpg", "jpeg" then "jpg"
|
|
290
|
+
when "png" then "png"
|
|
291
|
+
when "webp" then "webp"
|
|
292
|
+
when "gif" then "gif"
|
|
293
|
+
when "heic", "heif" then "heic"
|
|
294
|
+
when "avif" then "avif"
|
|
295
|
+
when "jxl" then "jxl"
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def output_format!(format)
|
|
300
|
+
normalized = normalized_format(String(format))
|
|
301
|
+
raise UnsupportedFormatError, "unsupported output format" if normalized.nil? || normalized == "heic"
|
|
302
|
+
normalized
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def validate_quality!(quality)
|
|
306
|
+
raise ArgumentError, "quality must be 1..100" unless (1..100).cover?(quality)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def load_image(track, path)
|
|
310
|
+
format = normalized_format(File.extname(path).delete_prefix("."))
|
|
311
|
+
raise UnsupportedFormatError, "unsupported input format" unless format
|
|
312
|
+
|
|
313
|
+
case format
|
|
314
|
+
when "gif"
|
|
315
|
+
# libnsgif loader: first frame only (the n=1 default), matching the
|
|
316
|
+
# [0] semantics of the ImageMagick compatibility backend.
|
|
317
|
+
raise UnsupportedFormatError, "this libvips build has no GIF loader" unless VipsGlue.type_find?("gifload")
|
|
318
|
+
when "jxl"
|
|
319
|
+
raise UnsupportedFormatError, "this libvips build has no JPEG XL loader" unless VipsGlue.type_find?("jxlload")
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
image = track.call(
|
|
323
|
+
VipsGlue.operation(LOADERS.fetch(format), { filename: path, access: "sequential", fail_on: "error" })
|
|
324
|
+
)
|
|
325
|
+
[image, format]
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def check_pixels!(image, max_pixels)
|
|
329
|
+
if max_pixels.nil?
|
|
330
|
+
limit = DEFAULT_MAX_PIXELS
|
|
331
|
+
else
|
|
332
|
+
limit = Integer(max_pixels)
|
|
333
|
+
raise ArgumentError, "max_pixels must be positive" if limit <= 0
|
|
334
|
+
end
|
|
335
|
+
width = VipsGlue.width(image)
|
|
336
|
+
height = VipsGlue.height(image)
|
|
337
|
+
raise InvalidImageError, "image dimensions are invalid" if width <= 0 || height <= 0
|
|
338
|
+
pixels = width * height
|
|
339
|
+
raise LimitError, "image has #{pixels} pixels, exceeds #{limit}" if pixels > limit
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# libvips renamed the strip-metadata save option from "strip" to "keep"
|
|
343
|
+
# (VIPS_FOREIGN_KEEP_NONE = 0) in 8.15; pick the spelling at runtime so
|
|
344
|
+
# one gem build serves distro packages from 8.13 up.
|
|
345
|
+
def strip_args
|
|
346
|
+
@strip_args ||= (VipsGlue.version <=> [8, 15]) >= 0 ? { keep: 0 } : { strip: true }
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def save_image(image, path, format, quality)
|
|
350
|
+
case format
|
|
351
|
+
when "jpg"
|
|
352
|
+
VipsGlue.operation("jpegsave", { in: image, filename: path, Q: quality, interlace: false, **strip_args }, output: nil)
|
|
353
|
+
when "png"
|
|
354
|
+
VipsGlue.operation("pngsave", { in: image, filename: path, compression: 6, **strip_args }, output: nil)
|
|
355
|
+
when "webp"
|
|
356
|
+
VipsGlue.operation("webpsave", { in: image, filename: path, Q: quality, **strip_args }, output: nil)
|
|
357
|
+
when "avif"
|
|
358
|
+
VipsGlue.operation("heifsave", { in: image, filename: path, Q: quality, compression: "av1", **strip_args }, output: nil)
|
|
359
|
+
when "gif"
|
|
360
|
+
# cgif-backed saver; optional at libvips build time. GIF output is
|
|
361
|
+
# palette-quantised and has no quality parameter.
|
|
362
|
+
raise UnsupportedFormatError, "this libvips build cannot save GIF (cgif support missing)" unless VipsGlue.type_find?("gifsave")
|
|
363
|
+
VipsGlue.operation("gifsave", { in: image, filename: path, **strip_args }, output: nil)
|
|
364
|
+
when "jxl"
|
|
365
|
+
raise UnsupportedFormatError, "this libvips build cannot save JPEG XL" unless VipsGlue.type_find?("jxlsave")
|
|
366
|
+
VipsGlue.operation("jxlsave", { in: image, filename: path, Q: quality, **strip_args }, output: nil)
|
|
367
|
+
else
|
|
368
|
+
raise UnsupportedFormatError, "unsupported output format"
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
data/lib/safe_image/processor.rb
CHANGED
|
@@ -6,14 +6,14 @@ require "tempfile"
|
|
|
6
6
|
|
|
7
7
|
module SafeImage
|
|
8
8
|
class Processor
|
|
9
|
-
SUPPORTED_INPUTS = %w[jpg jpeg png webp heic heif avif].freeze
|
|
10
|
-
SUPPORTED_OUTPUTS = %w[jpg jpeg png webp avif].freeze
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@
|
|
9
|
+
SUPPORTED_INPUTS = %w[jpg jpeg png gif webp heic heif avif jxl].freeze
|
|
10
|
+
SUPPORTED_OUTPUTS = %w[jpg jpeg png gif webp avif jxl].freeze
|
|
11
|
+
# Formats the post-processing optimizer tools understand; other outputs
|
|
12
|
+
# skip the optimize pass instead of erroring.
|
|
13
|
+
OPTIMIZABLE_OUTPUTS = %w[jpg png].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(max_pixels: nil, chroma_subsampling: :auto)
|
|
16
|
+
@max_pixels = max_pixels || SafeImage.config.max_pixels
|
|
17
17
|
@chroma_subsampling = chroma_subsampling
|
|
18
18
|
end
|
|
19
19
|
|
|
@@ -50,49 +50,17 @@ module SafeImage
|
|
|
50
50
|
raise UnsupportedFormatError, "unsupported output format: #{out_format.inspect}"
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
if @execution == :sandbox || @execution == :sandbox_if_available
|
|
54
|
-
if @execution == :sandbox && !Sandbox.available?
|
|
55
|
-
raise Error, "sandbox execution requested but Landlock::SafeExec is unavailable"
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
info = Sandbox.thumbnail(
|
|
59
|
-
input: input.to_s,
|
|
60
|
-
output: output.to_s,
|
|
61
|
-
width: width,
|
|
62
|
-
height: height,
|
|
63
|
-
format: out_format,
|
|
64
|
-
quality: quality,
|
|
65
|
-
max_pixels: @max_pixels,
|
|
66
|
-
backend: @backend,
|
|
67
|
-
optimize: optimize,
|
|
68
|
-
optimize_mode: optimize_mode
|
|
69
|
-
)
|
|
70
|
-
if info
|
|
71
|
-
return Result.new(
|
|
72
|
-
input: input.to_s,
|
|
73
|
-
output: output.to_s,
|
|
74
|
-
input_format: info.fetch(:input_format),
|
|
75
|
-
output_format: info.fetch(:output_format),
|
|
76
|
-
width: info.fetch(:width),
|
|
77
|
-
height: info.fetch(:height),
|
|
78
|
-
filesize: File.size(output),
|
|
79
|
-
backend: "sandboxed-#{info.fetch(:backend)}",
|
|
80
|
-
duration_ms: info.fetch(:duration_ms),
|
|
81
|
-
optimizer: info[:optimizer]
|
|
82
|
-
)
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
|
|
86
53
|
output.dirname.mkpath
|
|
54
|
+
backend = SafeImage.config.backend
|
|
87
55
|
info =
|
|
88
|
-
if out_format == "jpg" && use_jpegli_for_generated_jpeg?(
|
|
56
|
+
if out_format == "jpg" && use_jpegli_for_generated_jpeg?(backend)
|
|
89
57
|
jpegli_thumbnail(input: input, output: output, width: width, height: height, quality: quality, source_format: input.extname.delete_prefix(".").downcase)
|
|
90
58
|
else
|
|
91
|
-
case
|
|
59
|
+
case backend
|
|
92
60
|
when :vips
|
|
93
61
|
Native.thumbnail(input.to_s, output.to_s, width, height, out_format, quality, @max_pixels)
|
|
94
|
-
when :imagemagick
|
|
95
|
-
probe_info =
|
|
62
|
+
when :imagemagick
|
|
63
|
+
probe_info = ImageMagickBackend.probe(input.to_s)
|
|
96
64
|
validate_pixels!(probe_info.fetch(:width), probe_info.fetch(:height))
|
|
97
65
|
ImageMagickBackend.thumbnail(
|
|
98
66
|
input: input.to_s,
|
|
@@ -102,13 +70,11 @@ module SafeImage
|
|
|
102
70
|
format: out_format,
|
|
103
71
|
quality: quality
|
|
104
72
|
)
|
|
105
|
-
else
|
|
106
|
-
raise ArgumentError, "unknown backend: #{@backend.inspect}"
|
|
107
73
|
end
|
|
108
74
|
end
|
|
109
75
|
|
|
110
76
|
opt_info = nil
|
|
111
|
-
if optimize
|
|
77
|
+
if optimize && OPTIMIZABLE_OUTPUTS.include?(out_format)
|
|
112
78
|
opt_info = Optimizer.optimize(output, mode: optimize_mode, strip_metadata: true, quality: out_format == "jpg" ? quality : nil)
|
|
113
79
|
end
|
|
114
80
|
|
|
@@ -120,7 +86,7 @@ module SafeImage
|
|
|
120
86
|
width: info.fetch(:width),
|
|
121
87
|
height: info.fetch(:height),
|
|
122
88
|
filesize: File.size(output),
|
|
123
|
-
backend: result_backend(info),
|
|
89
|
+
backend: result_backend(info, backend),
|
|
124
90
|
duration_ms: info.fetch(:duration_ms),
|
|
125
91
|
optimizer: opt_info&.fetch(:tools, nil)
|
|
126
92
|
)
|
|
@@ -128,23 +94,14 @@ module SafeImage
|
|
|
128
94
|
|
|
129
95
|
private
|
|
130
96
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
true
|
|
137
|
-
when :vips, :imagemagick, :magick
|
|
138
|
-
false
|
|
139
|
-
else
|
|
140
|
-
raise ArgumentError, "unknown encoder: #{@encoder.inspect}"
|
|
141
|
-
end
|
|
97
|
+
# cjpegli is an output-quality tool, not a configuration choice: installed
|
|
98
|
+
# means used. It encodes only pixels this gem already decoded, so it is
|
|
99
|
+
# not part of the untrusted-input surface the backend choice controls.
|
|
100
|
+
def use_jpegli_for_generated_jpeg?(backend)
|
|
101
|
+
backend == :vips && JpegliBackend.available?
|
|
142
102
|
end
|
|
143
103
|
|
|
144
104
|
def jpegli_thumbnail(input:, output:, width:, height:, quality:, source_format:)
|
|
145
|
-
raise UnsupportedFormatError, "cjpegli is not installed" unless JpegliBackend.available?
|
|
146
|
-
raise ArgumentError, "encoder: :cjpegli currently requires backend: :vips" unless @backend == :vips
|
|
147
|
-
|
|
148
105
|
output.dirname.mkpath
|
|
149
106
|
Tempfile.create([output.basename(".*").to_s, ".safe-image.png"], output.dirname.to_s) do |tmp|
|
|
150
107
|
tmp_path = Pathname.new(tmp.path)
|
|
@@ -167,12 +124,9 @@ module SafeImage
|
|
|
167
124
|
format == "jpeg" ? "jpg" : format
|
|
168
125
|
end
|
|
169
126
|
|
|
170
|
-
def result_backend(info)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
else
|
|
174
|
-
@backend == :vips ? "libvips-direct" : "imagemagick"
|
|
175
|
-
end
|
|
127
|
+
def result_backend(info, backend)
|
|
128
|
+
base = backend == :vips ? "libvips-direct" : "imagemagick"
|
|
129
|
+
info[:encoder] == "cjpegli" ? "#{base}+cjpegli" : base
|
|
176
130
|
end
|
|
177
131
|
|
|
178
132
|
def safe_existing_file!(path)
|
data/lib/safe_image/remote.rb
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "ipaddr"
|
|
5
|
-
require "net/http"
|
|
6
|
-
require "resolv"
|
|
7
5
|
require "tempfile"
|
|
8
6
|
require "time"
|
|
9
7
|
require "tmpdir"
|
|
10
8
|
require "uri"
|
|
9
|
+
# net/http and resolv are required lazily inside the methods that fetch, not at
|
|
10
|
+
# load time: requiring them pulls in resolv, which (Ruby 3.4+) reads
|
|
11
|
+
# /etc/resolv.conf eagerly. The Landlock-sandboxed worker loads this file but
|
|
12
|
+
# never performs remote fetches, and on hosts where /etc/resolv.conf is a
|
|
13
|
+
# symlink into /run the sandbox denies that read and the worker dies at boot.
|
|
11
14
|
|
|
12
15
|
module SafeImage
|
|
13
16
|
module Remote
|
|
@@ -39,10 +42,11 @@ module SafeImage
|
|
|
39
42
|
"image/avif" => ".avif",
|
|
40
43
|
"image/x-icon" => ".ico",
|
|
41
44
|
"image/vnd.microsoft.icon" => ".ico",
|
|
45
|
+
"image/jxl" => ".jxl",
|
|
42
46
|
"image/svg+xml" => ".svg"
|
|
43
47
|
}.freeze
|
|
44
48
|
|
|
45
|
-
EXTENSIONS = %w[.jpg .jpeg .png .gif .webp .heic .heif .avif .ico .svg].freeze
|
|
49
|
+
EXTENSIONS = %w[.jpg .jpeg .png .gif .webp .heic .heif .avif .ico .jxl .svg].freeze
|
|
46
50
|
|
|
47
51
|
BLOCKED_IP_RANGES = [
|
|
48
52
|
# IPv4 special-use / non-public ranges. Default remote fetching is for
|
|
@@ -151,7 +155,14 @@ module SafeImage
|
|
|
151
155
|
end
|
|
152
156
|
end
|
|
153
157
|
|
|
158
|
+
def dominant_color(url, max_bytes: DEFAULT_MAX_BYTES, max_redirects: DEFAULT_MAX_REDIRECTS, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, total_timeout: DEFAULT_TOTAL_TIMEOUT, allow_private: false, allowed_ports: DEFAULT_ALLOWED_PORTS, headers: {}, max_pixels: nil)
|
|
159
|
+
fetch(url, max_bytes: max_bytes, max_redirects: max_redirects, open_timeout: open_timeout, read_timeout: read_timeout, total_timeout: total_timeout, allow_private: allow_private, allowed_ports: allowed_ports, headers: headers) do |path|
|
|
160
|
+
SafeImage.dominant_color(path, max_pixels: max_pixels)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
154
164
|
def request(uri, io:, max_bytes:, max_redirects:, open_timeout:, read_timeout:, total_timeout:, started_at:, allow_private:, allowed_ports:, headers: {})
|
|
165
|
+
require "net/http"
|
|
155
166
|
raise ArgumentError, "too many redirects" if max_redirects < 0
|
|
156
167
|
check_deadline!(started_at, total_timeout)
|
|
157
168
|
ipaddr = validate_uri!(uri, allow_private: allow_private, allowed_ports: allowed_ports)
|
|
@@ -228,6 +239,7 @@ module SafeImage
|
|
|
228
239
|
end
|
|
229
240
|
return nil if allow_private
|
|
230
241
|
|
|
242
|
+
require "resolv"
|
|
231
243
|
resolver = Resolv::DNS.new
|
|
232
244
|
resolver.timeouts = [2, 2]
|
|
233
245
|
addresses = resolver.getaddresses(uri.host).map(&:to_s)
|
data/lib/safe_image/runner.rb
CHANGED
|
@@ -40,7 +40,7 @@ module SafeImage
|
|
|
40
40
|
Dir.mktmpdir("safe-image-command-") do |tmpdir|
|
|
41
41
|
child_env = command_env(tmpdir, env)
|
|
42
42
|
|
|
43
|
-
if sandbox || SafeImage.
|
|
43
|
+
if sandbox || SafeImage.sandbox?
|
|
44
44
|
return Sandbox.capture_command!(argv, read: read, write: [*write, tmpdir], timeout: timeout, env: child_env)
|
|
45
45
|
end
|
|
46
46
|
|