apertur-sdk 0.1.5 → 0.1.8

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: 8e13b276ea448173ed53e96ce31df30060b576c768acd1012bc9df6df2030375
4
- data.tar.gz: ed633c8bd5e7509b7ef7f7a98e63fa2b8912048ca141e9230e184cff16eca960
3
+ metadata.gz: 54295e7074aee323e8302c6024d025831ef1f37fd4fba2bd8663585be266759a
4
+ data.tar.gz: 14fc7f33d55226e794a53cb4608b7f4d3be6c2dc2af1ac94ed6cc889cbe8e7bd
5
5
  SHA512:
6
- metadata.gz: 617cd4b37d3da2fffe8292367cbd336d42076fed098eaa72e77360b1440911b73ba22bb664b104bf73c7d8bc41279c2d3656ae113ba7630ca4c598de3c3b44e9
7
- data.tar.gz: 3806c669fb2bcb4eac40c7d4ecebed20a98993a6a19e82d13c2f290277e6b47b4d21122d9f525197cf2b65e17fb1d61bb31487e0fd2b5a066a762fa8bdc2dc8a
6
+ metadata.gz: 9c7b1b326a2dc9adf6ba246af70486777b548c9ad95e357d8c7535d98abd88d82daf49da40a35013628160c58dc6d52ee5f8d889c433c5d8c85144eed31bd472
7
+ data.tar.gz: 25eccfae7934a7178cf95aacaddff4b195f15205e34d1cc0b18b05460754fb417c63bb7b11840d5a1f77a4b165ac67e4fcbbafb6c52c463fc0713e5182fa3a99
data/README.md CHANGED
@@ -52,6 +52,17 @@ You can override the base URL:
52
52
  client = Apertur::Client.new(api_key: "aptr_...", base_url: "http://localhost:3000")
53
53
  ```
54
54
 
55
+ ### Request Signing (optional)
56
+
57
+ Pass `signing_secret:` to enable HMAC request signing. When set, every outgoing
58
+ JSON or no-body request is automatically signed, adding `X-Aptr-Signature` and
59
+ `X-Aptr-Timestamp` headers so the server can verify the request's authenticity.
60
+ Omit it for backwards-compatible, unsigned requests.
61
+
62
+ ```ruby
63
+ client = Apertur::Client.new(api_key: "aptr_...", signing_secret: "your_signing_secret")
64
+ ```
65
+
55
66
  ## Resources
56
67
 
57
68
  ### Sessions
@@ -51,15 +51,19 @@ module Apertur
51
51
  # @param oauth_token [String, nil] an OAuth bearer token (alternative to api_key)
52
52
  # @param base_url [String, nil] override the base URL; auto-detected from the
53
53
  # key prefix when nil
54
+ # @param signing_secret [String, nil] optional request signing secret. When
55
+ # present, every JSON request is automatically signed with
56
+ # +X-Aptr-Signature+ / +X-Aptr-Timestamp+ headers. Backwards-compatible:
57
+ # omit (or pass nil/empty) to send unsigned requests as before.
54
58
  # @raise [ArgumentError] if neither +api_key+ nor +oauth_token+ is provided
55
- def initialize(api_key: nil, oauth_token: nil, base_url: nil)
59
+ def initialize(api_key: nil, oauth_token: nil, base_url: nil, signing_secret: nil)
56
60
  token = api_key || oauth_token
57
61
  raise ArgumentError, "Either api_key or oauth_token must be provided" if token.nil? || token.empty?
58
62
 
59
63
  @env = token.start_with?("aptr_test_") ? "test" : "live"
60
64
 
61
65
  resolved_url = base_url || (@env == "test" ? SANDBOX_BASE_URL : DEFAULT_BASE_URL)
62
- http = HttpClient.new(resolved_url, token)
66
+ http = HttpClient.new(resolved_url, token, signing_secret: signing_secret)
63
67
 
64
68
  @sessions = Resources::Sessions.new(http)
65
69
  @upload = Resources::Upload.new(http)
@@ -4,18 +4,24 @@ require "net/http"
4
4
  require "uri"
5
5
  require "json"
6
6
  require "securerandom"
7
+ require_relative "signature"
7
8
 
8
9
  module Apertur
9
10
  # Low-level HTTP wrapper around Net::HTTP for communicating with the Apertur API.
10
11
  #
11
12
  # Handles JSON serialization, Bearer token authentication, multipart uploads,
12
- # and error mapping.
13
+ # request signing, and error mapping.
13
14
  class HttpClient
14
15
  # @param base_url [String] the API base URL (e.g. "https://api.aptr.ca")
15
16
  # @param token [String] the Bearer token (API key or OAuth token)
16
- def initialize(base_url, token)
17
+ # @param signing_secret [String, nil] optional request signing secret. When
18
+ # present (non-nil, non-empty), every JSON request is automatically
19
+ # signed with +X-Aptr-Signature+ / +X-Aptr-Timestamp+ headers. Absent by
20
+ # default for backwards compatibility.
21
+ def initialize(base_url, token, signing_secret: nil)
17
22
  @base_url = base_url.chomp("/")
18
23
  @token = token
24
+ @signing_secret = signing_secret
19
25
  end
20
26
 
21
27
  # Perform an API request and return the parsed JSON response.
@@ -38,6 +44,11 @@ module Apertur
38
44
  req.body = body.is_a?(String) ? body : JSON.generate(body)
39
45
  end
40
46
 
47
+ # Sign the full request-target (path + query) the server receives as
48
+ # req.url — build_uri may have appended a query string, and the server
49
+ # signs the query too. uri.request_uri is exactly what goes on the wire.
50
+ sign_headers(method, uri.request_uri, req.body).each { |k, v| req[k] = v }
51
+
41
52
  response = execute(uri, req, read_timeout: read_timeout)
42
53
  handle_response(response)
43
54
  end
@@ -53,6 +64,8 @@ module Apertur
53
64
  uri = build_uri(path, query)
54
65
  req = build_request(method, uri)
55
66
 
67
+ sign_headers(method, uri.request_uri, req.body).each { |k, v| req[k] = v }
68
+
56
69
  response = execute(uri, req)
57
70
  handle_error(response) unless response.is_a?(Net::HTTPSuccess)
58
71
  response.body
@@ -68,6 +81,13 @@ module Apertur
68
81
  # @param headers [Hash] additional request headers
69
82
  # @return [Hash, Array, nil] parsed JSON response
70
83
  # @raise [Apertur::Error] on API errors
84
+ #
85
+ # @note Multipart bodies are NOT signed. Net::HTTP streams the boundary
86
+ # and part framing after this point, so the exact bytes on the wire
87
+ # aren't available here to hash — signing an approximation would only
88
+ # produce a signature that fails server-side verification. Multipart
89
+ # uploads rely on Authorization (API key) auth instead; request signing
90
+ # covers the JSON request path only (mirrors the Node SDK).
71
91
  def request_multipart(path, file_data, filename:, mime_type:, fields: {}, headers: {})
72
92
  uri = build_uri(path)
73
93
  boundary = "AperturRubySDK#{SecureRandom.hex(16)}"
@@ -169,6 +189,22 @@ module Apertur
169
189
  end
170
190
  end
171
191
 
192
+ # Compute +X-Aptr-Signature+ / +X-Aptr-Timestamp+ headers for a request, or
193
+ # +{}+ when no signing secret is configured.
194
+ #
195
+ # @param method [Symbol, String] the HTTP method
196
+ # @param path [String] the exact request path (no query string), verbatim
197
+ # @param body [String, nil] the exact bytes/string that will be sent on
198
+ # the wire as the request body (or +nil+ for none) — the server hashes
199
+ # whatever it actually receives, so an approximation here would just
200
+ # produce a signature that fails verification.
201
+ # @return [Hash{String=>String}]
202
+ def sign_headers(method, path, body)
203
+ return {} if @signing_secret.nil? || @signing_secret.empty?
204
+
205
+ Signature.sign_request(@signing_secret, method, path, body, Time.now.to_i)
206
+ end
207
+
172
208
  # @param boundary [String]
173
209
  # @param file_data [String]
174
210
  # @param filename [String]
@@ -35,7 +35,9 @@ module Apertur
35
35
  # @param image_id [String] the image ID
36
36
  # @return [Hash] acknowledgement status
37
37
  def ack(uuid, image_id)
38
- @http.request(:post, "/api/v1/upload-sessions/#{uuid}/images/#{image_id}/ack")
38
+ # The server requires a JSON body on this endpoint; sending none
39
+ # results in a 500. An empty object is sufficient.
40
+ @http.request(:post, "/api/v1/upload-sessions/#{uuid}/images/#{image_id}/ack", body: {})
39
41
  end
40
42
 
41
43
  # Blocking polling loop that fetches, downloads, and acknowledges images.
@@ -59,18 +59,31 @@ module Apertur
59
59
  file_data = read_file(file)
60
60
  encrypted = Apertur::Crypto.encrypt_image(file_data, public_key)
61
61
 
62
- payload = encrypted.merge(
63
- "filename" => filename,
64
- "mimeType" => mime_type,
65
- "source" => source || "sdk"
62
+ # The server's "default" encryption mode expects a multipart file
63
+ # upload whose body is the JSON-serialized EncryptedPayload (camelCase
64
+ # keys), which it then decrypts with its private key. Sending a JSON
65
+ # request body instead yields a 500 ("No file uploaded").
66
+ payload_json = JSON.generate(
67
+ "encryptedKey" => encrypted["encrypted_key"],
68
+ "iv" => encrypted["iv"],
69
+ "encryptedData" => encrypted["encrypted_data"],
70
+ "algorithm" => encrypted["algorithm"]
66
71
  )
67
72
 
68
- headers = {
69
- "X-Aptr-Encrypted" => "default"
70
- }
73
+ fields = {}
74
+ fields["source"] = source if source
75
+
76
+ headers = { "X-Aptr-Encrypted" => "default" }
71
77
  headers["x-session-password"] = password if password
72
78
 
73
- @http.request(:post, "/api/v1/upload/#{uuid}/images", body: payload, headers: headers)
79
+ @http.request_multipart(
80
+ "/api/v1/upload/#{uuid}/images",
81
+ payload_json,
82
+ filename: "#{filename}.enc",
83
+ mime_type: "application/octet-stream",
84
+ fields: fields,
85
+ headers: headers
86
+ )
74
87
  end
75
88
 
76
89
  private
@@ -82,7 +95,7 @@ module Apertur
82
95
  def read_file(file)
83
96
  if file.respond_to?(:read)
84
97
  file.read.b
85
- elsif file.is_a?(String) && File.exist?(file) && file.length < 1024
98
+ elsif file.is_a?(String) && looks_like_path?(file) && File.exist?(file)
86
99
  # Treat short strings that point to existing files as paths.
87
100
  # Raw image bytes will almost never be < 1024 bytes AND match an
88
101
  # existing filename, so this heuristic is safe in practice.
@@ -93,6 +106,17 @@ module Apertur
93
106
  raise ArgumentError, "Unsupported file input. Use a file path String, IO object, or raw String bytes."
94
107
  end
95
108
  end
109
+
110
+ # Heuristic: does this String plausibly name a file path (as opposed to
111
+ # raw image bytes)? Must be short and contain no NUL byte. Calling
112
+ # File.exist? on binary image data that contains a NUL byte raises
113
+ # "ArgumentError: path name contains null byte", so we must guard it.
114
+ #
115
+ # @param str [String]
116
+ # @return [Boolean]
117
+ def looks_like_path?(str)
118
+ str.length < 1024 && !str.include?("\x00")
119
+ end
96
120
  end
97
121
  end
98
122
  end
@@ -63,6 +63,31 @@ module Apertur
63
63
  secure_compare(expected_b64, sig)
64
64
  end
65
65
 
66
+ # Sign an outgoing API request (HMAC SHA256 method).
67
+ #
68
+ # The signed payload is
69
+ # +"\#{timestamp}.\#{method.upcase}.\#{path}.\#{sha256hex(body)}"+ and the
70
+ # signature header is formatted as +sha256=<hex>+.
71
+ #
72
+ # @param secret [String] the request signing secret
73
+ # @param method [String, Symbol] the HTTP method (case-insensitive; uppercased)
74
+ # @param path [String] the exact request path, verbatim (for apertur this
75
+ # includes the +/api/v1+ prefix, with no query string)
76
+ # @param body [String, nil] the exact serialized bytes/string sent as the
77
+ # request body; +nil+ hashes as the empty string
78
+ # @param timestamp [Integer] unix seconds
79
+ # @return [Hash{String=>String}] +{ "X-Aptr-Signature" => "sha256=<hex>",
80
+ # "X-Aptr-Timestamp" => "<unix seconds>" }+
81
+ def sign_request(secret, method, path, body, timestamp)
82
+ body_hash = OpenSSL::Digest::SHA256.hexdigest(body || "")
83
+ signature_base = "#{timestamp}.#{method.to_s.upcase}.#{path}.#{body_hash}"
84
+ signature = OpenSSL::HMAC.hexdigest("SHA256", secret, signature_base)
85
+ {
86
+ "X-Aptr-Signature" => "sha256=#{signature}",
87
+ "X-Aptr-Timestamp" => timestamp.to_s
88
+ }
89
+ end
90
+
66
91
  # Constant-time string comparison to prevent timing attacks.
67
92
  #
68
93
  # @param a [String]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Apertur
4
- VERSION = "0.1.5"
4
+ VERSION = "0.1.8"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: apertur-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Apertur
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-18 00:00:00.000000000 Z
11
+ date: 2026-08-03 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Official Ruby client for the Apertur image upload and delivery API. Supports
14
14
  session management, image uploads (including client-side encryption), polling, destinations,