activestorage-hotcell-server 0.0.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/MIT-LICENSE +20 -0
- data/README.md +3 -0
- data/lib/active_storage/hot_cell/server/analyzers/image/magick.rb +62 -0
- data/lib/active_storage/hot_cell/server/analyzers/image/vips.rb +52 -0
- data/lib/active_storage/hot_cell/server/analyzers/media/ffprobe.rb +139 -0
- data/lib/active_storage/hot_cell/server/magick_operation.rb +64 -0
- data/lib/active_storage/hot_cell/server/operation.rb +43 -0
- data/lib/active_storage/hot_cell/server/previewers/pdf/mutool.rb +36 -0
- data/lib/active_storage/hot_cell/server/previewers/pdf/poppler.rb +38 -0
- data/lib/active_storage/hot_cell/server/previewers/pdf.rb +47 -0
- data/lib/active_storage/hot_cell/server/previewers/video/ffmpeg.rb +76 -0
- data/lib/active_storage/hot_cell/server/tool_operation.rb +71 -0
- data/lib/active_storage/hot_cell/server/transformers/image/magick.rb +45 -0
- data/lib/active_storage/hot_cell/server/transformers/image/vips.rb +56 -0
- data/lib/active_storage/hot_cell/server/transforming.rb +76 -0
- data/lib/active_storage/hot_cell/server/version.rb +9 -0
- data/lib/active_storage/hot_cell/server/vips_operation.rb +91 -0
- data/lib/active_storage/hot_cell/server.rb +21 -0
- data/lib/activestorage-hotcell-server.rb +5 -0
- metadata +91 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 42399adf3e7993d0df16dd7a13010053ff389ddad17b3c90a6f7705052b3c2e7
|
|
4
|
+
data.tar.gz: 324c2a21f2e55e7747355f2155542d5ef572164ac53938a1077db3156fbc1c87
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f596b308fbdf479792bdb2de90d1fb626ed9207c6194d60a618a1342cca51ae13a7f47a456c15c1ca764f807477070b6d7697f00bc6d4f1dfa7b31788fa37b94
|
|
7
|
+
data.tar.gz: a6b15cc763f189ce7249bb473f1cd11c24f4d112f8ecad66f97c2e4ad1afb63f845c201dd704e17147a690866dc79b1c49795b61f915719e1c28c91f952236b7
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright (c) 37signals, LLC
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_storage/hot_cell/server/magick_operation"
|
|
4
|
+
|
|
5
|
+
module ActiveStorage
|
|
6
|
+
module HotCell
|
|
7
|
+
module Server
|
|
8
|
+
module Analyzers
|
|
9
|
+
module Image
|
|
10
|
+
# What `ActiveStorage::Analyzer::ImageAnalyzer::ImageMagick` does, moved out of the application:
|
|
11
|
+
# width and height from `identify`, with the dimensions swapped when the stored orientation is a
|
|
12
|
+
# quarter turn, the way the shared image analyzer does it.
|
|
13
|
+
#
|
|
14
|
+
# The result is a superset — pages and animation, which the vips analyzer also reports — that the
|
|
15
|
+
# client analyzer slices to Rails' `{ width, height }`. An input ImageMagick cannot decode exits
|
|
16
|
+
# `identify` non-zero, which raises MiniMagick::Error, and the cell answers `unreadable`.
|
|
17
|
+
class Magick < MagickOperation
|
|
18
|
+
operation "active_storage.analyzers.image.magick"
|
|
19
|
+
|
|
20
|
+
limits deadline: 10, memory: 1024 * 1024**2, file_size: 48 * 1024**2, open_files: 64
|
|
21
|
+
|
|
22
|
+
# The orientation names identify reports for a quarter turn, matching Rails' rotated_image? on the
|
|
23
|
+
# ImageMagick path.
|
|
24
|
+
ROTATED = %w[ LeftTop RightTop RightBottom LeftBottom ].freeze
|
|
25
|
+
|
|
26
|
+
def perform(inputs, _outputs)
|
|
27
|
+
source, = inputs
|
|
28
|
+
frames = identified(source.path)
|
|
29
|
+
width, height, orientation = frames.first
|
|
30
|
+
|
|
31
|
+
{ **dimensions(width, height, orientation), pages: frames.size, animated: frames.size > 1,
|
|
32
|
+
bytes: source.to_io.stat.size }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
# One `identify` run reports every frame's dimensions and orientation, where MiniMagick::Image's
|
|
37
|
+
# accessors (`valid?`, `width`, `pages`) each spawn an identify of their own — four execs per
|
|
38
|
+
# analysis, two of them decoding every frame.
|
|
39
|
+
def identified(path)
|
|
40
|
+
frames = MiniMagick.identify do |identify|
|
|
41
|
+
identify.format "%w %h %[orientation]\n"
|
|
42
|
+
identify << path
|
|
43
|
+
end.lines.map(&:split)
|
|
44
|
+
|
|
45
|
+
raise MiniMagick::Invalid, "ImageMagick does not recognise this as an image" if frames.empty?
|
|
46
|
+
|
|
47
|
+
frames
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def dimensions(width, height, orientation)
|
|
51
|
+
if ROTATED.include?(orientation)
|
|
52
|
+
{ width: height.to_i, height: width.to_i }
|
|
53
|
+
else
|
|
54
|
+
{ width: width.to_i, height: height.to_i }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_storage/hot_cell/server/vips_operation"
|
|
4
|
+
|
|
5
|
+
module ActiveStorage
|
|
6
|
+
module HotCell
|
|
7
|
+
module Server
|
|
8
|
+
module Analyzers
|
|
9
|
+
module Image
|
|
10
|
+
# Metadata and no bytes, which is the request shape with no outputs at all.
|
|
11
|
+
#
|
|
12
|
+
# Shipping this is mandatory rather than a nicety. The built-in image analyzers gate `accept?` on
|
|
13
|
+
# `variant_processor` being `:vips` or `:mini_magick`, so a class value makes them all decline,
|
|
14
|
+
# `analyzer_class` falls through to `NullAnalyzer`, and the blob is marked analyzed with no dimensions
|
|
15
|
+
# at all.
|
|
16
|
+
#
|
|
17
|
+
# There is a sharper version of the same problem inside the built-in vips analyzer, which this
|
|
18
|
+
# deliberately does not copy: it rescues every Vips::Error and returns an empty hash, which is then
|
|
19
|
+
# merged with `analyzed: true`. An undecodable image is recorded as successfully analyzed, forever,
|
|
20
|
+
# and nothing re-enqueues AnalyzeJob. Here an undecodable input raises Vips::Error, the cell answers
|
|
21
|
+
# `unreadable`, and the client decides — because only the client knows whether that verdict is safe
|
|
22
|
+
# to write down.
|
|
23
|
+
class Vips < VipsOperation
|
|
24
|
+
operation "active_storage.analyzers.image.vips"
|
|
25
|
+
|
|
26
|
+
# Analysis reads a header rather than decoding a whole image, so it gets far less room than a
|
|
27
|
+
# transform.
|
|
28
|
+
limits deadline: 10, memory: 1024 * 1024**2, file_size: 48 * 1024**2, open_files: 64
|
|
29
|
+
|
|
30
|
+
def perform(inputs, _outputs)
|
|
31
|
+
source, = inputs
|
|
32
|
+
image = ::Vips::Image.new_from_file(source.fd_path, access: :sequential)
|
|
33
|
+
|
|
34
|
+
{ **dimensions_of(image), **frames_of(image),
|
|
35
|
+
bytes: source.to_io.stat.size, tracked_mem_highwater: vips_highwater }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
# `pages` is what tells a caller whether asking for `loader: { n: -1 }` would mean anything, and
|
|
40
|
+
# it is one of the things a custom analyzer produces that Rails' does not.
|
|
41
|
+
def frames_of(image)
|
|
42
|
+
pages = image.get("n-pages").to_i
|
|
43
|
+
{ pages: pages, animated: pages > 1 }
|
|
44
|
+
rescue ::Vips::Error
|
|
45
|
+
{ pages: 1, animated: false }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "active_storage/hot_cell/server/tool_operation"
|
|
5
|
+
|
|
6
|
+
module ActiveStorage
|
|
7
|
+
module HotCell
|
|
8
|
+
module Server
|
|
9
|
+
module Analyzers
|
|
10
|
+
module Media
|
|
11
|
+
# What Rails' video and audio analyzers do, moved out of the application: ffprobe, and the numbers it
|
|
12
|
+
# reports shaped exactly as `Analyzer::VideoAnalyzer` and `Analyzer::AudioAnalyzer` shape them. One
|
|
13
|
+
# operation serves both client analyzers, because ffprobe reports video and audio streams in one pass.
|
|
14
|
+
#
|
|
15
|
+
# **This reads its tool's stdout, and that is a judgement, so here it is.**
|
|
16
|
+
#
|
|
17
|
+
# ffprobe parses the media in an exec'd child that dies at the end of the call. What comes back into this
|
|
18
|
+
# worker is JSON on a bounded buffer, parsed by `JSON.parse` — not a media decoder, and not a parser with a
|
|
19
|
+
# history of memory-safety bugs. The question that decides whether recycling a worker is safe is whether a
|
|
20
|
+
# malicious input can execute in this process, and through ffprobe's JSON it cannot. Reading a tool's
|
|
21
|
+
# *output file* with an in-process media library is in-process decoding; parsing its structured stdout with
|
|
22
|
+
# the standard library is not.
|
|
23
|
+
#
|
|
24
|
+
# The result is a superset of what either analyzer writes, and the client analyzers slice it to Rails'
|
|
25
|
+
# exact keys — the same split the image analyzer uses. Two things here are deliberately not Rails. Every
|
|
26
|
+
# number is coerced tolerantly rather than with `Integer()`/`Float()` that raise, because ffprobe's output
|
|
27
|
+
# is attacker-controlled where Rails' is trusted; a value that is not a clean number is dropped rather than
|
|
28
|
+
# crashing the analysis. And `tags` — title, artist, arbitrary bytes that need not be valid UTF-8 — are
|
|
29
|
+
# never returned, where Rails writes them straight into the database.
|
|
30
|
+
class Ffprobe < ToolOperation
|
|
31
|
+
operation "active_storage.analyzers.media.ffprobe"
|
|
32
|
+
|
|
33
|
+
limits deadline: 30, memory: 1024 * 1024**2, file_size: 48 * 1024**2, open_files: 128
|
|
34
|
+
|
|
35
|
+
ROTATIONS = [ 90, 270, -90, -270 ].freeze
|
|
36
|
+
|
|
37
|
+
# `probe_arguments` is `config.active_storage.ffprobe_arguments`, split, and it goes where Rails
|
|
38
|
+
# puts it: before the input path, which is where an input option such as `-codec_whitelist` has
|
|
39
|
+
# to be to take effect.
|
|
40
|
+
def perform(inputs, _outputs, probe_arguments: [])
|
|
41
|
+
source, = inputs
|
|
42
|
+
probed = JSON.parse(run!("ffprobe", "-v", "quiet", "-print_format", "json",
|
|
43
|
+
"-show_format", "-show_streams",
|
|
44
|
+
*arguments!(:probe_arguments, probe_arguments), source.fd_path,
|
|
45
|
+
pass: [ source.to_io ]).out)
|
|
46
|
+
|
|
47
|
+
video = stream_of(probed, "video")
|
|
48
|
+
audio = stream_of(probed, "audio")
|
|
49
|
+
|
|
50
|
+
{ duration: duration(probed, video, audio), bytes: source.to_io.stat.size,
|
|
51
|
+
video: !video.nil?, audio: !audio.nil? }
|
|
52
|
+
.merge(video ? video_metadata(video) : {})
|
|
53
|
+
.merge(audio ? audio_metadata(audio) : {})
|
|
54
|
+
.compact
|
|
55
|
+
rescue JSON::ParserError => error
|
|
56
|
+
raise UnreadableDocument, "ffprobe said something that is not JSON: #{error.message[0, 120]}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
def stream_of(probed, type)
|
|
61
|
+
Array(probed["streams"]).find { |stream| stream["codec_type"] == type }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def duration(probed, video, audio)
|
|
65
|
+
source = if video
|
|
66
|
+
video["duration"] || probed.dig("format", "duration")
|
|
67
|
+
elsif audio
|
|
68
|
+
audio["duration"]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
float(source)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Rails' width and height: the encoded width, and a height recomputed from the display aspect ratio
|
|
75
|
+
# so an anamorphic stream reports its displayed shape rather than its stored one — width-preserving,
|
|
76
|
+
# the way VideoAnalyzer does it. Swapped when the stream is stored rotated a quarter turn.
|
|
77
|
+
def video_metadata(stream)
|
|
78
|
+
encoded_width = float(stream["width"])
|
|
79
|
+
encoded_height = float(stream["height"])
|
|
80
|
+
ratio = display_aspect_ratio(stream)
|
|
81
|
+
displayed_height = computed_height(encoded_width, ratio) || encoded_height
|
|
82
|
+
angle = angle(stream)
|
|
83
|
+
rotated = ROTATIONS.include?(angle)
|
|
84
|
+
|
|
85
|
+
{ width: rotated ? displayed_height : encoded_width,
|
|
86
|
+
height: rotated ? encoded_width : displayed_height,
|
|
87
|
+
angle: angle, display_aspect_ratio: ratio,
|
|
88
|
+
video_codec: SCRUB[stream["codec_name"]] }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def audio_metadata(stream)
|
|
92
|
+
{ sample_rate: integer(stream["sample_rate"]), bit_rate: integer(stream["bit_rate"]),
|
|
93
|
+
audio_codec: SCRUB[stream["codec_name"]] }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def computed_height(encoded_width, ratio)
|
|
97
|
+
return nil unless encoded_width && ratio
|
|
98
|
+
|
|
99
|
+
encoded_width * (ratio.last.to_f / ratio.first)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def display_aspect_ratio(stream)
|
|
103
|
+
descriptor = stream["display_aspect_ratio"].to_s
|
|
104
|
+
numerator, denominator = descriptor.split(":", 2).map { |term| integer(term) }
|
|
105
|
+
[ numerator, denominator ] if numerator&.positive? && denominator&.positive?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The angle Rails reports: a rotate tag first, then a Display Matrix side-data entry. Anything that
|
|
109
|
+
# is not a clean integer is nothing, because this rides untrusted ffprobe output.
|
|
110
|
+
def angle(stream)
|
|
111
|
+
tags = stream["tags"] || {}
|
|
112
|
+
return integer(tags["rotate"]) if tags["rotate"]
|
|
113
|
+
|
|
114
|
+
matrix = Array(stream["side_data_list"]).find { |data| data["side_data_type"] == "Display Matrix" }
|
|
115
|
+
integer(matrix["rotation"]) if matrix
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Numbers, and codec names matched against a conservative pattern. A tag or a codec that is not one
|
|
119
|
+
# of these is dropped rather than carried into a database as arbitrary bytes.
|
|
120
|
+
CODEC = /\A[a-z0-9_]{1,32}\z/
|
|
121
|
+
SCRUB = ->(value) { value.to_s.match?(CODEC) ? value.to_s : nil }
|
|
122
|
+
|
|
123
|
+
def integer(value)
|
|
124
|
+
number = float(value)
|
|
125
|
+
number&.to_i
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def float(value)
|
|
129
|
+
return nil if value.nil?
|
|
130
|
+
|
|
131
|
+
parsed = Float(value, exception: false)
|
|
132
|
+
parsed if parsed&.finite?
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_storage/hot_cell/server/operation"
|
|
4
|
+
|
|
5
|
+
# Loaded here rather than lazily in a worker, so mini_magick resolves the ImageMagick binary once at boot
|
|
6
|
+
# rather than shelling out to find it on the first request under a tight limit. mini_magick shells out for
|
|
7
|
+
# every operation, so unlike libvips there is no thread pool that fork would break — but keeping the two
|
|
8
|
+
# toolchains' loads in the same place is what makes a single cell carrying both legible.
|
|
9
|
+
require "image_processing/mini_magick"
|
|
10
|
+
|
|
11
|
+
# Invariant 9: a tool sees only the environment its operation wrote for it. `Operation#run_tool` holds it
|
|
12
|
+
# with `unsetenv_others: true`, and these operations do not use it — mini_magick spawns `magick` itself.
|
|
13
|
+
# `MiniMagick.restricted_env` is `false` by default, and with it false mini_magick calls
|
|
14
|
+
# `Open3.popen3({}, *command, unsetenv_others: false)`, so `magick` inherited this worker's whole
|
|
15
|
+
# environment. `bin/conformance` did not catch it, because its environment check drives an operation that
|
|
16
|
+
# goes through `run_tool`.
|
|
17
|
+
#
|
|
18
|
+
# `cli_env` names the locale for the reason `tool_environment` does: a tool's output must not shift under
|
|
19
|
+
# it. mini_magick passes `HOME`, `PATH` and `LANG` and does not pass `LC_ALL`, so both go here.
|
|
20
|
+
#
|
|
21
|
+
# Set at require time rather than in `before_worker_boot`, so that the binary lookup this require performs
|
|
22
|
+
# is covered too. Note what this is: mini_magick filters the environment it was given, where `run_tool`
|
|
23
|
+
# writes a fresh one. Driving `magick` directly would make it ours, and that is the separate enhancement
|
|
24
|
+
# this class already names.
|
|
25
|
+
MiniMagick.restricted_env = true
|
|
26
|
+
MiniMagick.cli_env = { "LANG" => "C.UTF-8", "LC_ALL" => "C.UTF-8" }
|
|
27
|
+
|
|
28
|
+
module ActiveStorage
|
|
29
|
+
module HotCell
|
|
30
|
+
module Server
|
|
31
|
+
# Everything that transforms or analyses an image with ImageMagick, through mini_magick.
|
|
32
|
+
#
|
|
33
|
+
# ImageMagick runs as an exec'd `magick` process that dies at the end of the call, so a decompression
|
|
34
|
+
# bomb executes in a child rather than in this worker. What this does touch in-process is mini_magick's
|
|
35
|
+
# own output — dimensions from `identify`, an exit status — which is a number and a string, not a
|
|
36
|
+
# decoder. The distinction is the same one probe_media draws for ffprobe.
|
|
37
|
+
#
|
|
38
|
+
# Unlike the vips operations, the input is staged onto scratch: mini_magick spawns its own `magick` and
|
|
39
|
+
# does not inherit this worker's descriptors, so an input cannot be handed to it as /dev/fd. That
|
|
40
|
+
# returns the file_size ceiling to this path — an input larger than the operation's file_size dies
|
|
41
|
+
# being staged — which the vips operations shed. Removing it means driving `magick` directly rather
|
|
42
|
+
# than through mini_magick, and is a separate enhancement.
|
|
43
|
+
class MagickOperation < Operation
|
|
44
|
+
abstract_operation
|
|
45
|
+
|
|
46
|
+
# MiniMagick::Error is how mini_magick reports a `magick` that exited non-zero — the common shape of an
|
|
47
|
+
# input it cannot decode. MiniMagick::Invalid is an input `identify` rejects outright. Both are the
|
|
48
|
+
# input's fault rather than the operation's.
|
|
49
|
+
unreadable MiniMagick::Error, MiniMagick::Invalid
|
|
50
|
+
|
|
51
|
+
# **Accepted risk.** A tool's output is not bounded on this path. `Operation#run_tool` caps what it
|
|
52
|
+
# reads at 64KB and drops the rest as it arrives, because an input that makes a tool print gigabytes
|
|
53
|
+
# of diagnostics costs this worker gigabytes of address space, takes RLIMIT_DATA with it, and arrives
|
|
54
|
+
# as a `memory` verdict — which is permanent, for a document whose only crime was being noisy.
|
|
55
|
+
# mini_magick reads both streams to EOF in a thread apiece and has no setting that bounds either;
|
|
56
|
+
# `graphicsmagick`, `cli_prefix`, `cli_env`, `restricted_env`, `timeout`, `logger`, `tmpdir`,
|
|
57
|
+
# `errors` and `warnings` are the whole list. The premise is that patching the library is worse than
|
|
58
|
+
# carrying this, and that the operations move off it: driving `magick` directly through `run_tool`
|
|
59
|
+
# bounds the output, returns the environment to us, and sheds the input staging, which is
|
|
60
|
+
# basecamp/hotcell#7.
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "hot_cell/server"
|
|
4
|
+
|
|
5
|
+
module ActiveStorage
|
|
6
|
+
# Despite the namespace, nothing here loads Active Storage. The name says which consumer these operations
|
|
7
|
+
# serve, not what they link against, and the gem stays small because a smaller graph is a smaller thing to
|
|
8
|
+
# audit — not because a cell polices what runs inside it. It does not: the container is the control, and what
|
|
9
|
+
# an operation chooses to require is its own business.
|
|
10
|
+
module HotCell
|
|
11
|
+
# Everything the cell side defines lives under this, and everything the application side defines lives under
|
|
12
|
+
# ActiveStorage::HotCell::Client. Not a tidying convention: the two gems are never both loaded in production,
|
|
13
|
+
# and a cell is forked from a process that may well have loaded the client — after which a shared name is a
|
|
14
|
+
# superclass mismatch while the cell boots. Two namespaces make that impossible rather than avoided.
|
|
15
|
+
module Server
|
|
16
|
+
# What every operation in this gem shares, whichever toolchain performs it.
|
|
17
|
+
class Operation < ::HotCell::Operation
|
|
18
|
+
abstract_operation
|
|
19
|
+
|
|
20
|
+
# A lookup, not a gate: which format a caller may ask for is decided by the toolchain's own build,
|
|
21
|
+
# the way Rails decides it. This only names the content type in a result, and a format it has never
|
|
22
|
+
# heard of gets none.
|
|
23
|
+
CONTENT_TYPES = {
|
|
24
|
+
"png" => "image/png",
|
|
25
|
+
"jpg" => "image/jpeg",
|
|
26
|
+
"jpeg" => "image/jpeg",
|
|
27
|
+
"webp" => "image/webp",
|
|
28
|
+
"gif" => "image/gif",
|
|
29
|
+
"avif" => "image/avif",
|
|
30
|
+
"tiff" => "image/tiff",
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
# A caller breaking the protocol is a caller bug, not a bad document. Raising MessageError is what
|
|
35
|
+
# makes the cell answer `invalid`, which is permanent and which the client raises rather than
|
|
36
|
+
# turning into a placeholder.
|
|
37
|
+
def refuse!(message)
|
|
38
|
+
raise ::HotCell::MessageError, message
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_storage/hot_cell/server/previewers/pdf"
|
|
4
|
+
|
|
5
|
+
module ActiveStorage
|
|
6
|
+
module HotCell
|
|
7
|
+
module Server
|
|
8
|
+
module Previewers
|
|
9
|
+
class Pdf
|
|
10
|
+
# What `ActiveStorage::Previewer::MuPDFPreviewer` does, moved out of the application.
|
|
11
|
+
#
|
|
12
|
+
# Rails runs `mutool draw -F png -o - <file> 1` and streams the result through the web process. This
|
|
13
|
+
# writes to the worker's own scratch instead of to stdout.
|
|
14
|
+
class Mutool < Pdf
|
|
15
|
+
operation "active_storage.previewers.pdf.mutool"
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
# The input is read through its descriptor, but the output is staged: mutool unlinks its output
|
|
19
|
+
# path before writing, and /dev/fd cannot be unlinked, so a passed output descriptor fails with
|
|
20
|
+
# "Operation not permitted". The staged PNG is copied out by Output#post. Streaming mutool's
|
|
21
|
+
# stdout to the descriptor — what Rails does with `-o -` — would remove the copy, and is a
|
|
22
|
+
# separate change.
|
|
23
|
+
def render(source, destination, page:, resolution:)
|
|
24
|
+
run! "mutool", "draw", "-F", "png", "-r", resolution.to_s, "-o", destination.path,
|
|
25
|
+
source.fd_path, page.to_s, pass: [ source.to_io ]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def tool
|
|
29
|
+
"mutool"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_storage/hot_cell/server/previewers/pdf"
|
|
4
|
+
|
|
5
|
+
module ActiveStorage
|
|
6
|
+
module HotCell
|
|
7
|
+
module Server
|
|
8
|
+
module Previewers
|
|
9
|
+
class Pdf
|
|
10
|
+
# What `ActiveStorage::Previewer::PopplerPDFPreviewer` does, moved out of the application: the Poppler
|
|
11
|
+
# sibling of the mutool preview, for an image whose Rails default previewer chain reaches pdftoppm
|
|
12
|
+
# first.
|
|
13
|
+
#
|
|
14
|
+
# Rails runs `pdftoppm -singlefile -cropbox -r 72 -png <file>` and streams the result through the web
|
|
15
|
+
# process. This reads the input through its descriptor and writes to the worker's own scratch.
|
|
16
|
+
class Poppler < Pdf
|
|
17
|
+
operation "active_storage.previewers.pdf.poppler"
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
# pdftoppm appends `.png` to its output root, so the frame lands beside the scratch name and is
|
|
21
|
+
# adopted into place; Output#post makes the one copy out through the caller's descriptor.
|
|
22
|
+
def render(source, destination, page:, resolution:)
|
|
23
|
+
run! "pdftoppm", "-png", "-singlefile", "-cropbox", "-r", resolution.to_s,
|
|
24
|
+
"-f", page.to_s, "-l", page.to_s, source.fd_path, destination.path,
|
|
25
|
+
pass: [ source.to_io ]
|
|
26
|
+
|
|
27
|
+
destination.adopt destination.path(extension: "png")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def tool
|
|
31
|
+
"pdftoppm"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_storage/hot_cell/server/tool_operation"
|
|
4
|
+
|
|
5
|
+
module ActiveStorage
|
|
6
|
+
module HotCell
|
|
7
|
+
module Server
|
|
8
|
+
module Previewers
|
|
9
|
+
# What the two PDF previewers share: one bounded page, rendered to PNG on the worker's own scratch, so
|
|
10
|
+
# a pathological page cannot be answered by buffering an unbounded PNG in memory — the file is bounded
|
|
11
|
+
# by the cell's `file_size` limit and the kernel enforces it. A subclass supplies the tool run.
|
|
12
|
+
#
|
|
13
|
+
# The result carries no dimensions, and that is what Rails does rather than a concession to make here. A
|
|
14
|
+
# previewer yields `io:`, `filename:` and `content_type:` and nothing else; `Preview#process` attaches that
|
|
15
|
+
# as a new blob, and the dimensions come later from analyzing *that* blob like any other. So a drop-in
|
|
16
|
+
# replacement returns no dimensions either.
|
|
17
|
+
#
|
|
18
|
+
# It is also what keeps the `:subprocess` claim true. Reading the produced PNG here would mean parsing
|
|
19
|
+
# bytes the tool just made out of a hostile PDF, in this worker, which is exactly what turns a
|
|
20
|
+
# subprocess operation into an in-process one.
|
|
21
|
+
class Pdf < ToolOperation
|
|
22
|
+
abstract_operation
|
|
23
|
+
|
|
24
|
+
limits deadline: 30, memory: 1024 * 1024**2, file_size: 48 * 1024**2, open_files: 128
|
|
25
|
+
|
|
26
|
+
MAX_PAGE = 10_000
|
|
27
|
+
MAX_RESOLUTION = 600
|
|
28
|
+
|
|
29
|
+
def perform(inputs, outputs, page: 1, resolution: 72)
|
|
30
|
+
source, = inputs
|
|
31
|
+
destination, = outputs
|
|
32
|
+
|
|
33
|
+
page = positive_integer!(:page, page, MAX_PAGE)
|
|
34
|
+
resolution = positive_integer!(:resolution, resolution, MAX_RESOLUTION)
|
|
35
|
+
|
|
36
|
+
render source, destination, page: page, resolution: resolution
|
|
37
|
+
|
|
38
|
+
staged = File.exist?(destination.path) ? File.size(destination.path) : 0
|
|
39
|
+
|
|
40
|
+
{ format: "png", content_type: "image/png", page: page, resolution: resolution,
|
|
41
|
+
bytes: produced!(staged, tool) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_storage/hot_cell/server/tool_operation"
|
|
4
|
+
|
|
5
|
+
module ActiveStorage
|
|
6
|
+
module HotCell
|
|
7
|
+
module Server
|
|
8
|
+
module Previewers
|
|
9
|
+
module Video
|
|
10
|
+
# What `ActiveStorage::Previewer::VideoPreviewer` does, moved out of the application.
|
|
11
|
+
#
|
|
12
|
+
# Rails runs `ffmpeg <input args> -i <file> <output args> -` and lets an application set both
|
|
13
|
+
# argument lists as shell strings it splits with Shellwords. This honours the input half:
|
|
14
|
+
# `video_preview_input_arguments` arrives split, and it goes before `-i` exactly as it does in
|
|
15
|
+
# Rails, because that is where an input option has to be. The output half is fixed here — see
|
|
16
|
+
# RELEVANT_FRAME — and the only other thing a caller says is how many seconds in to seek.
|
|
17
|
+
class Ffmpeg < ToolOperation
|
|
18
|
+
operation "active_storage.previewers.video.ffmpeg"
|
|
19
|
+
|
|
20
|
+
# Video is the reason a cell exists as a separate accessory. A preview measured in minutes and a
|
|
21
|
+
# thumbnail measured in milliseconds must not share a concurrency limit, so this is sized to be
|
|
22
|
+
# given a cell of its own rather than dropped in beside the image operations.
|
|
23
|
+
limits deadline: 120, memory: 1536 * 1024**2, file_size: 128 * 1024**2, open_files: 128
|
|
24
|
+
|
|
25
|
+
MAX_SEEK = 86_400
|
|
26
|
+
|
|
27
|
+
# Rails' own frame selection, verbatim from the 7.0 defaults: frame 0, keyframes, and scene changes
|
|
28
|
+
# over 0.015 are selected, and the loop/trim pair yields the second of those with the first as the
|
|
29
|
+
# fallback — so a video that opens on black previews as its first scene rather than as the black.
|
|
30
|
+
# This is the output half of `video_preview_arguments`, and it stays fixed: the input half is
|
|
31
|
+
# what an application needs to shape, and that travels separately as `input_arguments`.
|
|
32
|
+
RELEVANT_FRAME = 'select=eq(n\,0)+eq(key\,1)+gt(scene\,0.015),loop=loop=-1:size=2,trim=start_frame=1'
|
|
33
|
+
|
|
34
|
+
def perform(inputs, outputs, seek: 0, input_arguments: [])
|
|
35
|
+
source, = inputs
|
|
36
|
+
destination, = outputs
|
|
37
|
+
|
|
38
|
+
seek = seek!(seek)
|
|
39
|
+
input_arguments = arguments!(:input_arguments, input_arguments)
|
|
40
|
+
|
|
41
|
+
# JPEG rather than PNG, because Rails' own video previewer yields image/jpeg and this is meant to
|
|
42
|
+
# drop into its place. A preview that changed the attached blob's content type would not be a
|
|
43
|
+
# replacement.
|
|
44
|
+
#
|
|
45
|
+
# -ss before -i seeks by keyframe, which is orders of magnitude cheaper on a long file than
|
|
46
|
+
# decoding up to the point. -nostdin because ffmpeg reads the terminal otherwise, and a worker has
|
|
47
|
+
# no terminal. The application's input arguments go directly before -i, so they cannot drift
|
|
48
|
+
# past it as the fixed flags around them change.
|
|
49
|
+
#
|
|
50
|
+
# Both descriptors go to ffmpeg: it reads /dev/fd for the source and writes /dev/fd for the frame,
|
|
51
|
+
# so neither the input nor the output touches scratch. Writing the descriptor directly leaves
|
|
52
|
+
# partial bytes in the caller's file on a mid-write failure where a staged output would have left
|
|
53
|
+
# it empty — harmless here, because run! turns a non-zero exit into a refusal and the previewer
|
|
54
|
+
# discards its output on any failure, but it is a real property of the direct write.
|
|
55
|
+
run! "ffmpeg", "-nostdin", "-loglevel", "error", "-ss", seek.to_s,
|
|
56
|
+
*input_arguments, "-i", source.fd_path,
|
|
57
|
+
"-vf", RELEVANT_FRAME,
|
|
58
|
+
"-frames:v", "1", "-f", "image2", "-c:v", "mjpeg", "-y", destination.fd_path,
|
|
59
|
+
pass: [ source.to_io, destination.to_io ]
|
|
60
|
+
|
|
61
|
+
{ format: "jpg", content_type: "image/jpeg", seek: seek,
|
|
62
|
+
bytes: produced!(destination.to_io.stat.size, "ffmpeg") }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
def seek!(seek)
|
|
67
|
+
return seek if seek.is_a?(Numeric) && !seek.negative? && seek <= MAX_SEEK
|
|
68
|
+
|
|
69
|
+
refuse! "seek #{seek.inspect} must be a number of seconds between 0 and #{MAX_SEEK}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_storage/hot_cell/server/operation"
|
|
4
|
+
|
|
5
|
+
module ActiveStorage
|
|
6
|
+
module HotCell
|
|
7
|
+
module Server
|
|
8
|
+
# Everything that hands the untrusted bytes to an exec'd tool rather than parsing them here.
|
|
9
|
+
#
|
|
10
|
+
# A malicious input executes inside a child that dies at the end of the conversion, and this worker only
|
|
11
|
+
# copies bytes, spawns, and reads an exit status. That is worth knowing when reading these operations,
|
|
12
|
+
# and it is worth being careful about when changing them: reading a tool's output with an
|
|
13
|
+
# in-process library — to put dimensions in the result, say — or parsing its stdout brings bytes a
|
|
14
|
+
# hostile input produced back into this worker. Neither happens here.
|
|
15
|
+
class ToolOperation < Operation
|
|
16
|
+
abstract_operation
|
|
17
|
+
|
|
18
|
+
# A tool that exits non-zero on a document it cannot read is the commonest failure on this path, and
|
|
19
|
+
# it is the input's fault rather than the operation's. No `unreadable` declaration is needed:
|
|
20
|
+
# UnreadableInput is always in the rescue list, and this descends from it.
|
|
21
|
+
class UnreadableDocument < ::HotCell::UnreadableInput; end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
# Runs the tool with unsetenv_others and a written environment, and turns a non-zero exit into
|
|
25
|
+
# `unreadable`. The tool's own stderr is the only useful diagnostic, and it is attacker-influenced,
|
|
26
|
+
# so it is capped and scrubbed on its way onto the wire like any other error message.
|
|
27
|
+
def run!(*command, pass: [])
|
|
28
|
+
result = run_tool(*command, pass: pass)
|
|
29
|
+
return result if result.ok?
|
|
30
|
+
|
|
31
|
+
raise UnreadableDocument, "#{command.first} exited #{result.status.exitstatus}: " \
|
|
32
|
+
"#{result.err.to_s.strip[0, 200]}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# An empty output means the tool said it succeeded and produced nothing, which the client would
|
|
36
|
+
# otherwise have to catch as zero bytes on a successful response. The caller measures the output
|
|
37
|
+
# wherever it landed — the descriptor's own file for a tool that wrote it directly, or the staged
|
|
38
|
+
# scratch path for one that could not.
|
|
39
|
+
def produced!(bytes, command)
|
|
40
|
+
raise UnreadableDocument, "#{command} wrote nothing" if bytes.zero?
|
|
41
|
+
|
|
42
|
+
bytes
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def positive_integer!(name, value, maximum)
|
|
46
|
+
return value if value.is_a?(Integer) && value.positive? && value <= maximum
|
|
47
|
+
|
|
48
|
+
refuse! "#{name} #{value.inspect} must be an integer between 1 and #{maximum}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Extra command-line arguments an application configures — `config.active_storage.ffprobe_arguments`
|
|
52
|
+
# and its siblings — spliced into the tool's argv at the position that setting names. The client
|
|
53
|
+
# splits the shell string, so what arrives is already argv, and this checks only that it is: what
|
|
54
|
+
# the flags mean is the application's decision, exactly as it is when Rails runs the tool itself.
|
|
55
|
+
#
|
|
56
|
+
# **Accepted risk.** These are tool options and nothing here restricts which. `-o` makes ffprobe
|
|
57
|
+
# write a file, `-dump_attachment` makes ffmpeg extract one, and no shell is involved in either.
|
|
58
|
+
# The premise is that the payload comes from the trusted side: an application's own configuration
|
|
59
|
+
# crosses a socket only that application can reach, and Rails hands the same strings to the same
|
|
60
|
+
# tools when it runs them itself. An allowlist here would be a second, worse copy of a decision the
|
|
61
|
+
# application already made. What would change the premise is the work socket becoming reachable by
|
|
62
|
+
# something other than the application.
|
|
63
|
+
def arguments!(name, value)
|
|
64
|
+
return value if value.is_a?(Array) && value.all?(String)
|
|
65
|
+
|
|
66
|
+
refuse! "#{name} must be an array of strings, and this is #{value.inspect[0, 80]}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_storage/hot_cell/server/magick_operation"
|
|
4
|
+
require "active_storage/hot_cell/server/transforming"
|
|
5
|
+
|
|
6
|
+
module ActiveStorage
|
|
7
|
+
module HotCell
|
|
8
|
+
module Server
|
|
9
|
+
module Transformers
|
|
10
|
+
module Image
|
|
11
|
+
# What `ActiveStorage::Transformers::ImageMagick` does, moved out of the application: the same
|
|
12
|
+
# `source(file).loader(page: 0).convert(format).apply(operations)` pipeline, run through
|
|
13
|
+
# ImageProcessing::MiniMagick.
|
|
14
|
+
#
|
|
15
|
+
# The transformation allowlist Rails' ImageMagick transformer enforces —
|
|
16
|
+
# `supported_image_processing_methods` and the argument blocklist — runs on the client, where an
|
|
17
|
+
# application's Rails configuration applies today, and is deliberately not repeated here.
|
|
18
|
+
# ImageProcessing still refuses a name that is neither one of its operations nor a MiniMagick method,
|
|
19
|
+
# so `:system` and friends cannot reach `magick`.
|
|
20
|
+
class Magick < MagickOperation
|
|
21
|
+
include Transforming
|
|
22
|
+
|
|
23
|
+
operation "active_storage.transformers.image.magick"
|
|
24
|
+
|
|
25
|
+
limits deadline: 30, memory: 1280 * 1024**2, file_size: 48 * 1024**2, open_files: 256
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
def processor
|
|
29
|
+
ImageProcessing::MiniMagick
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The staged path rather than /dev/fd; MagickOperation's comment says why.
|
|
33
|
+
def source_path(source)
|
|
34
|
+
source.path
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def describe(path, format)
|
|
38
|
+
{ format: format, content_type: CONTENT_TYPES[format.downcase], bytes: File.size(path) }.compact
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_storage/hot_cell/server/vips_operation"
|
|
4
|
+
require "active_storage/hot_cell/server/transforming"
|
|
5
|
+
|
|
6
|
+
module ActiveStorage
|
|
7
|
+
module HotCell
|
|
8
|
+
module Server
|
|
9
|
+
module Transformers
|
|
10
|
+
module Image
|
|
11
|
+
# What `ActiveStorage::Transformers::ImageProcessingTransformer` does, moved out of the application,
|
|
12
|
+
# and deliberately nothing more: the transformations and the format reach ImageProcessing exactly as
|
|
13
|
+
# Rails hands them over.
|
|
14
|
+
#
|
|
15
|
+
# There is no allowlist here, and that mirrors Rails: the vips path validates exactly one thing —
|
|
16
|
+
# `combine_options` is refused — and `ActiveStorage.supported_image_processing_methods` is enforced by
|
|
17
|
+
# the ImageMagick transformer alone. ImageProcessing itself refuses a name that is not one of its own
|
|
18
|
+
# operations or a `Vips::Image` method, which rules out `:system` and friends. Bounding the operation
|
|
19
|
+
# set and the keys inside `loader`/`saver` is a planned, separate deliverable — see the README — and
|
|
20
|
+
# until then the cell's limits are the bound: RLIMIT_DATA, RLIMIT_FSIZE and the deadline still apply,
|
|
21
|
+
# so a pipeline libvips has been told not to bound costs the caller a killed worker.
|
|
22
|
+
class Vips < VipsOperation
|
|
23
|
+
include Transforming
|
|
24
|
+
|
|
25
|
+
operation "active_storage.transformers.image.vips"
|
|
26
|
+
|
|
27
|
+
limits deadline: 30, memory: 1280 * 1024**2, file_size: 48 * 1024**2, open_files: 256
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
def processor
|
|
31
|
+
ImageProcessing::Vips
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def source_path(source)
|
|
35
|
+
source.fd_path
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Read back what was just written. Several callers need it: an analyzer returns metadata and no
|
|
39
|
+
# bytes at all, and a thumbnailer needs the output's own dimensions rather than the ones it asked
|
|
40
|
+
# for.
|
|
41
|
+
#
|
|
42
|
+
# This parses bytes libvips produced from a hostile input, in this worker. That is already true of
|
|
43
|
+
# everything above: this operation parses hostile bytes in the worker, and reading its own output
|
|
44
|
+
# is one more place it does so.
|
|
45
|
+
def describe(path, format)
|
|
46
|
+
image = ::Vips::Image.new_from_file(path)
|
|
47
|
+
|
|
48
|
+
{ format: format, content_type: CONTENT_TYPES[format.downcase], bytes: File.size(path),
|
|
49
|
+
tracked_mem_highwater: vips_highwater, **dimensions_of(image) }.compact
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_storage/hot_cell/server/operation"
|
|
4
|
+
|
|
5
|
+
module ActiveStorage
|
|
6
|
+
module HotCell
|
|
7
|
+
module Server
|
|
8
|
+
# The transform shared by the two toolchains. An includer names its ImageProcessing backend with
|
|
9
|
+
# `processor`, how the source reaches it with `source_path`, and what it reports with `describe`.
|
|
10
|
+
module Transforming
|
|
11
|
+
def perform(inputs, outputs, format:, operations: {})
|
|
12
|
+
source, = inputs
|
|
13
|
+
destination, = outputs
|
|
14
|
+
|
|
15
|
+
format = format.to_s
|
|
16
|
+
|
|
17
|
+
# ImageProcessing chooses its saver from the destination's extension, and a scratch path has none,
|
|
18
|
+
# so encode to a suffixed sibling on the slot's own scratch and adopt it into place. That is one
|
|
19
|
+
# rename rather than the copy-out-of-Dir.tmpdir that a destination-less `call` would do, and it
|
|
20
|
+
# keeps ImageProcessing's own saver — quality, strip, format defaults — rather than reaching past
|
|
21
|
+
# it to the library, which cannot reproduce those without restating them. Output#post makes the
|
|
22
|
+
# one remaining copy, out through the caller's descriptor.
|
|
23
|
+
encoded = destination.path(extension: format)
|
|
24
|
+
pipeline(source_path(source), format, operations).call(destination: encoded)
|
|
25
|
+
destination.adopt encoded
|
|
26
|
+
|
|
27
|
+
describe destination.path, format
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
# `loader(page: 0)` first is what Rails does, and it is what stops a multi-page TIFF or a
|
|
32
|
+
# hundred-frame GIF being decoded in full to produce one thumbnail. A caller's own `loader` arrives
|
|
33
|
+
# through `apply` and merges over it, which is also what Rails does — asking for every frame is
|
|
34
|
+
# `loader: { n: -1 }`.
|
|
35
|
+
def pipeline(path, format, operations)
|
|
36
|
+
processor
|
|
37
|
+
.source(path)
|
|
38
|
+
.loader(page: 0)
|
|
39
|
+
.convert(format)
|
|
40
|
+
.apply(operations_for(operations))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# What Rails validates, and no more. `combine_options` is refused because it can never be one
|
|
44
|
+
# pipeline, and a blank argument means "skip this operation" — Rails filters on `present?`, and the
|
|
45
|
+
# test below is that semantic reimplemented, because the cell does not load Active Support.
|
|
46
|
+
#
|
|
47
|
+
# **Accepted risk.** Any other name reaches ImageProcessing, which dispatches it to the backend.
|
|
48
|
+
# `ImageProcessing.unsafe_method?` stops the Ruby core methods, so `send` and `system` cannot be
|
|
49
|
+
# reached, and it does not stop a genuine one: `write_to_file` and `dzsave` are real libvips
|
|
50
|
+
# operations that write where they are told. The premise is that the transformations come from the
|
|
51
|
+
# trusted side, and that this is no more permissive than stock Rails, whose own Vips transformer
|
|
52
|
+
# refuses `combine_options` and nothing else. The ImageMagick allowlist Rails added after
|
|
53
|
+
# CVE-2022-21831 runs on the client, where the application's configuration for it applies — see
|
|
54
|
+
# `Transformers::Image::Magick` on that side, which calls `operations` for that effect.
|
|
55
|
+
def operations_for(declared)
|
|
56
|
+
refuse! "operations must be an object, and this is a #{declared.class}" unless declared.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
declared.filter_map do |name, argument|
|
|
59
|
+
if name.to_s == "combine_options"
|
|
60
|
+
refuse! "combine_options is not supported, because it cannot generate a single command"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
[ name, argument ] unless blank?(argument)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def blank?(argument)
|
|
68
|
+
return true if argument.nil? || argument == false
|
|
69
|
+
return argument.match?(/\A[[:space:]]*\z/) if argument.is_a?(String)
|
|
70
|
+
|
|
71
|
+
argument.respond_to?(:empty?) && argument.empty?
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_storage/hot_cell/server/operation"
|
|
4
|
+
|
|
5
|
+
# Loaded here, in the supervisor, rather than lazily in a worker.
|
|
6
|
+
#
|
|
7
|
+
# Two reasons, and the second is the one that bites. It forces libvips' loader plugins to be dlopened now: left
|
|
8
|
+
# to load lazily in a worker, they load *after* that worker's limits are on, and a tight memory limit makes
|
|
9
|
+
# dlopen fail with only a VIPS-WARNING — after which the process runs on with HEIC and AVIF quietly missing. A
|
|
10
|
+
# resource-limit breach becomes `unreadable` for an entire format family, which is the worst confusion this
|
|
11
|
+
# error taxonomy can produce.
|
|
12
|
+
#
|
|
13
|
+
# It is also what makes `unreadable Vips::Error` below expressible at all.
|
|
14
|
+
require "image_processing/vips"
|
|
15
|
+
|
|
16
|
+
module ActiveStorage
|
|
17
|
+
module HotCell
|
|
18
|
+
module Server
|
|
19
|
+
# Everything that parses an image with libvips, which happens in the worker's own address space.
|
|
20
|
+
class VipsOperation < Operation
|
|
21
|
+
abstract_operation
|
|
22
|
+
|
|
23
|
+
# Vips::Error is how libvips reports a file it cannot decode, which is common rather than exceptional: it
|
|
24
|
+
# covers truncated uploads, formats this build was not compiled with, and formats deliberately refused by
|
|
25
|
+
# block_untrusted. All of those are the input's fault and none is the operation's.
|
|
26
|
+
unreadable Vips::Error
|
|
27
|
+
|
|
28
|
+
# Requires and configures. It must never evaluate an image, and the reason is mechanical: libvips starts
|
|
29
|
+
# its thread pool on the first evaluation, that pool does not survive fork, and the child then waits on a
|
|
30
|
+
# pool with no threads.
|
|
31
|
+
#
|
|
32
|
+
# Measured on this design's own machine, with libvips 8.18: after the require and the block below, the
|
|
33
|
+
# process has three threads and every forked child converts. One `Vips::Image.black(1,1).avg` takes it to
|
|
34
|
+
# five, and from then on every forked child blocks forever in futex_do_wait. Not the first child — every
|
|
35
|
+
# child. The suite holds this.
|
|
36
|
+
before_fork do
|
|
37
|
+
unless Vips.respond_to?(:block_untrusted)
|
|
38
|
+
raise ::HotCell::ConfigurationError,
|
|
39
|
+
"libvips' unfuzzed operations are not safe on untrusted content and this build cannot disable " \
|
|
40
|
+
"them. That needs libvips 8.13 or later and ruby-vips 2.2.1 or later."
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# There is no `Vips.block_untrusted true` here, and that is a decision rather than an omission.
|
|
45
|
+
#
|
|
46
|
+
# The gemspec pins image_processing to 2.0 or later, which blocks libvips' unfuzzed loaders as it loads.
|
|
47
|
+
# It skips that call when `VIPS_BLOCK_UNTRUSTED` is in the environment, and measuring rather than reading
|
|
48
|
+
# is what settled this: libvips honours that variable itself, so the loaders are blocked with the variable
|
|
49
|
+
# unset, set, or set to the empty string. A call here would be a fourth road to a place already reached
|
|
50
|
+
# three ways.
|
|
51
|
+
#
|
|
52
|
+
# What none of them covers is somebody calling `Vips.block_untrusted false` afterwards — and a call at
|
|
53
|
+
# worker boot would not cover it either, since an operation could do it inside `perform`.
|
|
54
|
+
# blocked_loaders_test.rb holds the property, which is the thing worth holding.
|
|
55
|
+
#
|
|
56
|
+
# The operation cache is set to nothing on purpose. Above `max_requests_per_worker: 1` it would span requests inside one
|
|
57
|
+
# worker, which is a place one request's image data can sit while the next one runs.
|
|
58
|
+
before_worker_boot do
|
|
59
|
+
Vips.concurrency_set Integer(ENV.fetch("VIPS_CONCURRENCY", "2"))
|
|
60
|
+
Vips.cache_set_max 0
|
|
61
|
+
Vips.cache_set_max_mem 0
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
# EXIF says the pixels are stored rotated, so the dimensions a caller cares about are swapped. Rails'
|
|
66
|
+
# own analyzer does the same thing for the same orientations.
|
|
67
|
+
SWAPPED_ORIENTATIONS = (5..8)
|
|
68
|
+
|
|
69
|
+
def dimensions_of(image)
|
|
70
|
+
if SWAPPED_ORIENTATIONS.cover?(orientation_of(image))
|
|
71
|
+
{ width: image.height, height: image.width }
|
|
72
|
+
else
|
|
73
|
+
{ width: image.width, height: image.height }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def orientation_of(image)
|
|
78
|
+
image.get("exif-ifd0-Orientation").to_i
|
|
79
|
+
rescue ::Vips::Error
|
|
80
|
+
0
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The only number that says whether a cell's `memory` limit is sized right: RSS understates the charge
|
|
84
|
+
# and VmSize overstates it by more than twice.
|
|
85
|
+
def vips_highwater
|
|
86
|
+
::Vips.tracked_mem_highwater
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_storage/hot_cell/server/version"
|
|
4
|
+
|
|
5
|
+
require "active_storage/hot_cell/server/operation"
|
|
6
|
+
require "active_storage/hot_cell/server/transforming"
|
|
7
|
+
|
|
8
|
+
require "active_storage/hot_cell/server/vips_operation"
|
|
9
|
+
require "active_storage/hot_cell/server/transformers/image/vips"
|
|
10
|
+
require "active_storage/hot_cell/server/analyzers/image/vips"
|
|
11
|
+
|
|
12
|
+
require "active_storage/hot_cell/server/magick_operation"
|
|
13
|
+
require "active_storage/hot_cell/server/transformers/image/magick"
|
|
14
|
+
require "active_storage/hot_cell/server/analyzers/image/magick"
|
|
15
|
+
|
|
16
|
+
require "active_storage/hot_cell/server/tool_operation"
|
|
17
|
+
require "active_storage/hot_cell/server/previewers/pdf"
|
|
18
|
+
require "active_storage/hot_cell/server/previewers/pdf/mutool"
|
|
19
|
+
require "active_storage/hot_cell/server/previewers/pdf/poppler"
|
|
20
|
+
require "active_storage/hot_cell/server/previewers/video/ffmpeg"
|
|
21
|
+
require "active_storage/hot_cell/server/analyzers/media/ffprobe"
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Bundler auto-requires this gem as "activestorage-hotcell-server", then as "activestorage/hotcell/server".
|
|
4
|
+
# It uses neither path. Without this file, naming it in a Gemfile silently loads nothing.
|
|
5
|
+
require "active_storage/hot_cell/server"
|
metadata
CHANGED
|
@@ -1,24 +1,108 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activestorage-hotcell-server
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mike Dalessio
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
-
dependencies:
|
|
12
|
-
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: hotcell-server
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - '='
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.2.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - '='
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.2.0
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: image_processing
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: mini_magick
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '4.0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '4.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: ruby-vips
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '2.2'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '2.2'
|
|
68
|
+
description: |
|
|
69
|
+
The HotCell operations behind activestorage-hotcell-client: image transformation with libvips or
|
|
70
|
+
ImageMagick, image and media analysis, PDF previews with mutool or poppler, and video previews with
|
|
71
|
+
ffmpeg. Install it in the cell, with the tools the operations you load require.
|
|
13
72
|
email:
|
|
14
73
|
- mike@37signals.com
|
|
15
74
|
executables: []
|
|
16
75
|
extensions: []
|
|
17
76
|
extra_rdoc_files: []
|
|
18
|
-
files:
|
|
77
|
+
files:
|
|
78
|
+
- MIT-LICENSE
|
|
79
|
+
- README.md
|
|
80
|
+
- lib/active_storage/hot_cell/server.rb
|
|
81
|
+
- lib/active_storage/hot_cell/server/analyzers/image/magick.rb
|
|
82
|
+
- lib/active_storage/hot_cell/server/analyzers/image/vips.rb
|
|
83
|
+
- lib/active_storage/hot_cell/server/analyzers/media/ffprobe.rb
|
|
84
|
+
- lib/active_storage/hot_cell/server/magick_operation.rb
|
|
85
|
+
- lib/active_storage/hot_cell/server/operation.rb
|
|
86
|
+
- lib/active_storage/hot_cell/server/previewers/pdf.rb
|
|
87
|
+
- lib/active_storage/hot_cell/server/previewers/pdf/mutool.rb
|
|
88
|
+
- lib/active_storage/hot_cell/server/previewers/pdf/poppler.rb
|
|
89
|
+
- lib/active_storage/hot_cell/server/previewers/video/ffmpeg.rb
|
|
90
|
+
- lib/active_storage/hot_cell/server/tool_operation.rb
|
|
91
|
+
- lib/active_storage/hot_cell/server/transformers/image/magick.rb
|
|
92
|
+
- lib/active_storage/hot_cell/server/transformers/image/vips.rb
|
|
93
|
+
- lib/active_storage/hot_cell/server/transforming.rb
|
|
94
|
+
- lib/active_storage/hot_cell/server/version.rb
|
|
95
|
+
- lib/active_storage/hot_cell/server/vips_operation.rb
|
|
96
|
+
- lib/activestorage-hotcell-server.rb
|
|
97
|
+
homepage: https://github.com/basecamp/hotcell
|
|
19
98
|
licenses:
|
|
20
99
|
- MIT
|
|
21
|
-
metadata:
|
|
100
|
+
metadata:
|
|
101
|
+
homepage_uri: https://github.com/basecamp/hotcell
|
|
102
|
+
source_code_uri: https://github.com/basecamp/hotcell/tree/v0.2.0/activestorage-hotcell-server
|
|
103
|
+
changelog_uri: https://github.com/basecamp/hotcell/blob/v0.2.0/CHANGELOG.md
|
|
104
|
+
bug_tracker_uri: https://github.com/basecamp/hotcell/issues
|
|
105
|
+
rubygems_mfa_required: 'true'
|
|
22
106
|
rdoc_options: []
|
|
23
107
|
require_paths:
|
|
24
108
|
- lib
|
|
@@ -26,7 +110,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
26
110
|
requirements:
|
|
27
111
|
- - ">="
|
|
28
112
|
- !ruby/object:Gem::Version
|
|
29
|
-
version: '
|
|
113
|
+
version: '3.3'
|
|
30
114
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
31
115
|
requirements:
|
|
32
116
|
- - ">="
|
|
@@ -35,5 +119,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
35
119
|
requirements: []
|
|
36
120
|
rubygems_version: 4.0.16
|
|
37
121
|
specification_version: 4
|
|
38
|
-
summary:
|
|
122
|
+
summary: The operations a cell runs on behalf of Active Storage.
|
|
39
123
|
test_files: []
|