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.
@@ -1,3 +1,373 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "safe_image_native"
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
@@ -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
- def initialize(max_pixels: nil, backend: :vips, execution: :inline, encoder: :auto, chroma_subsampling: :auto)
13
- @max_pixels = max_pixels
14
- @backend = backend.to_sym
15
- @execution = execution.to_sym
16
- @encoder = encoder.to_sym
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?(input)
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 @backend
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, :magick
95
- probe_info = Native.probe(input.to_s)
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
- def use_jpegli_for_generated_jpeg?(input)
132
- case @encoder
133
- when :auto
134
- @backend == :vips && JpegliBackend.available?
135
- when :cjpegli
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
- if info[:encoder] == "cjpegli"
172
- "#{@backend == :vips ? "libvips-direct" : "imagemagick"}+cjpegli"
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)
@@ -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)
@@ -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.sandbox_enabled?
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