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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d671aeb422ca384fe64dd59d2f53174760c04801f37e75f51488ca5cf34f6fe2
4
- data.tar.gz: dae5536d6035f67546136f12ffe1fc52d602bdffc8dd5eeba120fec2313c4cd7
3
+ metadata.gz: 42399adf3e7993d0df16dd7a13010053ff389ddad17b3c90a6f7705052b3c2e7
4
+ data.tar.gz: 324c2a21f2e55e7747355f2155542d5ef572164ac53938a1077db3156fbc1c87
5
5
  SHA512:
6
- metadata.gz: 23612a5bc095201e6131c061067af692af514913a88b13cb04851f72758af0341895c4571a4e195d9dbb58e647321a25eead9b9832da94bf267a6395556fa203
7
- data.tar.gz: 8cf8d948c812075b068479e25737a0d43999831f2dea87a018d6d62fe8c1049d96bb27076f88173ed91d4bd4ae1939669d768402ee9e8ce8e5c215c14014d909
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,3 @@
1
+ # activestorage-hotcell-server
2
+
3
+ Part of [HotCell](https://github.com/basecamp/hotcell). See the repository README.
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module HotCell
5
+ module Server
6
+ VERSION = "0.2.0"
7
+ end
8
+ end
9
+ 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.0.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
- description: To be released soon, secure sidecar for Rails with Active Storage extensions
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: '0'
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: To be released soon, secure sidecar for Rails with Active Storage extensions
122
+ summary: The operations a cell runs on behalf of Active Storage.
39
123
  test_files: []