calmlens 0.1.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.
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+ require 'uri'
6
+ require_relative '../calmlens'
7
+ require_relative 'testing/fake_api'
8
+
9
+ module CalmLens
10
+ # An offline client for tests. Calls run the real client's validation, encoding, retries and
11
+ # error handling, then reach an in-memory stand-in for the API and CDN instead of the network.
12
+ #
13
+ # By default the stand-in is stateful: uploads create assets you can retrieve, list, filter,
14
+ # move and delete, folders behave like the API's, the CDN serves stored bytes, and errors use
15
+ # the API's codes, HTTP statuses and body shape. It does not classify content: assets moderated
16
+ # synchronously get `analyzed_outcome` and queued ones `queued_outcome`. Content is kept only with
17
+ # `keepContentAfterProcessing: true`; otherwise it is removed once processing is `done`, as in the API.
18
+ #
19
+ # To script exact responses, enqueue them: queued responses and errors are used first, in order,
20
+ # before the stand-in answers.
21
+ #
22
+ # @example
23
+ # mock = CalmLens::MockServerClient.new
24
+ # asset = mock.assets.moderate(name: 'note', content: {plain_text: 'hi', type: 'text/plain'})
25
+ # mock.assets.retrieve(asset_id: asset['id']).dig('moderation', 'status') # => "approved"
26
+ # mock.enqueue(status: 503, body: {code: 'server/unavailable', message: 'Try again'})
27
+ class MockServerClient < ServerClient
28
+ # A request the mock received.
29
+ #
30
+ # `path` is relative to `base_url` for API calls (`/projects/<id>/assets`) and to
31
+ # `cdn_base_url` for CDN requests. `query` holds the decoded query string. `body` is the bytes
32
+ # sent: JSON text, or the uploaded content (streams are read), or nil.
33
+ Request = Struct.new(:method, :url, :path, :query, :headers, :body, keyword_init: true) do
34
+ # @return [Object] the body parsed as JSON.
35
+ def json = body && JSON.parse(body)
36
+ end
37
+
38
+ # @return [Testing::State] the stored assets, folders, content and events.
39
+ attr_reader :state
40
+
41
+ # @param project_id [String]
42
+ # @param now [#call] returns the current time in epoch milliseconds.
43
+ # @param create_id [#call] returns a new ID.
44
+ # @param queued_outcome [String] the outcome of assets created with {Assets#create}: `pending`
45
+ # (processing, moderation pending), `approved` or `rejected` (processing done with that
46
+ # decision) or `failed` (processing failed, retryable).
47
+ # @param analyzed_outcome [String, Hash, #call] the result of {Assets#moderate}: an outcome, fields
48
+ # to deep-merge into the asset (`{'moderation' => {'status' => 'rejected', 'action' => 'hide'}}`;
49
+ # the outcome follows `moderation.status`, or is `failed` when `processing.status` is `failed`,
50
+ # and is `approved` otherwise), or a callable that receives a copy of the asset and returns either.
51
+ # @param options [Hash] other {ServerClient#initialize} settings, except `api_key` and `transport`.
52
+ # Retries wait no time unless a queued response sets `Retry-After`.
53
+ def initialize(project_id: 'project-1', base_url: 'https://api.calmlens.invalid/v1',
54
+ cdn_base_url: 'https://cdn.calmlens.invalid/v1', now: -> { (Time.now.to_f * 1000).to_i },
55
+ create_id: -> { SecureRandom.uuid }, queued_outcome: 'pending', analyzed_outcome: 'approved',
56
+ **options)
57
+ @mock_lock = Mutex.new
58
+ @queue = []
59
+ @received = []
60
+ super(api_key: 'mock-key', project_id: project_id, base_url: base_url, cdn_base_url: cdn_base_url,
61
+ retry_base_delay: 0, **options, transport: method(:receive))
62
+ @fake = Testing::FakeAPI.new(project_id: project_id, base_url: self.base_url, cdn_base_url: self.cdn_base_url,
63
+ now: now, create_id: create_id, queued_outcome: queued_outcome,
64
+ analyzed_outcome: analyzed_outcome)
65
+ @state = @fake.state
66
+ end
67
+
68
+ # Queues a JSON response for the next request.
69
+ # @param status [Integer]
70
+ # @param body [Object] encoded as JSON
71
+ # @param headers [Hash{String => String}]
72
+ # @return [self]
73
+ def enqueue(body:, status: 200, headers: {})
74
+ enqueue_raw(status: status, body: JSON.generate(body), headers: { 'content-type' => 'application/json' }.merge(headers))
75
+ end
76
+
77
+ # Queues a response with a raw body, such as malformed JSON or binary CDN content.
78
+ # @return [self]
79
+ def enqueue_raw(body:, status: 200, headers: {})
80
+ response = Response.new(status: status, body: body.to_s.dup,
81
+ headers: { 'x-request-id' => "mock-#{SecureRandom.hex(8)}" }.merge(headers))
82
+ @mock_lock.synchronize { @queue << response }
83
+ self
84
+ end
85
+
86
+ # Fails the next request with an exception raised by the transport, such as
87
+ # `Net::ReadTimeout.new` (raised as `sdk/timeout`) or `Errno::ECONNRESET.new`
88
+ # (`sdk/transport-error`). The client wraps it exactly as it wraps real network failures.
89
+ # @return [self]
90
+ def enqueue_error(error)
91
+ @mock_lock.synchronize { @queue << error }
92
+ self
93
+ end
94
+
95
+ # Adds an event to the mock's events feed. Fields you omit (`id`, `projectId`, `createdAt`,
96
+ # `severity`, `title`, `facts`) are filled in.
97
+ # @param event [Hash] at least `type:`; snake_case keys are converted.
98
+ # @return [Hash] the stored event
99
+ def enqueue_event(event)
100
+ @mock_lock.synchronize { JSON.parse(JSON.generate(@fake.add_event(Internal.normalize(event)))) }
101
+ end
102
+
103
+ # @return [Array<Request>] copies of every request received so far, oldest first.
104
+ def requests
105
+ @mock_lock.synchronize { @received.map { |request| Request.new(**Marshal.load(Marshal.dump(request.to_h))) } }
106
+ end
107
+
108
+ # @return [Integer] queued responses and errors not used yet.
109
+ def remaining_responses
110
+ @mock_lock.synchronize { @queue.size }
111
+ end
112
+
113
+ def inspect = "#<#{self.class.name} project_id=#{project_id.inspect}>"
114
+
115
+ private
116
+
117
+ # Returns the response rather than yielding it: the client accepts either from a transport.
118
+ def receive(request, &)
119
+ body = request.body.respond_to?(:read) ? read_stream(request.body) : request.body&.b
120
+ path, query = Internal.split_url(request.url).last.split('?', 2)
121
+ base = request.url.start_with?("#{cdn_base_url}/") ? URI(cdn_base_url).path : URI(base_url).path
122
+ @mock_lock.synchronize do
123
+ @received << Request.new(method: request.method, url: request.url, path: path.delete_prefix(base),
124
+ query: URI.decode_www_form(query.to_s).to_h, headers: request.headers.dup,
125
+ body: body)
126
+ queued = @queue.shift
127
+ raise queued if queued.is_a?(Exception)
128
+
129
+ queued || @fake.handle(request.method, request.url, request.headers, body)
130
+ end
131
+ end
132
+
133
+ def read_stream(io)
134
+ buffer = String.new(encoding: Encoding::BINARY)
135
+ while (chunk = io.read(64 * 1024))
136
+ buffer << chunk
137
+ end
138
+ buffer
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CalmLens
4
+ # Text moderation shortcuts. Reach it through {ServerClient#text}.
5
+ class Text < Resource
6
+ # @api private
7
+ def initialize(session, assets)
8
+ super(session)
9
+ @assets = assets
10
+ end
11
+
12
+ # Moderates a string as a `text/plain` asset and waits for the decision.
13
+ # @option options [String] :text
14
+ # @option options [String] :name defaults to `text`
15
+ # @return [Hash] the asset; publish only when `moderation.status` is `approved` and
16
+ # `moderation.action` is nil.
17
+ def moderate(options)
18
+ api, request = call(options) { |*split| split }
19
+ text = api['text']
20
+ unless text.is_a?(String) && !text.strip.empty?
21
+ raise Internal.invalid('Text to moderate must be a non-empty string', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
22
+ end
23
+
24
+ @assets.moderate(without(api, 'text').merge('name' => api.fetch('name', 'text'),
25
+ 'content' => { 'plainText' => text, 'type' => 'text/plain' },
26
+ 'request_options' => request))
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CalmLens
4
+ # CDN transform selectors: selector Strings and structured steps serialized the way every SDK does.
5
+ # @api private
6
+ module Transforms
7
+ module_function
8
+
9
+ TYPE = /\A[A-Za-z0-9_]+\z/
10
+
11
+ # Returns the selector the API and CDN accept for a transform: a selector String unchanged, or
12
+ # structured steps (`{type:, options:}` or an Array of them) serialized by {serialize}.
13
+ def selector(value)
14
+ value.nil? || value.is_a?(String) ? value : serialize(value)
15
+ end
16
+
17
+ # Builds the CDN path of a transform. A selector String keeps its own separators and literal
18
+ # `[`/`]`, which the CDN matches; serialized steps are already path-safe.
19
+ def path(value)
20
+ return serialize(value) unless value.is_a?(String)
21
+
22
+ text = value.sub(%r{\A/+}, '')
23
+ raise Internal.invalid('Transform cannot contain "..", "?" or "#"') if text.include?('..') || text.match?(/[?#]/)
24
+
25
+ # Like JavaScript's encodeURI, except that brackets stay literal.
26
+ text.b.gsub(%r{[^A-Za-z0-9\-_.!~*'();/:@&=+$,\[\]]}n) { |byte| format('%%%02X', byte.ord) }
27
+ end
28
+
29
+ # Serializes structured transforms as a CDN selector such as `imageTransform[format=png,width=1024]`.
30
+ # Option keys are sorted so equal transforms share a URL; nested keys (`border[color]`) and values
31
+ # are percent-encoded.
32
+ def serialize(value)
33
+ steps = value.is_a?(Array) ? value : [value]
34
+ raise Internal.invalid('Transform needs at least one step') if steps.empty?
35
+
36
+ steps.map do |step|
37
+ raise Internal.invalid('Transform steps must be Hashes') unless step.is_a?(Hash)
38
+
39
+ step = step.transform_keys(&:to_s)
40
+ type = step['type']
41
+ unless type.is_a?(String) && type.match?(TYPE)
42
+ raise Internal.invalid('Transform type must contain only letters, digits and underscores')
43
+ end
44
+
45
+ options = step['options'] || {}
46
+ raise Internal.invalid('Transform options must be a Hash') unless options.is_a?(Hash)
47
+
48
+ pairs = []
49
+ sorted_options(options).each { |key, item| append_pairs(pairs, key, item) }
50
+ "#{type}[#{pairs.join(',')}]"
51
+ end.join(',')
52
+ end
53
+
54
+ def sorted_options(options) = options.map { |key, item| [key.to_s, item] }.sort_by(&:first)
55
+
56
+ def append_pairs(pairs, key, value)
57
+ return if value.nil?
58
+ return sorted_options(value).each { |child, item| append_pairs(pairs, "#{key}[#{child}]", item) } if value.is_a?(Hash)
59
+
60
+ text = case value
61
+ when String, true, false, Integer then value.to_s
62
+ when Float
63
+ raise Internal.invalid("Transform option \"#{key}\" must be a finite number") unless value.finite?
64
+
65
+ js_number(value)
66
+ else raise Internal.invalid("Transform option \"#{key}\" must be a String, number, boolean or Hash")
67
+ end
68
+ pairs << "#{component(key)}=#{component(text)}"
69
+ end
70
+
71
+ # Percent-encodes everything except RFC 3986 unreserved characters.
72
+ def component(text)
73
+ text.encode(Encoding::UTF_8).b.gsub(/[^A-Za-z0-9\-._~]/n) { |byte| format('%%%02X', byte.ord) }
74
+ end
75
+
76
+ # Formats a Float the way JavaScript's String(number) does, so every SDK builds the same URL.
77
+ def js_number(value)
78
+ return '0' if value.zero?
79
+
80
+ mantissa, exponent = value.abs.to_s.split('e')
81
+ whole, fraction = mantissa.split('.')
82
+ digits = "#{whole}#{fraction}"
83
+ point = whole.length + exponent.to_i
84
+ stripped = digits.sub(/\A0+/, '')
85
+ point -= digits.length - stripped.length
86
+ digits = stripped.sub(/0+\z/, '')
87
+ sign = value.negative? ? '-' : ''
88
+ "#{sign}#{js_digits(digits, point)}"
89
+ end
90
+
91
+ # Places a decimal point in significant `digits` whose value is 0.digits x 10^point.
92
+ def js_digits(digits, point)
93
+ size = digits.length
94
+ if point.between?(size, 21) then digits + ('0' * (point - size))
95
+ elsif point.positive? && point <= 21 then "#{digits[0, point]}.#{digits[point..]}"
96
+ elsif point > -6 && point <= 0 then "0.#{'0' * -point}#{digits}"
97
+ else
98
+ shown = size > 1 ? "#{digits[0]}.#{digits[1..]}" : digits
99
+ "#{shown}e#{point - 1 >= 0 ? '+' : '-'}#{(point - 1).abs}"
100
+ end
101
+ end
102
+ end
103
+ private_constant :Transforms
104
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'openssl'
5
+ require 'uri'
6
+ require 'zlib'
7
+
8
+ module CalmLens
9
+ # The HTTP layer. Pass a custom transport to {ServerClient#initialize} to route requests elsewhere;
10
+ # it receives a {Transport::Request} and either returns a {Response} or yields one to the block
11
+ # it is given (and returns the block's result), which lets the body stream.
12
+ module Transport
13
+ # One outgoing HTTP request.
14
+ #
15
+ # @!attribute method [String] the HTTP method.
16
+ # @!attribute url [String] the absolute URL.
17
+ # @!attribute headers [Hash{String => String}]
18
+ # @!attribute body [String, #read, nil] the request body. A streamed body has a `Content-Length` header.
19
+ # @!attribute open_timeout [Numeric] seconds allowed to open a connection.
20
+ # @!attribute timeout [Numeric] seconds allowed for each read or write; not a whole-transfer deadline.
21
+ Request = Struct.new(:method, :url, :headers, :body, :open_timeout, :timeout, keyword_init: true) do
22
+ def inspect = "#<#{self.class.name} #{method} #{url}>"
23
+ end
24
+
25
+ # Exceptions that mean the connection failed before a complete response arrived.
26
+ ERRORS = [IOError, SystemCallError, SocketError, OpenSSL::SSL::SSLError, Net::ProtocolError,
27
+ Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Zlib::Error].freeze
28
+
29
+ # The default transport: Net::HTTP with a small per-origin pool of keep-alive connections.
30
+ # Redirects are never followed, so credentials never reach another host. After `fork`, the
31
+ # child process discards the inherited connections and opens its own.
32
+ class NetHTTP
33
+ MAX_IDLE_PER_ORIGIN = 8
34
+
35
+ def initialize
36
+ @lock = Mutex.new
37
+ @idle = {}
38
+ @closed = false
39
+ @pid = Process.pid
40
+ end
41
+
42
+ # @param request [Request]
43
+ # @yieldparam response [Response] a response whose body streams from the socket.
44
+ def call(request)
45
+ uri, target = Internal.split_url(request.url)
46
+ origin = [uri.scheme, uri.host, uri.port]
47
+ connection = checkout(origin)
48
+ reusable = false
49
+ begin
50
+ connection ||= connect(uri, request.open_timeout)
51
+ connection.read_timeout = connection.write_timeout = request.timeout
52
+ result = nil
53
+ connection.request(build(target, request)) do |net|
54
+ response = Response.new(status: net.code, headers: net.to_hash) { |&chunk| net.read_body(&chunk) }
55
+ result = yield response
56
+ reusable = !net['connection'].to_s.casecmp?('close')
57
+ end
58
+ result
59
+ ensure
60
+ checkin(origin, connection, reusable) if connection
61
+ end
62
+ end
63
+
64
+ # Closes idle connections. Later requests fail.
65
+ def close
66
+ @lock.synchronize do
67
+ @closed = true
68
+ @idle.each_value { |connections| connections.each { |connection| finish(connection) } }
69
+ @idle.clear
70
+ end
71
+ end
72
+
73
+ def inspect = "#<#{self.class.name}>"
74
+
75
+ private
76
+
77
+ def build(target, request)
78
+ net = Net::HTTPGenericRequest.new(request.method, !request.body.nil?, request.method != 'HEAD',
79
+ target, request.headers)
80
+ if request.body.respond_to?(:read)
81
+ net.body_stream = request.body
82
+ elsif request.body
83
+ net.body = request.body
84
+ end
85
+ net
86
+ end
87
+
88
+ def connect(uri, open_timeout)
89
+ connection = Net::HTTP.new(uri.host, uri.port)
90
+ connection.use_ssl = uri.scheme == 'https'
91
+ connection.open_timeout = open_timeout
92
+ # Retries are decided by the client, which knows which requests are safe to repeat.
93
+ connection.max_retries = 0
94
+ connection.start
95
+ connection
96
+ end
97
+
98
+ def checkout(origin)
99
+ @lock.synchronize do
100
+ raise ConnectionError.new(SdkErrorCodes::TRANSPORT_ERROR, 'Client is closed') if @closed
101
+
102
+ # Sockets inherited across fork are shared with the parent: drop them without closing.
103
+ if @pid != Process.pid
104
+ @idle = {}
105
+ @pid = Process.pid
106
+ end
107
+ (@idle[origin] ||= []).pop
108
+ end
109
+ end
110
+
111
+ def checkin(origin, connection, reusable)
112
+ kept = @lock.synchronize do
113
+ idle = (@idle[origin] ||= [])
114
+ if reusable && !@closed && @pid == Process.pid && idle.size < MAX_IDLE_PER_ORIGIN
115
+ idle << connection
116
+ true
117
+ end
118
+ end
119
+ finish(connection) unless kept
120
+ end
121
+
122
+ def finish(connection)
123
+ connection.finish if connection.started?
124
+ rescue IOError, SystemCallError
125
+ nil
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CalmLens
4
+ # An upload payload: a binary String, or an IO streamed with an exact byte count.
5
+ # @api private
6
+ class UploadBody
7
+ PAYLOAD_KEYS = %w[plainText base64 data bytes].freeze
8
+ MIN_PART_BYTES = 5 * 1024 * 1024
9
+
10
+ attr_reader :size
11
+
12
+ # Builds the body from upload content options (`plainText`, `base64`, or `data`/`bytes` as a
13
+ # String or IO). IO input is streamed, never read into memory.
14
+ def self.from(content, multipart:)
15
+ raise Internal.invalid('Upload content must be a Hash', ErrorCodes::INVALID_UPLOAD_PAYLOAD) unless content.is_a?(Hash)
16
+
17
+ keys = PAYLOAD_KEYS.select { |key| content.key?(key) }
18
+ raise Internal.invalid('Supply exactly one payload representation', ErrorCodes::INVALID_UPLOAD_PAYLOAD) unless keys.size == 1
19
+
20
+ body = build(keys.first, content.fetch(keys.first), content['sizeInBytes'], multipart)
21
+ if content.key?('sizeInBytes') && content['sizeInBytes'] != body.size
22
+ raise Internal.invalid('sizeInBytes must match the payload byte length', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
23
+ end
24
+ if multipart && !content.fetch('isFinal', false) && body.size < MIN_PART_BYTES
25
+ raise Internal.invalid('Multipart upload parts must be at least 5MiB except the final part.',
26
+ ErrorCodes::INVALID_UPLOAD_PAYLOAD)
27
+ end
28
+ body
29
+ end
30
+
31
+ def self.build(kind, value, declared, multipart)
32
+ case kind
33
+ when 'plainText'
34
+ raise Internal.invalid('plainText must be a String', ErrorCodes::INVALID_UPLOAD_PAYLOAD) unless value.is_a?(String)
35
+
36
+ new(value.encode(Encoding::UTF_8).b)
37
+ when 'base64' then new(decode64(value, multipart))
38
+ else
39
+ return new(value.b) if value.is_a?(String)
40
+ return new(IOReader.new(value, stream_size(value, declared))) if value.respond_to?(:read)
41
+
42
+ raise Internal.invalid('Expected a binary String or IO', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
43
+ end
44
+ end
45
+
46
+ def self.decode64(value, multipart)
47
+ raise Internal.invalid('base64 must be a String', ErrorCodes::INVALID_UPLOAD_PAYLOAD) unless value.is_a?(String)
48
+
49
+ raw = value.split(';base64,').last.to_s.gsub(/\s/, '')
50
+ if multipart && raw.length % 4 != 0
51
+ raise Internal.invalid('Split bytes before base64-encoding each part', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
52
+ end
53
+
54
+ (raw + ('=' * ((4 - (raw.length % 4)) % 4))).unpack1('m0')
55
+ rescue ArgumentError
56
+ raise Internal.invalid('Invalid base64', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
57
+ end
58
+
59
+ # The bytes left in a sized stream (a File, Tempfile or StringIO), or the declared size.
60
+ def self.stream_size(io, declared)
61
+ return declared if declared.is_a?(Integer) && declared >= 0
62
+
63
+ remaining = io.size - io.pos if io.respond_to?(:size) && io.respond_to?(:pos)
64
+ return remaining if remaining.is_a?(Integer) && remaining >= 0
65
+
66
+ raise Internal.invalid('Pass sizeInBytes for a stream whose length is unknown', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
67
+ rescue IOError, SystemCallError, NoMethodError
68
+ raise Internal.invalid('Pass sizeInBytes for a stream whose length is unknown', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
69
+ end
70
+ private_class_method :build, :decode64, :stream_size
71
+
72
+ def initialize(source)
73
+ @source = source
74
+ @size = source.is_a?(String) ? source.bytesize : source.size
75
+ end
76
+
77
+ # @return [String, IOReader] what the transport sends.
78
+ def content
79
+ @source
80
+ end
81
+
82
+ # @return [Boolean] whether the body can be sent again for a retry.
83
+ def replayable?
84
+ @source.is_a?(String) || @source.rewindable?
85
+ end
86
+
87
+ # Prepares the body to be sent again.
88
+ def rewind
89
+ @source.rewind unless @source.is_a?(String)
90
+ end
91
+
92
+ # The bytes, read fully. Used only by the offline mock.
93
+ def read_all
94
+ return @source if @source.is_a?(String)
95
+
96
+ @source.rewind if @source.rewindable?
97
+ @source.read || ''.b
98
+ end
99
+
100
+ # Reads exactly `size` bytes from an IO and fails if it holds fewer or more.
101
+ class IOReader
102
+ attr_reader :size
103
+
104
+ def initialize(io, size)
105
+ @io = io
106
+ @size = size
107
+ @remaining = size
108
+ @start = (io.pos if io.respond_to?(:pos) && io.respond_to?(:seek))
109
+ rescue IOError, SystemCallError
110
+ @start = nil
111
+ end
112
+
113
+ def rewindable? = !@start.nil?
114
+
115
+ def rewind
116
+ @io.seek(@start)
117
+ @remaining = @size
118
+ end
119
+
120
+ def read(length = nil, buffer = nil)
121
+ return (buffer ? buffer.replace(''.b) : ''.b) if length&.zero?
122
+
123
+ if @remaining.zero?
124
+ extra = @io.read(1)
125
+ unless extra.nil? || extra.empty?
126
+ raise Internal.invalid('The stream holds more bytes than sizeInBytes',
127
+ ErrorCodes::INVALID_UPLOAD_PAYLOAD)
128
+ end
129
+
130
+ buffer&.clear
131
+ return length.nil? || length.zero? ? ''.b : nil
132
+ end
133
+ chunk = @io.read([length || @remaining, @remaining].min)
134
+ if chunk.nil? || chunk.empty?
135
+ raise Internal.invalid('The stream ended before sizeInBytes bytes', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
136
+ end
137
+
138
+ @remaining -= chunk.bytesize
139
+ chunk = chunk.b
140
+ buffer ? buffer.replace(chunk) : chunk
141
+ end
142
+
143
+ def readpartial(length, buffer = nil)
144
+ read(length, buffer) || raise(EOFError)
145
+ end
146
+ end
147
+ end
148
+ private_constant :UploadBody
149
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+ require 'openssl'
6
+ require 'securerandom'
7
+
8
+ module CalmLens
9
+ # Signs upload tokens, which let a browser upload through an upload preset. Reach it through
10
+ # {ServerClient#upload_tokens}. Signing happens locally with the API key; no request is made.
11
+ class UploadTokens < Resource
12
+ TOKEN_PREFIX = 'clut_'
13
+ SIGNING_CONTEXT = 'calmlens-upload-token:v1:'
14
+ DEFAULT_TTL_SECONDS = 600
15
+ MAX_TTL_SECONDS = 3600
16
+
17
+ # @api private
18
+ def initialize(session, api_key)
19
+ super(session)
20
+ @api_key = api_key
21
+ end
22
+
23
+ # Signs a single-use token. Hand it to the browser as its bearer token; give each upload its own.
24
+ # @option options [String] :upload_preset_id the preset the token may upload through
25
+ # @option options [String] :external_id the external ID the uploaded asset gets (not empty)
26
+ # @option options [Integer] :expires_in_seconds lifetime, 1 to 3600 (default 600)
27
+ # @return [Hash] `{'token' => String, 'expiresAt' => Integer}` (milliseconds since the Unix epoch)
28
+ def sign(options)
29
+ api, = call(options) { |*split| split }
30
+ preset_id = api['uploadPresetId']
31
+ Internal.segment(preset_id)
32
+ ttl = api.fetch('expiresInSeconds', DEFAULT_TTL_SECONDS)
33
+ unless ttl.is_a?(Integer) && ttl.between?(1, MAX_TTL_SECONDS)
34
+ raise Internal.invalid("expiresInSeconds must be a whole number from 1 to #{MAX_TTL_SECONDS}")
35
+ end
36
+
37
+ external_id = api['externalId']
38
+ if api.key?('externalId') && !(external_id.is_a?(String) && !external_id.empty?)
39
+ raise Internal.invalid('externalId must be a non-empty string')
40
+ end
41
+
42
+ expires_at = (Time.now.to_f * 1000).to_i + (ttl * 1000)
43
+ claims = { 'v' => 1, 'u' => preset_id, 'k' => @api_key[0, @api_key.start_with?('clsk_') ? 13 : 8],
44
+ 'e' => expires_at, 'n' => SecureRandom.uuid }
45
+ claims['x'] = external_id if external_id
46
+ body = [JSON.generate(claims)].pack('m0').tr('+/', '-_').delete('=')
47
+ secret = Digest::SHA256.hexdigest(@api_key)
48
+ signature = OpenSSL::HMAC.hexdigest('SHA256', secret, SIGNING_CONTEXT + body)
49
+ { 'token' => "#{TOKEN_PREFIX}#{body}.#{signature}", 'expiresAt' => expires_at }
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CalmLens
4
+ # The gem version. The gemspec and the User-Agent header read it from here.
5
+ VERSION = '0.1.0'
6
+ end