Module: SafeImage
- Defined in:
- lib/safe_image/remote.rb,
lib/safe_image.rb,
lib/safe_image.rb,
lib/safe_image/ico.rb,
lib/safe_image/native.rb,
lib/safe_image/result.rb,
lib/safe_image/runner.rb,
lib/safe_image/sandbox.rb,
lib/safe_image/version.rb,
lib/safe_image/optimizer.rb,
lib/safe_image/processor.rb,
lib/safe_image/vips_glue.rb,
lib/safe_image/path_safety.rb,
lib/safe_image/svg_metadata.rb,
lib/safe_image/vips_backend.rb,
lib/safe_image/svg_sanitizer.rb,
lib/safe_image/jpegli_backend.rb,
lib/safe_image/discourse_compat.rb,
lib/safe_image/image_magick_backend.rb
Overview
symlink into /run the sandbox denies that read and the worker dies at boot.
Defined Under Namespace
Modules: DiscourseCompat, Ico, ImageMagickBackend, JpegliBackend, Native, Optimizer, PathSafety, Remote, Runner, Sandbox, SvgMetadata, SvgSanitizer, VipsBackend, VipsGlue Classes: CommandError, Config, Error, Info, InvalidImageError, LimitError, NotConfiguredError, Processor, Result, UnsafePathError, UnsupportedFormatError, VipsUnavailableError
Constant Summary collapse
- DEFAULT_MAX_PIXELS =
Default decompression-bomb ceiling when configure! is not given an explicit max_pixels. Mirrored in the native binding (SAFE_IMAGE_DEFAULT_MAX_PIXELS) and aligned with the 128MP area limit on the ImageMagick path. Per-call max_pixels: overrides the configured value.
128 * 1024 * 1024
- BACKENDS =
%i[vips imagemagick].freeze
- VERSION =
"0.2.0"
Class Method Summary collapse
- .animated?(*args, **kwargs) ⇒ Boolean
- .config ⇒ Object
-
.configure!(backend:, landlock:, max_pixels: DEFAULT_MAX_PIXELS) ⇒ Object
Decides, in one place, everything that varies by host: which backend decodes untrusted bytes, whether operations run inside the Landlock sandbox, and the default decompression-bomb ceiling.
- .configured? ⇒ Boolean
- .convert(*args, **kwargs) ⇒ Object
- .convert_favicon_to_png(*args, **kwargs) ⇒ Object
- .convert_to_jpeg(*args, **kwargs) ⇒ Object
- .crop(*args, **kwargs) ⇒ Object
- .dimensions(path, max_pixels: nil) ⇒ Object
- .dominant_color(path, max_pixels: nil) ⇒ Object
- .downsize(*args, **kwargs) ⇒ Object
- .fastimage_type(format) ⇒ Object
- .fetch_remote(url, **kwargs, &block) ⇒ Object
- .fix_orientation(*args, **kwargs) ⇒ Object
- .frame_count(*args, **kwargs) ⇒ Object
- .imagemagick_dominant_color(path, max_pixels:) ⇒ Object
- .info(path, max_pixels: nil, animated: false, orientation: false) ⇒ Object
- .letter_avatar(*args, **kwargs) ⇒ Object
- .maybe_sandbox(operation, args: [], kwargs: {}) ⇒ Object
- .optimize(path, mode: :lossless, strip_metadata: true, quality: nil, strict: true) ⇒ Object
- .optimize_image!(*args, **kwargs) ⇒ Object
- .orientation(path, max_pixels: nil) ⇒ Object
- .probe(path, max_pixels: nil) ⇒ Object
- .remote_animated?(url, **kwargs) ⇒ Boolean
- .remote_dimensions(url, **kwargs) ⇒ Object
- .remote_dominant_color(url, **kwargs) ⇒ Object
- .remote_info(url, **kwargs) ⇒ Object
- .remote_size(url, **kwargs) ⇒ Object
- .remote_type(url, **kwargs) ⇒ Object
- .resize(*args, **kwargs) ⇒ Object
-
.resolved_max_pixels(max_pixels) ⇒ Object
Internal: per-call max_pixels overrides the configured default.
-
.sandbox? ⇒ Boolean
Internal: whether operations must route through the sandbox worker.
- .sandbox_available? ⇒ Boolean
- .sanitize_svg!(*args, **kwargs) ⇒ Object
- .size(path, max_pixels: nil) ⇒ Object
- .thumbnail(input:, output:, width:, height:, format: nil, quality: 85, max_pixels: nil, optimize: false, optimize_mode: :lossless, chroma_subsampling: :auto) ⇒ Object
- .type(path, max_pixels: nil) ⇒ Object
Class Method Details
.animated?(*args, **kwargs) ⇒ Boolean
360 361 362 363 364 365 366 |
# File 'lib/safe_image.rb', line 360 def animated?(*args, **kwargs) config path = args.first return false if path && File.extname(PathSafety.local_path(path)).downcase == ".svg" maybe_sandbox(:animated?, args: args, kwargs: kwargs) { DiscourseCompat.animated?(*args, **kwargs) } end |
.config ⇒ Object
92 93 94 |
# File 'lib/safe_image.rb', line 92 def config @config || raise(NotConfiguredError, "call SafeImage.configure!(backend: :vips | :imagemagick, landlock: true | false) before using SafeImage") end |
.configure!(backend:, landlock:, max_pixels: DEFAULT_MAX_PIXELS) ⇒ Object
Decides, in one place, everything that varies by host: which backend decodes untrusted bytes, whether operations run inside the Landlock sandbox, and the default decompression-bomb ceiling. Must be called before any operation; calling it again replaces the configuration.
Validation is eager so a misconfigured host fails at boot rather than on the first request.
62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/safe_image.rb', line 62 def configure!(backend:, landlock:, max_pixels: DEFAULT_MAX_PIXELS) backend = backend.to_sym unless BACKENDS.include?(backend) raise ArgumentError, "unknown backend: #{backend.inspect} (expected :vips or :imagemagick)" end unless [true, false].include?(landlock) raise ArgumentError, "landlock must be true or false, got: #{landlock.inspect}" end max_pixels = Integer(max_pixels) raise ArgumentError, "max_pixels must be positive" if max_pixels <= 0 case backend when :vips begin VipsGlue.init! rescue VipsUnavailableError => e raise Error, "backend: :vips requested but libvips is unavailable: #{e.}" end when :imagemagick unless Runner.available?("magick") || Runner.available?("convert") raise Error, "backend: :imagemagick requested but no magick/convert executable was found" end end if landlock && !Sandbox.available? raise Error, "landlock: true requested but the Landlock sandbox is unavailable on this host" end @config = Config.new(backend: backend, landlock: landlock, max_pixels: max_pixels) end |
.configured? ⇒ Boolean
96 |
# File 'lib/safe_image.rb', line 96 def configured? = !@config.nil? |
.convert(*args, **kwargs) ⇒ Object
340 341 342 |
# File 'lib/safe_image.rb', line 340 def convert(*args, **kwargs) maybe_sandbox(:convert, args: args, kwargs: kwargs) { DiscourseCompat.convert(*args, **kwargs) } end |
.convert_favicon_to_png(*args, **kwargs) ⇒ Object
352 353 354 |
# File 'lib/safe_image.rb', line 352 def convert_favicon_to_png(*args, **kwargs) maybe_sandbox(:convert_favicon_to_png, args: args, kwargs: kwargs) { DiscourseCompat.convert_favicon_to_png(*args, **kwargs) } end |
.convert_to_jpeg(*args, **kwargs) ⇒ Object
344 345 346 |
# File 'lib/safe_image.rb', line 344 def convert_to_jpeg(*args, **kwargs) maybe_sandbox(:convert_to_jpeg, args: args, kwargs: kwargs) { DiscourseCompat.convert_to_jpeg(*args, **kwargs) } end |
.crop(*args, **kwargs) ⇒ Object
332 333 334 |
# File 'lib/safe_image.rb', line 332 def crop(*args, **kwargs) maybe_sandbox(:crop, args: args, kwargs: kwargs) { DiscourseCompat.crop(*args, **kwargs) } end |
.dimensions(path, max_pixels: nil) ⇒ Object
190 191 192 |
# File 'lib/safe_image.rb', line 190 def dimensions(path, max_pixels: nil) size(path, max_pixels: max_pixels) end |
.dominant_color(path, max_pixels: nil) ⇒ Object
231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 |
# File 'lib/safe_image.rb', line 231 def dominant_color(path, max_pixels: nil) maybe_sandbox(:dominant_color, args: [path], kwargs: { max_pixels: max_pixels }) do max_pixels = resolved_max_pixels(max_pixels) case config.backend when :vips if File.extname(PathSafety.local_path(path)).downcase == ".ico" # Pure-Ruby ICO decode; vips only averages the decoded pixels. Ico.dominant_color(path, max_pixels: max_pixels) else VipsBackend.dominant_color(path, max_pixels: max_pixels) end when :imagemagick imagemagick_dominant_color(path, max_pixels: max_pixels) end end end |
.downsize(*args, **kwargs) ⇒ Object
336 337 338 |
# File 'lib/safe_image.rb', line 336 def downsize(*args, **kwargs) maybe_sandbox(:downsize, args: args, kwargs: kwargs) { DiscourseCompat.downsize(*args, **kwargs) } end |
.fastimage_type(format) ⇒ Object
255 256 257 |
# File 'lib/safe_image.rb', line 255 def fastimage_type(format) format.to_s == "jpg" ? :jpeg : format.to_s.to_sym end |
.fetch_remote(url, **kwargs, &block) ⇒ Object
288 289 290 291 |
# File 'lib/safe_image.rb', line 288 def fetch_remote(url, **kwargs, &block) config Remote.fetch(url, **kwargs, &block) end |
.fix_orientation(*args, **kwargs) ⇒ Object
348 349 350 |
# File 'lib/safe_image.rb', line 348 def fix_orientation(*args, **kwargs) maybe_sandbox(:fix_orientation, args: args, kwargs: kwargs) { DiscourseCompat.fix_orientation(*args, **kwargs) } end |
.frame_count(*args, **kwargs) ⇒ Object
356 357 358 |
# File 'lib/safe_image.rb', line 356 def frame_count(*args, **kwargs) maybe_sandbox(:frame_count, args: args, kwargs: kwargs) { DiscourseCompat.frame_count(*args, **kwargs) } end |
.imagemagick_dominant_color(path, max_pixels:) ⇒ Object
248 249 250 251 252 253 |
# File 'lib/safe_image.rb', line 248 def imagemagick_dominant_color(path, max_pixels:) # Probe first: rejects undecodable files and enforces the pixel cap # before ImageMagick fully decodes the image to average it. probe(path, max_pixels: max_pixels) ImageMagickBackend.dominant_color(path) end |
.info(path, max_pixels: nil, animated: false, orientation: false) ⇒ Object
194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 |
# File 'lib/safe_image.rb', line 194 def info(path, max_pixels: nil, animated: false, orientation: false) maybe_sandbox(:info, args: [path], kwargs: { max_pixels: max_pixels, animated: animated, orientation: orientation }) do result = probe(path, max_pixels: max_pixels) type = fastimage_type(result.input_format) Info.new( path: result.input, type: type, width: result.width, height: result.height, size: [result.width, result.height], animated: animated ? animated?(path, max_pixels: max_pixels) : nil, orientation: orientation ? orientation(path, max_pixels: max_pixels) : nil ) end end |
.letter_avatar(*args, **kwargs) ⇒ Object
368 369 370 |
# File 'lib/safe_image.rb', line 368 def letter_avatar(*args, **kwargs) maybe_sandbox(:letter_avatar, args: args, kwargs: kwargs) { DiscourseCompat.letter_avatar(*args, **kwargs) } end |
.maybe_sandbox(operation, args: [], kwargs: {}) ⇒ Object
112 113 114 115 116 117 |
# File 'lib/safe_image.rb', line 112 def maybe_sandbox(operation, args: [], kwargs: {}) config return yield unless sandbox? Sandbox.public_call!(operation, args: args, kwargs: kwargs) end |
.optimize(path, mode: :lossless, strip_metadata: true, quality: nil, strict: true) ⇒ Object
322 323 324 325 326 |
# File 'lib/safe_image.rb', line 322 def optimize(path, mode: :lossless, strip_metadata: true, quality: nil, strict: true) maybe_sandbox(:optimize, args: [path], kwargs: { mode: mode, strip_metadata: , quality: quality, strict: strict }) do Optimizer.optimize(path, mode: mode, strip_metadata: , quality: quality, strict: strict) end end |
.optimize_image!(*args, **kwargs) ⇒ Object
372 373 374 |
# File 'lib/safe_image.rb', line 372 def optimize_image!(*args, **kwargs) maybe_sandbox(:optimize_image!, args: args, kwargs: kwargs) { DiscourseCompat.optimize_image!(*args, **kwargs) } end |
.orientation(path, max_pixels: nil) ⇒ Object
210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 |
# File 'lib/safe_image.rb', line 210 def orientation(path, max_pixels: nil) maybe_sandbox(:orientation, args: [path], kwargs: { max_pixels: max_pixels }) do case File.extname(PathSafety.local_path(path)).downcase when ".svg", ".ico" # No EXIF orientation in either format; upright by definition. 1 else max_pixels = resolved_max_pixels(max_pixels) case config.backend when :vips # Header-only native read. VipsBackend.orientation(path, max_pixels: max_pixels) when :imagemagick # Probe first: rejects undecodable files and enforces the pixel cap. ImageMagickBackend.probe(path, max_pixels: max_pixels) ImageMagickBackend.orientation(path) end end end end |
.probe(path, max_pixels: nil) ⇒ Object
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 |
# File 'lib/safe_image.rb', line 119 def probe(path, max_pixels: nil) maybe_sandbox(:probe, args: [path], kwargs: { max_pixels: max_pixels }) do path = PathSafety.local_path(path) max_pixels = resolved_max_pixels(max_pixels) case File.extname(path).downcase when ".svg" info = SvgMetadata.probe(path, max_pixels: max_pixels) Result.new( input: File.(path), output: nil, input_format: "svg", output_format: nil, width: info.fetch(:width), height: info.fetch(:height), filesize: File.size(path), backend: "svg-metadata", duration_ms: info.fetch(:duration_ms), optimizer: nil ) when ".ico" # Pure-Ruby directory parse; reports the largest entry's dimensions. info = Ico.probe(path, max_pixels: max_pixels) Result.new( input: File.(path), output: nil, input_format: "ico", output_format: nil, width: info.fetch(:width), height: info.fetch(:height), filesize: File.size(path), backend: "ico-metadata", duration_ms: info.fetch(:duration_ms), optimizer: nil ) else case config.backend when :vips Processor.new(max_pixels: max_pixels).probe(path) when :imagemagick info = ImageMagickBackend.probe(path, max_pixels: max_pixels) Result.new( input: File.(path), output: nil, input_format: info.fetch(:input_format), output_format: nil, width: info.fetch(:width), height: info.fetch(:height), filesize: File.size(path), backend: "imagemagick", duration_ms: info.fetch(:duration_ms), optimizer: nil ) end end end end |
.remote_animated?(url, **kwargs) ⇒ Boolean
278 279 280 281 |
# File 'lib/safe_image.rb', line 278 def remote_animated?(url, **kwargs) config Remote.animated?(url, **kwargs) end |
.remote_dimensions(url, **kwargs) ⇒ Object
269 270 271 |
# File 'lib/safe_image.rb', line 269 def remote_dimensions(url, **kwargs) remote_size(url, **kwargs) end |
.remote_dominant_color(url, **kwargs) ⇒ Object
283 284 285 286 |
# File 'lib/safe_image.rb', line 283 def remote_dominant_color(url, **kwargs) config Remote.dominant_color(url, **kwargs) end |
.remote_info(url, **kwargs) ⇒ Object
259 260 261 262 |
# File 'lib/safe_image.rb', line 259 def remote_info(url, **kwargs) config Remote.info(url, **kwargs) end |
.remote_size(url, **kwargs) ⇒ Object
264 265 266 267 |
# File 'lib/safe_image.rb', line 264 def remote_size(url, **kwargs) config Remote.size(url, **kwargs) end |
.remote_type(url, **kwargs) ⇒ Object
273 274 275 276 |
# File 'lib/safe_image.rb', line 273 def remote_type(url, **kwargs) config Remote.type(url, **kwargs) end |
.resize(*args, **kwargs) ⇒ Object
328 329 330 |
# File 'lib/safe_image.rb', line 328 def resize(*args, **kwargs) maybe_sandbox(:resize, args: args, kwargs: kwargs) { DiscourseCompat.resize(*args, **kwargs) } end |
.resolved_max_pixels(max_pixels) ⇒ Object
Internal: per-call max_pixels overrides the configured default.
108 109 110 |
# File 'lib/safe_image.rb', line 108 def resolved_max_pixels(max_pixels) max_pixels.nil? ? config.max_pixels : max_pixels end |
.sandbox? ⇒ Boolean
Internal: whether operations must route through the sandbox worker. False before configure! (so configure!‘s own availability probes can run commands) and inside worker children (so sandboxed operations never nest).
103 104 105 |
# File 'lib/safe_image.rb', line 103 def sandbox? !!@config&.landlock && ENV["SAFE_IMAGE_SANDBOX_CHILD"] != "1" end |
.sandbox_available? ⇒ Boolean
98 |
# File 'lib/safe_image.rb', line 98 def sandbox_available? = Sandbox.available? |
.sanitize_svg!(*args, **kwargs) ⇒ Object
376 377 378 |
# File 'lib/safe_image.rb', line 376 def sanitize_svg!(*args, **kwargs) maybe_sandbox(:sanitize_svg!, args: args, kwargs: kwargs) { SvgSanitizer.sanitize!(*args, **kwargs) } end |
.size(path, max_pixels: nil) ⇒ Object
183 184 185 186 187 188 |
# File 'lib/safe_image.rb', line 183 def size(path, max_pixels: nil) maybe_sandbox(:size, args: [path], kwargs: { max_pixels: max_pixels }) do result = probe(path, max_pixels: max_pixels) [result.width, result.height] end end |
.thumbnail(input:, output:, width:, height:, format: nil, quality: 85, max_pixels: nil, optimize: false, optimize_mode: :lossless, chroma_subsampling: :auto) ⇒ Object
293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 |
# File 'lib/safe_image.rb', line 293 def thumbnail(input:, output:, width:, height:, format: nil, quality: 85, max_pixels: nil, optimize: false, optimize_mode: :lossless, chroma_subsampling: :auto) maybe_sandbox( :thumbnail, kwargs: { input: input, output: output, width: width, height: height, format: format, quality: quality, max_pixels: max_pixels, optimize: optimize, optimize_mode: optimize_mode, chroma_subsampling: chroma_subsampling } ) do Processor.new(max_pixels: resolved_max_pixels(max_pixels), chroma_subsampling: chroma_subsampling).thumbnail( input: input, output: output, width: width, height: height, format: format, quality: quality, optimize: optimize, optimize_mode: optimize_mode ) end end |
.type(path, max_pixels: nil) ⇒ Object
177 178 179 180 181 |
# File 'lib/safe_image.rb', line 177 def type(path, max_pixels: nil) maybe_sandbox(:type, args: [path], kwargs: { max_pixels: max_pixels }) do fastimage_type(probe(path, max_pixels: max_pixels).input_format) end end |