activestorage-hotcell-client 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.
Files changed (27) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +3 -0
  4. data/lib/active_storage/hot_cell/client/analyzers/analyzing.rb +53 -0
  5. data/lib/active_storage/hot_cell/client/analyzers/audio/ffprobe.rb +28 -0
  6. data/lib/active_storage/hot_cell/client/analyzers/audio.rb +28 -0
  7. data/lib/active_storage/hot_cell/client/analyzers/image/magick.rb +24 -0
  8. data/lib/active_storage/hot_cell/client/analyzers/image/vips.rb +23 -0
  9. data/lib/active_storage/hot_cell/client/analyzers/image.rb +34 -0
  10. data/lib/active_storage/hot_cell/client/analyzers/probing.rb +21 -0
  11. data/lib/active_storage/hot_cell/client/analyzers/video/ffprobe.rb +29 -0
  12. data/lib/active_storage/hot_cell/client/analyzers/video.rb +31 -0
  13. data/lib/active_storage/hot_cell/client/operations.rb +89 -0
  14. data/lib/active_storage/hot_cell/client/previewers/pdf/mutool.rb +29 -0
  15. data/lib/active_storage/hot_cell/client/previewers/pdf/poppler.rb +28 -0
  16. data/lib/active_storage/hot_cell/client/previewers/pdf.rb +16 -0
  17. data/lib/active_storage/hot_cell/client/previewers/previewing.rb +77 -0
  18. data/lib/active_storage/hot_cell/client/previewers/video/ffmpeg.rb +38 -0
  19. data/lib/active_storage/hot_cell/client/railtie.rb +37 -0
  20. data/lib/active_storage/hot_cell/client/tool_arguments.rb +36 -0
  21. data/lib/active_storage/hot_cell/client/transformers/image/magick.rb +41 -0
  22. data/lib/active_storage/hot_cell/client/transformers/image/vips.rb +35 -0
  23. data/lib/active_storage/hot_cell/client/transformers/transforming.rb +66 -0
  24. data/lib/active_storage/hot_cell/client/version.rb +9 -0
  25. data/lib/active_storage/hot_cell/client.rb +72 -0
  26. data/lib/activestorage-hotcell-client.rb +5 -0
  27. metadata +69 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3a1e753c9a98025000a66d4f8aebca975b816b65285c269e8b06f0883843a203
4
- data.tar.gz: dae5536d6035f67546136f12ffe1fc52d602bdffc8dd5eeba120fec2313c4cd7
3
+ metadata.gz: 668902d1347b4f118a31b299534d8b8bedfadd568cf3e4e6e872384ee6d6f83f
4
+ data.tar.gz: d55b6e9a965f5327fd2d41c2f94bd7a59e6b2af8083ea2a892a9e24b9db423cd
5
5
  SHA512:
6
- metadata.gz: f39f0f4ecb84fb0898933a4375520bf9bab0b84d86a28bf2df9a3a63f3a9cab90da5477b343f708b0204e3a957d3e8de4814d16a054481b1f17c4a4e7e0ec676
7
- data.tar.gz: 8cf8d948c812075b068479e25737a0d43999831f2dea87a018d6d62fe8c1049d96bb27076f88173ed91d4bd4ae1939669d768402ee9e8ce8e5c215c14014d909
6
+ metadata.gz: bad8f45d4e713516eb6e0c27a2a0b7ae764f48bfbffe4dd3cf81d73091276f274798a8a72eda5d45212443a120c649645c2f2861de5dad088e903aa77f6f82ae
7
+ data.tar.gz: 359071c3cae9ed347112afe70cd1a45d9dbbda0e1adca966d03fbcb887e16a56bebfc934997c62325b846a2560315c3f617e528cdc779303cf78aab25c0ac187
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-client
2
+
3
+ Part of [HotCell](https://github.com/basecamp/hotcell). See the repository README.
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_storage"
4
+ require "active_storage/analyzer"
5
+ require "active_support/core_ext/class/attribute"
6
+
7
+ module ActiveStorage
8
+ module HotCell
9
+ module Client
10
+ module Analyzers
11
+ # What every analyzer here shares: one round trip to the cell, and the permanent-versus-transient
12
+ # failure split. An analyzer names the client it asks and the KEYS it slices the cell's superset
13
+ # down to — the cell knows more than Rails writes, and an extra key would change the shape of every
14
+ # blob's metadata.
15
+ #
16
+ # **Which way `metadata` fails is the whole design of the method.** The built-in vips analyzer rescues
17
+ # every Vips::Error and returns an empty hash, which Rails then merges with `analyzed: true` — so an
18
+ # undecodable image is recorded as successfully analyzed, forever, and nothing ever re-enqueues
19
+ # AnalyzeJob. That is right for a permanent verdict and catastrophic for a transient one.
20
+ #
21
+ # So a permanent failure follows the built-in behaviour and lets the blob be marked analyzed, with the
22
+ # reason written to the log so it can be re-decided later against a newer library. A transient failure
23
+ # is not rescued at all, which is what leaves the blob `analyzed: false` and eligible to be tried
24
+ # again.
25
+ module Analyzing
26
+ def self.included(analyzer)
27
+ analyzer.class_attribute :client, instance_accessor: false
28
+ end
29
+
30
+ def metadata
31
+ measured.slice(*self.class::KEYS)
32
+ rescue self.class.client.cell.permanent => error
33
+ logger.warn "hotcell: #{blob.filename} could not be analyzed and will not be retried: #{error.message}"
34
+ {}
35
+ end
36
+
37
+ private
38
+ def measured
39
+ download_blob_to_tempfile do |file|
40
+ File.open(file.path, "rb") { |readable| self.class.client.perform_in_hotcell [ readable ], [], payload }
41
+ end
42
+ end
43
+
44
+ # What travels with the request beyond the descriptor. Nothing, for an analyzer that has no
45
+ # configuration to carry; the media analyzers override it with the application's ffprobe arguments.
46
+ def payload
47
+ {}
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_storage/hot_cell/client/operations"
4
+ require "active_storage/hot_cell/client/analyzers/audio"
5
+ require "active_storage/hot_cell/client/analyzers/probing"
6
+
7
+ module ActiveStorage
8
+ module HotCell
9
+ module Client
10
+ module Analyzers
11
+ class Audio
12
+ # What an application lists in `config.active_storage.analyzers`.
13
+ #
14
+ # config.active_storage.analyzers = [ ActiveStorage::HotCell::Client::Analyzers::Audio::Ffprobe, ... ]
15
+ #
16
+ # Shares the probe operation with the video analyzer; see Analyzers::Video::Ffprobe.
17
+ class Ffprobe < Audio
18
+ include Probing
19
+
20
+ self.client = Operations::Analyzers::Media::Ffprobe
21
+ end
22
+
23
+ FFprobe = Ffprobe
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_storage"
4
+ require "active_storage/analyzer"
5
+
6
+ require "active_storage/hot_cell/client/analyzers/analyzing"
7
+
8
+ module ActiveStorage
9
+ module HotCell
10
+ module Client
11
+ module Analyzers
12
+ # Rails' AudioAnalyzer, moved to the cell the way the video analyzer is.
13
+ #
14
+ # Rails' audio keys, minus `tags`: Rails writes raw container metadata into the database, and the
15
+ # cell refuses it because those bytes are attacker-controlled.
16
+ class Audio < ActiveStorage::Analyzer
17
+ include Analyzing
18
+
19
+ KEYS = %i[ duration bit_rate sample_rate ].freeze
20
+
21
+ def self.accept?(blob)
22
+ blob.audio?
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_storage/hot_cell/client/operations"
4
+ require "active_storage/hot_cell/client/analyzers/image"
5
+
6
+ module ActiveStorage
7
+ module HotCell
8
+ module Client
9
+ module Analyzers
10
+ class Image
11
+ # What an application on ImageMagick lists in `config.active_storage.analyzers`.
12
+ #
13
+ # config.active_storage.analyzers = [ ActiveStorage::HotCell::Client::Analyzers::Image::Magick, ... ]
14
+ #
15
+ # It shares the base's width-and-height slice and its permanent-versus-transient split, differing
16
+ # only in the client it reaches.
17
+ class Magick < Image
18
+ self.client = Operations::Analyzers::Image::Magick
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_storage/hot_cell/client/operations"
4
+ require "active_storage/hot_cell/client/analyzers/image"
5
+
6
+ module ActiveStorage
7
+ module HotCell
8
+ module Client
9
+ module Analyzers
10
+ class Image
11
+ # What an application lists in `config.active_storage.analyzers`.
12
+ #
13
+ # config.active_storage.analyzers = [ ActiveStorage::HotCell::Client::Analyzers::Image::Vips, ... ]
14
+ #
15
+ # The library itself is in the cell, so what this names is which operation to ask.
16
+ class Vips < Image
17
+ self.client = Operations::Analyzers::Image::Vips
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_storage"
4
+ require "active_storage/analyzer"
5
+
6
+ require "active_storage/hot_cell/client/analyzers/analyzing"
7
+
8
+ module ActiveStorage
9
+ module HotCell
10
+ module Client
11
+ module Analyzers
12
+ # Shipping an analyzer 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 all of them decline,
14
+ # `analyzer_class` falls through to NullAnalyzer, and the blob is marked analyzed with no dimensions at
15
+ # all. rails/rails#58384 leaves that to us deliberately.
16
+ #
17
+ # This holds the Rails-facing contract — which blobs are accepted, which keys are written. A leaf names
18
+ # the client that reaches its toolchain and differs in nothing else.
19
+ class Image < ActiveStorage::Analyzer
20
+ include Analyzing
21
+
22
+ # Width and height, which is exactly what the built-in analyzer returns. The cell knows more — page
23
+ # count, whether the image is animated — and surfacing it would change the shape of every blob's
24
+ # metadata.
25
+ KEYS = %i[ width height ].freeze
26
+
27
+ def self.accept?(blob)
28
+ blob.image?
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_storage/hot_cell/client/tool_arguments"
4
+
5
+ module ActiveStorage
6
+ module HotCell
7
+ module Client
8
+ module Analyzers
9
+ # What the two ffprobe analyzers add to the shared round trip: `config.active_storage.ffprobe_arguments`,
10
+ # which Rails' own video and audio analyzers splice before the input path, carried to the cell so it
11
+ # can do the same. Split here, the way Rails splits it, and omitted entirely when it is empty.
12
+ module Probing
13
+ private
14
+ def payload
15
+ ToolArguments.payload(:probe_arguments, ActiveStorage.ffprobe_arguments)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_storage/hot_cell/client/operations"
4
+ require "active_storage/hot_cell/client/analyzers/video"
5
+ require "active_storage/hot_cell/client/analyzers/probing"
6
+
7
+ module ActiveStorage
8
+ module HotCell
9
+ module Client
10
+ module Analyzers
11
+ class Video
12
+ # What an application lists in `config.active_storage.analyzers`.
13
+ #
14
+ # config.active_storage.analyzers = [ ActiveStorage::HotCell::Client::Analyzers::Video::Ffprobe, ... ]
15
+ #
16
+ # One probe operation serves this and the audio analyzer: ffprobe reports both stream kinds in one
17
+ # pass, and each analyzer slices the shared result to its own keys.
18
+ class Ffprobe < Video
19
+ include Probing
20
+
21
+ self.client = Operations::Analyzers::Media::Ffprobe
22
+ end
23
+
24
+ FFprobe = Ffprobe
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_storage"
4
+ require "active_storage/analyzer"
5
+
6
+ require "active_storage/hot_cell/client/analyzers/analyzing"
7
+
8
+ module ActiveStorage
9
+ module HotCell
10
+ module Client
11
+ module Analyzers
12
+ # Rails' VideoAnalyzer shells out to ffprobe inside the application process. Moving ffprobe into a
13
+ # cell means this has to move with it, or an application that adopts hotcell still analyzes media in
14
+ # its own process and cannot take ffprobe out of its image — the incomplete move for exactly the
15
+ # media type a cell exists to isolate.
16
+ #
17
+ # Rails' exact video keys, sliced from the cell's superset. The cell already applied the rotation
18
+ # swap and the anamorphic correction.
19
+ class Video < ActiveStorage::Analyzer
20
+ include Analyzing
21
+
22
+ KEYS = %i[ width height duration angle display_aspect_ratio audio video ].freeze
23
+
24
+ def self.accept?(blob)
25
+ blob.video?
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hot_cell/client"
4
+
5
+ module ActiveStorage
6
+ module HotCell
7
+ module Client
8
+ # The cell these all talk to by default. An application registers it under this name.
9
+ CELL = "active_storage"
10
+
11
+ # One client class per operation, named role, then subject, then tool — so lexical order groups
12
+ # siblings — and the wire name is the snake-cased class path.
13
+ #
14
+ # They are separate classes so that they need not stay together. A cell carries one toolchain by design, so
15
+ # an application that wants video previews in a cell with ffmpeg and variants in a cell with only libvips
16
+ # says so here and changes nothing else:
17
+ #
18
+ # ActiveStorage::HotCell::Client::Operations::Previewers::Video::Ffmpeg.hotcell "video"
19
+ #
20
+ # Routing is a class-level declaration rather than a call-site argument, so call sites carry no deployment
21
+ # detail at all.
22
+ module Operations
23
+ module Transformers
24
+ module Image
25
+ class Vips < ::HotCell::Client
26
+ hotcell CELL
27
+ operation "active_storage.transformers.image.vips"
28
+ end
29
+
30
+ class Magick < ::HotCell::Client
31
+ hotcell CELL
32
+ operation "active_storage.transformers.image.magick"
33
+ end
34
+ end
35
+ end
36
+
37
+ module Analyzers
38
+ module Image
39
+ class Vips < ::HotCell::Client
40
+ hotcell CELL
41
+ operation "active_storage.analyzers.image.vips"
42
+ end
43
+
44
+ class Magick < ::HotCell::Client
45
+ hotcell CELL
46
+ operation "active_storage.analyzers.image.magick"
47
+ end
48
+ end
49
+
50
+ # One probe serves both media analyzers: ffprobe reports video and audio streams in one pass, and
51
+ # the video and audio analyzers each slice the shared result to their own keys.
52
+ module Media
53
+ class Ffprobe < ::HotCell::Client
54
+ hotcell CELL
55
+ operation "active_storage.analyzers.media.ffprobe"
56
+ end
57
+
58
+ FFprobe = Ffprobe
59
+ end
60
+ end
61
+
62
+ module Previewers
63
+ module Pdf
64
+ class Mutool < ::HotCell::Client
65
+ hotcell CELL
66
+ operation "active_storage.previewers.pdf.mutool"
67
+ end
68
+
69
+ class Poppler < ::HotCell::Client
70
+ hotcell CELL
71
+ operation "active_storage.previewers.pdf.poppler"
72
+ end
73
+ end
74
+
75
+ PDF = Pdf
76
+
77
+ module Video
78
+ class Ffmpeg < ::HotCell::Client
79
+ hotcell CELL
80
+ operation "active_storage.previewers.video.ffmpeg"
81
+ end
82
+
83
+ FFmpeg = Ffmpeg
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_storage/previewer/mupdf_previewer"
4
+
5
+ require "active_storage/hot_cell/client/previewers/previewing"
6
+ require "active_storage/hot_cell/client/previewers/pdf"
7
+
8
+ module ActiveStorage
9
+ module HotCell
10
+ module Client
11
+ module Previewers
12
+ module Pdf
13
+ # What Rails configures in `config.active_storage.previewers`, replacing MuPDFPreviewer.
14
+ class Mutool < ActiveStorage::Previewer::MuPDFPreviewer
15
+ include Previewing
16
+
17
+ self.client = Operations::Previewers::Pdf::Mutool
18
+
19
+ # Delegates to the superclass's content-type predicate rather than restating the list, so the
20
+ # accepted set cannot drift away from the one Rails ships.
21
+ def self.accept?(blob)
22
+ pdf? blob.content_type
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_storage/previewer/poppler_pdf_previewer"
4
+
5
+ require "active_storage/hot_cell/client/previewers/previewing"
6
+ require "active_storage/hot_cell/client/previewers/pdf"
7
+
8
+ module ActiveStorage
9
+ module HotCell
10
+ module Client
11
+ module Previewers
12
+ module Pdf
13
+ # The Poppler sibling of Mutool, for an application whose image carries pdftoppm rather than
14
+ # mutool — the previewer Rails' default chain reaches first.
15
+ class Poppler < ActiveStorage::Previewer::PopplerPDFPreviewer
16
+ include Previewing
17
+
18
+ self.client = Operations::Previewers::Pdf::Poppler
19
+
20
+ def self.accept?(blob)
21
+ pdf? blob.content_type
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module HotCell
5
+ module Client
6
+ module Previewers
7
+ # The two PDF previewers cannot share a base here: each subclasses the Rails previewer it replaces to
8
+ # keep its content-type predicate, and those are different classes. The namespace is the shared home.
9
+ module Pdf
10
+ end
11
+
12
+ PDF = Pdf
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require "active_storage"
5
+ require "active_support/core_ext/class/attribute"
6
+
7
+ require "active_storage/hot_cell/client/operations"
8
+
9
+ module ActiveStorage
10
+ module HotCell
11
+ module Client
12
+ module Previewers
13
+ # The preview flow every previewer here shares. A previewer names the client that renders for it and
14
+ # differs in nothing else.
15
+ #
16
+ # **`accept?` must not probe for a binary, and that is the reason these previewers exist as much as the
17
+ # sandboxing is.** `MuPDFPreviewer.accept?` calls `mutool_exists?` and `VideoPreviewer.accept?` calls
18
+ # `ffmpeg_exists?`, both shelling out with `system` from inside a web request. The moment those binaries
19
+ # leave the application image — which is the point of moving the work into a cell — both answer false,
20
+ # `previewable?` goes false with them, and previews stop existing. No exception, no alert, nothing in a
21
+ # log.
22
+ #
23
+ # The sequencing matters as much as the override: these ship and are verified *before* the binary leaves
24
+ # the application image, because the window between those two events fails silently.
25
+ module Previewing
26
+ def self.included(previewer)
27
+ previewer.class_attribute :client, instance_accessor: false
28
+ end
29
+
30
+ def preview(**options)
31
+ download_blob_to_tempfile do |input|
32
+ render_through(input) do |attachable|
33
+ yield(**attachable, **options)
34
+ end
35
+ end
36
+ end
37
+
38
+ private
39
+ # The filename extension and content type come from the cell's own result rather than being
40
+ # restated here, so the operation is the one source of truth for what it produced. The scratch
41
+ # tempfile's name never reaches Rails — only the yielded filename does.
42
+ #
43
+ # **Accepted risk.** A compromised cell chooses both of these values. It does not need to be
44
+ # believed for them to matter, and validating them here buys nothing, because the cell wrote the
45
+ # preview's bytes into this descriptor as its whole job. Active Storage re-identifies the
46
+ # attachment from those bytes and Marcel treats the declared type only as a hint, so bytes that
47
+ # carry script are identified as what they are and served under Rails' own
48
+ # `content_types_allowed_inline` and `content_types_to_serve_as_binary` rules. Lying about the
49
+ # label adds nothing to controlling the bytes. What is left is a preview that renders wrong,
50
+ # which is one of many things a compromised cell can do. The premise is that the bytes are
51
+ # already the cell's to choose; a caller that stops handing the cell an output descriptor would
52
+ # be the thing that changes it.
53
+ def render_through(input)
54
+ Tempfile.create("hotcell-preview", binmode: true) do |output|
55
+ result = File.open(input.path, "rb") do |readable|
56
+ File.open(output.path, "wb") do |writable|
57
+ self.class.client.perform_in_hotcell [ readable ], [ writable ], payload
58
+ end
59
+ end
60
+
61
+ File.open(output.path, "rb") do |io|
62
+ yield io: io, filename: "#{blob.filename.base}.#{result[:format]}",
63
+ content_type: result[:content_type]
64
+ end
65
+ end
66
+ end
67
+
68
+ # What travels with the request beyond the two descriptors. Nothing, for a previewer that has no
69
+ # configuration to carry; the video previewer overrides it with the application's input arguments.
70
+ def payload
71
+ {}
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_storage/previewer/video_previewer"
4
+
5
+ require "active_storage/hot_cell/client/previewers/previewing"
6
+ require "active_storage/hot_cell/client/tool_arguments"
7
+
8
+ module ActiveStorage
9
+ module HotCell
10
+ module Client
11
+ module Previewers
12
+ module Video
13
+ # What Rails configures in `config.active_storage.previewers`, replacing VideoPreviewer.
14
+ class Ffmpeg < ActiveStorage::Previewer::VideoPreviewer
15
+ include Previewing
16
+
17
+ self.client = Operations::Previewers::Video::Ffmpeg
18
+
19
+ # blob.video? is the content-type predicate the superclass uses; ffmpeg_exists? is the part that
20
+ # has to go.
21
+ def self.accept?(blob)
22
+ blob.video?
23
+ end
24
+
25
+ private
26
+ # `config.active_storage.video_preview_input_arguments`, which Rails splices before `-i`, carried
27
+ # to the cell so it can do the same. Split here, the way Rails splits it, and omitted when empty.
28
+ def payload
29
+ ToolArguments.payload(:input_arguments, ActiveStorage.video_preview_input_arguments)
30
+ end
31
+ end
32
+
33
+ FFmpeg = Ffmpeg
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module ActiveStorage
6
+ module HotCell
7
+ module Client
8
+ # An application installs the transformer, the analyzer and the previewers itself, because Active Storage
9
+ # reads those from config and there is no way for a gem to set them that the engine does not later
10
+ # overwrite. Everything else this gem needs is done here.
11
+ class Railtie < ::Rails::Railtie
12
+ # to_prepare rather than after_initialize, because it runs again on every code reload and the job
13
+ # classes do not survive one. See retry_transient_failures!.
14
+ initializer "active_storage.hot_cell.client.retry_transient_failures" do |app|
15
+ app.config.to_prepare do
16
+ ActiveStorage::HotCell::Client.retry_transient_failures!
17
+ end
18
+ end
19
+
20
+ # The two settings rails/rails#58461 adds, copied from `config.active_storage.*` the way Rails' engine
21
+ # copies every other Active Storage setting — in after_initialize, once the environment file has run.
22
+ # On a Rails that already has them, its engine did this already and the same value lands twice.
23
+ #
24
+ # The engine creates `config.active_storage`; an application that has not loaded it has no such
25
+ # namespace, and then there is nothing to copy.
26
+ initializer "active_storage.hot_cell.client.tool_arguments" do
27
+ config.after_initialize do |app|
28
+ next unless app.config.respond_to?(:active_storage)
29
+
30
+ ActiveStorage.ffprobe_arguments = app.config.active_storage.ffprobe_arguments || ""
31
+ ActiveStorage.video_preview_input_arguments = app.config.active_storage.video_preview_input_arguments || ""
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "active_storage"
5
+ require "active_support/core_ext/module/attribute_accessors"
6
+
7
+ module ActiveStorage
8
+ # The two settings rails/rails#58461 adds, defined here when the installed Active Storage predates them so
9
+ # that an application configures the same thing either way. Rails' own definition takes precedence when it
10
+ # exists — a second mattr_accessor would reset the value the engine already copied from config.
11
+ #
12
+ # Both are shell strings, the shape Rails chose for `video_preview_arguments`, and default to nothing.
13
+ mattr_accessor :ffprobe_arguments, default: "" unless respond_to?(:ffprobe_arguments)
14
+ mattr_accessor :video_preview_input_arguments, default: "" unless respond_to?(:video_preview_input_arguments)
15
+
16
+ module HotCell
17
+ module Client
18
+ # Turns one of those shell strings into the argv the cell splices in, split the way Rails splits it —
19
+ # here, in the application, so a malformed string raises against the configuration rather than arriving
20
+ # in the cell as a failed conversion that reads like the document's fault.
21
+ #
22
+ # An empty setting yields nothing at all, so the request carries no key and the operation runs its
23
+ # default argv unchanged.
24
+ module ToolArguments
25
+ def self.split(setting)
26
+ Shellwords.split(setting.to_s)
27
+ end
28
+
29
+ def self.payload(key, setting)
30
+ arguments = split(setting)
31
+ arguments.empty? ? {} : { key => arguments }
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_storage"
4
+ require "active_storage/transformers/image_magick"
5
+
6
+ require "active_storage/hot_cell/client/operations"
7
+ require "active_storage/hot_cell/client/transformers/transforming"
8
+
9
+ module ActiveStorage
10
+ module HotCell
11
+ module Client
12
+ module Transformers
13
+ module Image
14
+ # What Rails configures as `config.active_storage.variant_processor` for an application on ImageMagick.
15
+ #
16
+ # config.active_storage.variant_processor = ActiveStorage::HotCell::Client::Transformers::Image::Magick
17
+ #
18
+ # It subclasses Rails' own ImageMagick transformer to keep its transformation allowlist —
19
+ # `supported_image_processing_methods` and the argument blocklist — which runs here, in the
20
+ # application, exactly where Rails runs it today. Only the final step changes: rather than shelling out
21
+ # to `magick` locally, the validated transformations cross to the cell.
22
+ class Magick < ActiveStorage::Transformers::ImageMagick
23
+ include Transforming
24
+
25
+ self.client = Operations::Transformers::Image::Magick
26
+
27
+ private
28
+ # `operations` is Rails' validation: it raises UnsupportedImageProcessingMethod or
29
+ # UnsupportedImageProcessingArgument for anything outside the allowlist. Called for that effect,
30
+ # then the transformations travel to the cell as the application wrote them.
31
+ def process(file, format:)
32
+ operations
33
+
34
+ super
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_storage"
4
+ require "active_storage/transformers/transformer"
5
+
6
+ require "active_storage/hot_cell/client/operations"
7
+ require "active_storage/hot_cell/client/transformers/transforming"
8
+
9
+ module ActiveStorage
10
+ module HotCell
11
+ module Client
12
+ module Transformers
13
+ module Image
14
+ # What Rails configures as `config.active_storage.variant_processor`.
15
+ #
16
+ # config.active_storage.variant_processor = ActiveStorage::HotCell::Client::Transformers::Image::Vips
17
+ #
18
+ # Note what adopting this does not achieve, because it is easy to assume otherwise: libvips is still loaded
19
+ # into the application process, by `require "active_storage/engine"`, before any configuration is read. The
20
+ # engine builds its default analyzers array by referencing ActiveStorage::Analyzer::ImageAnalyzer::Vips,
21
+ # which requires ruby-vips, which dlopens the library. No `variant_processor` value changes that. Getting
22
+ # the library out of the application means removing ruby-vips from the bundle, which then breaks that
23
+ # default array — an application's call, and not one configuration alone can make.
24
+ #
25
+ # What this does achieve is that no untrusted byte is decoded there.
26
+ class Vips < ActiveStorage::Transformers::Transformer
27
+ include Transforming
28
+
29
+ self.client = Operations::Transformers::Image::Vips
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require "active_storage"
5
+ require "active_support/core_ext/class/attribute"
6
+ require "active_support/core_ext/hash/deep_transform_values"
7
+
8
+ require "active_storage/hot_cell/client/operations"
9
+
10
+ module ActiveStorage
11
+ module HotCell
12
+ module Client
13
+ module Transformers
14
+ # The conversion both transformers share. A transformer names the client that carries its
15
+ # transformations to the cell, and differs in nothing else.
16
+ module Transforming
17
+ def self.included(transformer)
18
+ transformer.class_attribute :client, instance_accessor: false
19
+ end
20
+
21
+ private
22
+ # Returns an open, rewound Tempfile, which is the contract.
23
+ #
24
+ # The transformations travel to the cell almost as they arrived. Rails hands this a symbol-keyed hash
25
+ # with :format already removed — Variation deep-symbolizes on the way in and builds the transformer
26
+ # with `transformations.except(:format)` — so the keys need nothing, and deciding which keys are
27
+ # allowed belongs to the operation rather than to this side of the socket. Values are another matter:
28
+ # Variation symbolizes only keys, so `crop: :attention` reaches here as the application wrote it, and
29
+ # stock Rails accepts it. A Symbol value cannot ride JSON, which is this gem's transport detail
30
+ # rather than the application's problem — so Symbol values go over as the Strings the toolchain
31
+ # would have been handed anyway.
32
+ def process(file, format:)
33
+ output = Tempfile.new([ "hotcell", ".#{format}" ], binmode: true)
34
+
35
+ begin
36
+ convert file, output, format: format.to_s, operations: stringified(transformations)
37
+ output.tap(&:rewind)
38
+ rescue StandardError
39
+ output.close!
40
+ raise
41
+ end
42
+ end
43
+
44
+ # Both handles are reopened by path, narrowly, and that is the protocol rather than fussiness. Rails
45
+ # hands out Tempfiles, which are read-write; an input descriptor must be read-only and an output
46
+ # write-only. An access mode is fixed at open, so a cell handed the wrong one cannot narrow it — it can
47
+ # only decline the request.
48
+ def convert(file, output, **payload)
49
+ File.open(file.path, "rb") do |readable|
50
+ File.open(output.path, "wb") do |writable|
51
+ self.class.client.perform_in_hotcell [ readable ], [ writable ], payload
52
+ end
53
+ end
54
+ end
55
+
56
+ # Only Symbol values, and only to String. Anything else unserializable is refused by the payload
57
+ # check as ever, because for everything but a Symbol there is no value Rails would have accepted
58
+ # in its place.
59
+ def stringified(transformations)
60
+ transformations.deep_transform_values { |value| value.is_a?(Symbol) ? value.to_s : value }
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module HotCell
5
+ module Client
6
+ VERSION = "0.2.0"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_storage"
4
+ require "active_support/core_ext/string/inflections"
5
+
6
+ require "active_storage/hot_cell/client/version"
7
+ require "active_storage/hot_cell/client/operations"
8
+ require "active_storage/hot_cell/client/transformers/image/vips"
9
+ require "active_storage/hot_cell/client/transformers/image/magick"
10
+ require "active_storage/hot_cell/client/analyzers/image/vips"
11
+ require "active_storage/hot_cell/client/analyzers/image/magick"
12
+ require "active_storage/hot_cell/client/analyzers/video/ffprobe"
13
+ require "active_storage/hot_cell/client/analyzers/audio/ffprobe"
14
+ require "active_storage/hot_cell/client/previewers/pdf/mutool"
15
+ require "active_storage/hot_cell/client/previewers/pdf/poppler"
16
+ require "active_storage/hot_cell/client/previewers/video/ffmpeg"
17
+ require "active_storage/hot_cell/client/railtie" if defined?(::Rails::Railtie)
18
+
19
+ module ActiveStorage
20
+ module HotCell
21
+ # Everything the application side defines lives under this, and everything the cell side defines lives under
22
+ # ActiveStorage::HotCell::Server. Not a tidying convention: the two gems are never both loaded in production,
23
+ # and a cell is forked from a process that may well have loaded this one — after which a shared name is a
24
+ # superclass mismatch while the cell boots. Two namespaces make that impossible rather than avoided.
25
+ module Client
26
+ # These four jobs declare `retry_on ActiveStorage::IntegrityError` and nothing else, and ActiveJob does
27
+ # not retry by default. They should retry the transient class too: `capacity` most obviously, and every
28
+ # other transient verdict. The policy matches the one they already declare, so a cell failure and an
29
+ # integrity failure back off the same way.
30
+ #
31
+ # Which of these classes exists depends on the Rails version, so a name that is not loaded is skipped.
32
+ JOBS = %w[
33
+ ActiveStorage::AnalyzeJob
34
+ ActiveStorage::CreateVariantsJob
35
+ ActiveStorage::PreviewImageJob
36
+ ActiveStorage::TransformJob
37
+ ].freeze
38
+
39
+ RETRY = { wait: :polynomially_longer, attempts: 10 }.freeze
40
+
41
+ # The clients this gem ships, which is where the retry hook learns which cells' transient classes
42
+ # the jobs must retry. Deliberately not HotCell.clients: that records every client the process loaded,
43
+ # including an application's own for unrelated cells, and Active Storage's jobs have no business
44
+ # retrying those.
45
+ CLIENTS = [ Operations::Transformers::Image::Vips, Operations::Transformers::Image::Magick,
46
+ Operations::Analyzers::Image::Vips, Operations::Analyzers::Image::Magick,
47
+ Operations::Analyzers::Media::Ffprobe,
48
+ Operations::Previewers::Pdf::Mutool, Operations::Previewers::Pdf::Poppler,
49
+ Operations::Previewers::Video::Ffmpeg ].freeze
50
+
51
+ class << self
52
+ # The railtie calls this from a to_prepare block. Applied once at boot it would not survive a code
53
+ # reload: a gem engine's app/jobs is in the reloadable autoloader, so these classes are discarded and
54
+ # redefined, and the retry would silently disappear after the first file save in development.
55
+ #
56
+ # Every registered cell contributes its transient class, because the documented multi-cell
57
+ # arrangement routes PreviewVideo to a cell of its own and each cell names its own class. A cell not
58
+ # yet registered contributes nothing rather than raising: this runs on the boot where the application
59
+ # has bundled the gem and not yet written the initializer, and that boot has to succeed for the
60
+ # rollout to take two deploys rather than one.
61
+ def retry_transient_failures!(jobs: JOBS)
62
+ transients = CLIENTS.filter_map { |client| client.cell.transient if client.registered? }
63
+ .uniq
64
+ return [] if transients.empty?
65
+
66
+ jobs.filter_map(&:safe_constantize)
67
+ .each { |job| job.retry_on(*transients, **RETRY) }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler auto-requires this gem as "activestorage-hotcell-client", then as "activestorage/hotcell/client".
4
+ # It uses neither path. Without this file, naming it in a Gemfile silently loads nothing.
5
+ require "active_storage/hot_cell/client"
metadata CHANGED
@@ -1,24 +1,86 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activestorage-hotcell-client
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-client
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: activestorage
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 8.2.0.alpha
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 8.2.0.alpha
40
+ description: |
41
+ Drop-in replacements for Active Storage's variant processor, analyzers, and previewers. Configure
42
+ Rails with these classes and variants, image and media analysis, and previews run in a HotCell
43
+ container instead of in the application process.
13
44
  email:
14
45
  - mike@37signals.com
15
46
  executables: []
16
47
  extensions: []
17
48
  extra_rdoc_files: []
18
- files: []
49
+ files:
50
+ - MIT-LICENSE
51
+ - README.md
52
+ - lib/active_storage/hot_cell/client.rb
53
+ - lib/active_storage/hot_cell/client/analyzers/analyzing.rb
54
+ - lib/active_storage/hot_cell/client/analyzers/audio.rb
55
+ - lib/active_storage/hot_cell/client/analyzers/audio/ffprobe.rb
56
+ - lib/active_storage/hot_cell/client/analyzers/image.rb
57
+ - lib/active_storage/hot_cell/client/analyzers/image/magick.rb
58
+ - lib/active_storage/hot_cell/client/analyzers/image/vips.rb
59
+ - lib/active_storage/hot_cell/client/analyzers/probing.rb
60
+ - lib/active_storage/hot_cell/client/analyzers/video.rb
61
+ - lib/active_storage/hot_cell/client/analyzers/video/ffprobe.rb
62
+ - lib/active_storage/hot_cell/client/operations.rb
63
+ - lib/active_storage/hot_cell/client/previewers/pdf.rb
64
+ - lib/active_storage/hot_cell/client/previewers/pdf/mutool.rb
65
+ - lib/active_storage/hot_cell/client/previewers/pdf/poppler.rb
66
+ - lib/active_storage/hot_cell/client/previewers/previewing.rb
67
+ - lib/active_storage/hot_cell/client/previewers/video/ffmpeg.rb
68
+ - lib/active_storage/hot_cell/client/railtie.rb
69
+ - lib/active_storage/hot_cell/client/tool_arguments.rb
70
+ - lib/active_storage/hot_cell/client/transformers/image/magick.rb
71
+ - lib/active_storage/hot_cell/client/transformers/image/vips.rb
72
+ - lib/active_storage/hot_cell/client/transformers/transforming.rb
73
+ - lib/active_storage/hot_cell/client/version.rb
74
+ - lib/activestorage-hotcell-client.rb
75
+ homepage: https://github.com/basecamp/hotcell
19
76
  licenses:
20
77
  - MIT
21
- metadata: {}
78
+ metadata:
79
+ homepage_uri: https://github.com/basecamp/hotcell
80
+ source_code_uri: https://github.com/basecamp/hotcell/tree/v0.2.0/activestorage-hotcell-client
81
+ changelog_uri: https://github.com/basecamp/hotcell/blob/v0.2.0/CHANGELOG.md
82
+ bug_tracker_uri: https://github.com/basecamp/hotcell/issues
83
+ rubygems_mfa_required: 'true'
22
84
  rdoc_options: []
23
85
  require_paths:
24
86
  - lib
@@ -26,7 +88,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
26
88
  requirements:
27
89
  - - ">="
28
90
  - !ruby/object:Gem::Version
29
- version: '0'
91
+ version: '3.3'
30
92
  required_rubygems_version: !ruby/object:Gem::Requirement
31
93
  requirements:
32
94
  - - ">="
@@ -35,5 +97,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
35
97
  requirements: []
36
98
  rubygems_version: 4.0.16
37
99
  specification_version: 4
38
- summary: To be released soon, secure sidecar for Rails with Active Storage extensions
100
+ summary: Point Active Storage's transformer, analyzer, and previewers at a HotCell.
39
101
  test_files: []