From 902ebccf5005e0a317b0cc507da36f7db5add0ba Mon Sep 17 00:00:00 2001 From: tim-kos Date: Tue, 13 Oct 2026 09:23:39 +0200 Subject: [PATCH 1/3] Sign Assembly cancellations with the originating client --- README.md | 6 ++ lib/transloadit/assembly.rb | 14 ++-- lib/transloadit/response/assembly.rb | 12 +++- test/unit/transloadit/test_assembly.rb | 88 ++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 85d8118..35f4c4f 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,12 @@ response.completed? # => true response.error? # => false ``` +Assembly responses returned by `create!`, `get`, and `replay` retain their client +for cancellation. `cancel!` includes authentication and signs the DELETE request +when that client has an Auth Secret, so it works with Workspaces that require +correct signatures. Responses constructed directly without a client retain +the existing unsigned cancellation behavior. + It's important to note that none of these queries are "live" (with the exception of the `cancel!` method). They all check the response given by the API at the time the Assembly was created. You have to explicitly ask the diff --git a/lib/transloadit/assembly.rb b/lib/transloadit/assembly.rb index c6c0899..65858e7 100644 --- a/lib/transloadit/assembly.rb +++ b/lib/transloadit/assembly.rb @@ -56,9 +56,9 @@ def create!(*ios, **params) # update the payload with file entries ios.each_with_index { |f, i| extra_params.update "file_#{i}": f } - response = _do_request( + response = _assembly_response(_do_request( "/assemblies", params, "post", extra_params - ).extend!(Transloadit::Response::Assembly) + )) return response unless response.rate_limit? @@ -89,7 +89,7 @@ def list(params = {}) # @param [String] id id of the desired assembly # def get(id) - _do_request("/assemblies/#{id}").extend!(Transloadit::Response::Assembly) + _assembly_response(_do_request("/assemblies/#{id}")) end # @@ -99,7 +99,7 @@ def get(id) # def replay(id, params = {}) params[:wait] = false - _do_request("/assemblies/#{id}/replay", params, "post").extend!(Transloadit::Response::Assembly) + _assembly_response(_do_request("/assemblies/#{id}/replay", params, "post")) end # @@ -131,6 +131,12 @@ def to_hash private + def _assembly_response(response) + response.extend!(Transloadit::Response::Assembly) + response.transloadit = transloadit + response + end + # # Returns a Transloadit-compatible Hash wrapping the +steps+ passed to it. # Accepts any supported format the +steps+ could come in. diff --git a/lib/transloadit/response/assembly.rb b/lib/transloadit/response/assembly.rb index 6e6d998..dd59d34 100644 --- a/lib/transloadit/response/assembly.rb +++ b/lib/transloadit/response/assembly.rb @@ -1,12 +1,22 @@ require "transloadit" +require "securerandom" module Transloadit::Response::Assembly + # @return [Transloadit, nil] the client used to authenticate Assembly actions + attr_accessor :transloadit + def reload! replace Transloadit::Request.new(self["assembly_ssl_url"]).get end def cancel! - replace Transloadit::Request.new(self["assembly_ssl_url"]).delete + return replace Transloadit::Request.new(self["assembly_ssl_url"]).delete unless transloadit + + auth = transloadit.to_hash + # Separate cancellation attempts must not produce the same signed payload. + auth[:nonce] = SecureRandom.uuid unless transloadit.secret.to_s.empty? + replace Transloadit::Request.new(self["assembly_ssl_url"], transloadit.secret) + .delete(params: {auth: auth}) end def aborted? diff --git a/test/unit/transloadit/test_assembly.rb b/test/unit/transloadit/test_assembly.rb index 84f743f..4fc95ae 100644 --- a/test/unit/transloadit/test_assembly.rb +++ b/test/unit/transloadit/test_assembly.rb @@ -302,6 +302,94 @@ @assembly = Transloadit::Assembly.new @transloadit end + after do + WebMock.reset! + end + + it "must sign cancellations of created assemblies with the originating client" do + client = Transloadit.new(key: "workspace-key", secret: "workspace-secret") + endpoint = "https://api2-jane.transloadit.com/assemblies/assembly-id" + status = MultiJson.dump(ok: "ASSEMBLY_EXECUTING", assembly_ssl_url: endpoint) + stub_request(:post, "https://api2.transloadit.com/assemblies").to_return(body: status) + stub_request(:delete, endpoint).to_return( + body: MultiJson.dump(ok: "ASSEMBLY_CANCELED", assembly_ssl_url: endpoint) + ) + + response = client.assembly.create! + _(response.transloadit).must_be_same_as client + _(response.cancel!).must_be_same_as response + _(response.canceled?).must_equal true + + assert_requested(:delete, endpoint) do |request| + fields = URI.decode_www_form(request.body).to_h + params = MultiJson.load(fields.fetch("params")) + _(params.dig("auth", "key")).must_equal "workspace-key" + _(params.dig("auth", "expires")).wont_be_nil + _(params.dig("auth", "nonce")).must_match(/\A[0-9a-f-]{36}\z/) + expected = "sha384:" + OpenSSL::HMAC.hexdigest("SHA384", client.secret, fields.fetch("params")) + _(fields.fetch("signature")).must_equal expected + _(request.body).wont_include client.secret + end + end + + it "must keep cancellation authentication after reloading a fetched assembly" do + client = Transloadit.new(key: "workspace-key", secret: "workspace-secret") + endpoint = "https://api2-jane.transloadit.com/assemblies/assembly-id" + status = MultiJson.dump(ok: "ASSEMBLY_EXECUTING", assembly_ssl_url: endpoint) + stub_request(:get, "https://api2.transloadit.com/assemblies/assembly-id").to_return(body: status) + stub_request(:get, endpoint).to_return(body: status) + stub_request(:delete, endpoint).to_return(body: '{"ok":"ASSEMBLY_CANCELED"}') + + response = client.assembly.get("assembly-id") + response.reload! + _(response.transloadit).must_be_same_as client + response.cancel! + + assert_requested(:delete, endpoint) do |request| + fields = URI.decode_www_form(request.body).to_h + _(fields.fetch("signature")).must_equal( + "sha384:" + OpenSSL::HMAC.hexdigest("SHA384", client.secret, fields.fetch("params")) + ) + end + end + + it "must authenticate cancellation of replayed assemblies with fresh nonces" do + client = Transloadit.new(key: "workspace-key", secret: "workspace-secret") + endpoint = "https://api2-jane.transloadit.com/assemblies/replayed-id" + status = MultiJson.dump(ok: "ASSEMBLY_REPLAYING", assembly_ssl_url: endpoint) + stub_request(:post, "https://api2.transloadit.com/assemblies/assembly-id/replay") + .to_return(body: status) + nonces = [] + stub_request(:delete, endpoint).to_return do |request| + fields = URI.decode_www_form(request.body).to_h + nonces << MultiJson.load(fields.fetch("params")).dig("auth", "nonce") + {body: MultiJson.dump(ok: "ASSEMBLY_CANCELED", assembly_ssl_url: endpoint)} + end + + response = client.assembly.replay("assembly-id") + response.cancel! + response.cancel! + + _(response.transloadit).must_be_same_as client + _(nonces.length).must_equal 2 + _(nonces.uniq.length).must_equal 2 + end + + it "must include the client key without a signature when no secret was configured" do + endpoint = "https://api2-jane.transloadit.com/assemblies/assembly-id" + status = MultiJson.dump(ok: "ASSEMBLY_EXECUTING", assembly_ssl_url: endpoint) + stub_request(:get, "https://api2.transloadit.com/assemblies/assembly-id").to_return(body: status) + stub_request(:delete, endpoint).to_return(body: '{"ok":"ASSEMBLY_CANCELED"}') + + @assembly.get("assembly-id").cancel! + + assert_requested(:delete, endpoint) do |request| + fields = URI.decode_www_form(request.body).to_h + _(MultiJson.load(fields.fetch("params"))).must_equal "auth" => {"key" => ""} + _(fields).wont_include "signature" + end + end + describe "when fetching all assemblies" do it "must perform GET request to /assemblies" do stub = stub_request(:get, "https://api2.transloadit.com/assemblies?params=%7B%22auth%22:%7B%22key%22:%22%22%7D%7D") From 6c3a291b3a1c9c3238496dd86bead56a3574ed12 Mon Sep 17 00:00:00 2001 From: tim-kos Date: Tue, 13 Oct 2026 09:30:53 +0200 Subject: [PATCH 2/3] Keep JSON compatible with MultiJson on Ruby 3.1 --- transloadit.gemspec | 2 ++ 1 file changed, 2 insertions(+) diff --git a/transloadit.gemspec b/transloadit.gemspec index afcbef7..f66916f 100644 --- a/transloadit.gemspec +++ b/transloadit.gemspec @@ -23,6 +23,8 @@ Gem::Specification.new do |gem| gem.add_dependency "faraday", ">= 2.0", "< 3.0" gem.add_dependency "faraday-multipart", "~> 1.0" + # Ruby 3.1 uses MultiJson 1.19, whose positional parse options are incompatible with JSON 3. + gem.add_dependency "json", "< 3.0" gem.add_dependency "multi_json" gem.add_dependency "mime-types" From 5be4bfc72cfaad4f0710f3b626f91cc8df0ff086 Mon Sep 17 00:00:00 2001 From: tim-kos Date: Tue, 13 Oct 2026 09:34:56 +0200 Subject: [PATCH 3/3] Keep rate-limit warning assertion independent of dependency warnings --- test/unit/transloadit/test_assembly.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/transloadit/test_assembly.rb b/test/unit/transloadit/test_assembly.rb index 4fc95ae..c54f06b 100644 --- a/test/unit/transloadit/test_assembly.rb +++ b/test/unit/transloadit/test_assembly.rb @@ -170,7 +170,7 @@ response = @assembly.create! open("lib/transloadit/version.rb") _(response["ok"]).must_equal "ASSEMBLY_COMPLETED" } - _(warning).must_equal "Rate limit reached. Waiting for 0 seconds before retrying.\n" + _(warning).must_include "Rate limit reached. Waiting for 0 seconds before retrying.\n" end end