calmlens 0.1.0 → 0.17.1

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.
data/lib/calmlens/text.rb CHANGED
@@ -12,16 +12,18 @@ module CalmLens
12
12
  # Moderates a string as a `text/plain` asset and waits for the decision.
13
13
  # @option options [String] :text
14
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)
15
+ # @option options [String] :author_id who wrote the text, as for {Assets#create}
16
+ # @option options [Hash] :context earlier posts in the conversation, as for {Assets#create}
17
+ # @return [Hash] the asset; show it to everyone only when `classification.moderation.status` is
18
+ # `allowed` or `limited` (reduce the reach of `limited` content).
19
+ def classify(options)
18
20
  api, request = call(options) { |*split| split }
19
21
  text = api['text']
20
22
  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)
23
+ raise Internal.invalid('Text to classify must be a non-empty string', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
22
24
  end
23
25
 
24
- @assets.moderate(without(api, 'text').merge('name' => api.fetch('name', 'text'),
26
+ @assets.classify(without(api, 'text').merge('name' => api.fetch('name', 'text'),
25
27
  'content' => { 'plainText' => text, 'type' => 'text/plain' },
26
28
  'request_options' => request))
27
29
  end
@@ -6,7 +6,13 @@ module CalmLens
6
6
  module Transforms
7
7
  module_function
8
8
 
9
- TYPE = /\A[A-Za-z0-9_]+\z/
9
+ OPTION_NAME = /\A[a-z][a-zA-Z0-9]*\z/
10
+ # <generated: image-transform-colours>
11
+ # Generated by packages/sdk-contract/generate.ts from IMAGE_TRANSFORM_OPTIONS_SCHEMA; do not edit.
12
+ # Options that hold colours, written as hex without `#` in a selector.
13
+ COLOUR_KEYS = %w[background border.color trim.border.color].freeze
14
+ # </generated: image-transform-colours>
15
+ HEX_COLOUR = /\A#((?:\h{3}){1,2}|(?:\h{4}){1,2})\z/
10
16
 
11
17
  # Returns the selector the API and CDN accept for a transform: a selector String unchanged, or
12
18
  # structured steps (`{type:, options:}` or an Array of them) serialized by {serialize}.
@@ -14,21 +20,21 @@ module CalmLens
14
20
  value.nil? || value.is_a?(String) ? value : serialize(value)
15
21
  end
16
22
 
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.
23
+ # Builds the CDN path of a transform. A selector String keeps its own step separators and is
24
+ # encoded like JavaScript's encodeURI; serialized steps are already path-safe.
19
25
  def path(value)
20
26
  return serialize(value) unless value.is_a?(String)
21
27
 
22
28
  text = value.sub(%r{\A/+}, '')
23
29
  raise Internal.invalid('Transform cannot contain "..", "?" or "#"') if text.include?('..') || text.match?(/[?#]/)
24
30
 
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) }
31
+ # Like JavaScript's encodeURI.
32
+ text.b.gsub(%r{[^A-Za-z0-9\-_.!~*'();/:@&=+$,]}n) { |byte| format('%%%02X', byte.ord) }
27
33
  end
28
34
 
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.
35
+ # Serializes structured transforms as a CDN selector, one path segment per step, such as
36
+ # `fit=cover,width=640/format=webp`. Option keys are sorted so equal transforms share a URL, nested
37
+ # keys are dotted (`border.color`), hex colours are written without `#` and values are percent-encoded.
32
38
  def serialize(value)
33
39
  steps = value.is_a?(Array) ? value : [value]
34
40
  raise Internal.invalid('Transform needs at least one step') if steps.empty?
@@ -37,35 +43,48 @@ module CalmLens
37
43
  raise Internal.invalid('Transform steps must be Hashes') unless step.is_a?(Hash)
38
44
 
39
45
  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
46
+ raise Internal.invalid('Transform type must be "imageTransform"') unless step['type'] == 'imageTransform'
44
47
 
45
48
  options = step['options'] || {}
46
49
  raise Internal.invalid('Transform options must be a Hash') unless options.is_a?(Hash)
47
50
 
48
51
  pairs = []
49
52
  sorted_options(options).each { |key, item| append_pairs(pairs, key, item) }
50
- "#{type}[#{pairs.join(',')}]"
51
- end.join(',')
53
+ raise Internal.invalid('Transform steps need at least one option') if pairs.empty?
54
+
55
+ pairs.join(',')
56
+ end.join('/')
52
57
  end
53
58
 
54
- def sorted_options(options) = options.map { |key, item| [key.to_s, item] }.sort_by(&:first)
59
+ def sorted_options(options, prefix = '')
60
+ options.map do |key, item|
61
+ name = key.to_s
62
+ raise Internal.invalid("Transform option \"#{prefix}#{name}\" is not a valid name") unless name.match?(OPTION_NAME)
63
+
64
+ [name, item]
65
+ end.sort_by(&:first)
66
+ end
55
67
 
56
68
  def append_pairs(pairs, key, value)
57
69
  return if value.nil?
58
- return sorted_options(value).each { |child, item| append_pairs(pairs, "#{key}[#{child}]", item) } if value.is_a?(Hash)
70
+ return sorted_options(value, "#{key}.").each { |child, item| append_pairs(pairs, "#{key}.#{child}", item) } if value.is_a?(Hash)
59
71
 
60
72
  text = case value
61
- when String, true, false, Integer then value.to_s
73
+ when String then colour(key, value)
74
+ when true, false, Integer then value.to_s
62
75
  when Float
63
76
  raise Internal.invalid("Transform option \"#{key}\" must be a finite number") unless value.finite?
64
77
 
65
78
  js_number(value)
66
79
  else raise Internal.invalid("Transform option \"#{key}\" must be a String, number, boolean or Hash")
67
80
  end
68
- pairs << "#{component(key)}=#{component(text)}"
81
+ pairs << "#{key}=#{component(text)}"
82
+ end
83
+
84
+ # Writes a hex colour option without `#`, in lower case; other values are unchanged.
85
+ def colour(key, value)
86
+ match = COLOUR_KEYS.include?(key) && HEX_COLOUR.match(value)
87
+ match ? match[1].downcase : value
69
88
  end
70
89
 
71
90
  # Percent-encodes everything except RFC 3986 unreserved characters.
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CalmLens
4
- # An upload payload: a binary String, or an IO streamed with an exact byte count.
4
+ # An upload payload: a binary String, or an IO streamed with an exact byte count (or, when its
5
+ # length is unknown, until it ends, sent with chunked transfer encoding).
5
6
  # @api private
6
7
  class UploadBody
7
8
  PAYLOAD_KEYS = %w[plainText base64 data bytes].freeze
@@ -11,7 +12,8 @@ module CalmLens
11
12
 
12
13
  # Builds the body from upload content options (`plainText`, `base64`, or `data`/`bytes` as a
13
14
  # String or IO). IO input is streamed, never read into memory.
14
- def self.from(content, multipart:)
15
+ # @param part_size [Boolean] check that a multipart part other than the last has 5 MiB.
16
+ def self.from(content, multipart:, part_size: true)
15
17
  raise Internal.invalid('Upload content must be a Hash', ErrorCodes::INVALID_UPLOAD_PAYLOAD) unless content.is_a?(Hash)
16
18
 
17
19
  keys = PAYLOAD_KEYS.select { |key| content.key?(key) }
@@ -21,7 +23,7 @@ module CalmLens
21
23
  if content.key?('sizeInBytes') && content['sizeInBytes'] != body.size
22
24
  raise Internal.invalid('sizeInBytes must match the payload byte length', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
23
25
  end
24
- if multipart && !content.fetch('isFinal', false) && body.size < MIN_PART_BYTES
26
+ if part_size && multipart && !content.fetch('isFinal', false) && body.size && body.size < MIN_PART_BYTES
25
27
  raise Internal.invalid('Multipart upload parts must be at least 5MiB except the final part.',
26
28
  ErrorCodes::INVALID_UPLOAD_PAYLOAD)
27
29
  end
@@ -56,16 +58,15 @@ module CalmLens
56
58
  raise Internal.invalid('Invalid base64', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
57
59
  end
58
60
 
59
- # The bytes left in a sized stream (a File, Tempfile or StringIO), or the declared size.
61
+ # The bytes left in a sized stream (a File, Tempfile or StringIO), the declared size, or nil when
62
+ # the length is unknown (a pipe or socket).
60
63
  def self.stream_size(io, declared)
61
64
  return declared if declared.is_a?(Integer) && declared >= 0
62
65
 
63
66
  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
+ remaining.is_a?(Integer) && remaining >= 0 ? remaining : nil
67
68
  rescue IOError, SystemCallError, NoMethodError
68
- raise Internal.invalid('Pass sizeInBytes for a stream whose length is unknown', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
69
+ nil
69
70
  end
70
71
  private_class_method :build, :decode64, :stream_size
71
72
 
@@ -97,7 +98,8 @@ module CalmLens
97
98
  @source.read || ''.b
98
99
  end
99
100
 
100
- # Reads exactly `size` bytes from an IO and fails if it holds fewer or more.
101
+ # Reads exactly `size` bytes from an IO and fails if it holds fewer or more; without a size, reads
102
+ # until the IO ends.
101
103
  class IOReader
102
104
  attr_reader :size
103
105
 
@@ -119,6 +121,7 @@ module CalmLens
119
121
 
120
122
  def read(length = nil, buffer = nil)
121
123
  return (buffer ? buffer.replace(''.b) : ''.b) if length&.zero?
124
+ return read_unsized(length, buffer) if @size.nil?
122
125
 
123
126
  if @remaining.zero?
124
127
  extra = @io.read(1)
@@ -140,6 +143,14 @@ module CalmLens
140
143
  buffer ? buffer.replace(chunk) : chunk
141
144
  end
142
145
 
146
+ def read_unsized(length, buffer)
147
+ chunk = @io.read(*[length].compact)
148
+ return buffer&.clear && nil if chunk.nil? || (length && chunk.empty?)
149
+
150
+ chunk = chunk.b
151
+ buffer ? buffer.replace(chunk) : chunk
152
+ end
153
+
143
154
  def readpartial(length, buffer = nil)
144
155
  read(length, buffer) || raise(EOFError)
145
156
  end
@@ -26,7 +26,14 @@ module CalmLens
26
26
  # @option options [Integer] :expires_in_seconds lifetime, 1 to 3600 (default 600)
27
27
  # @return [Hash] `{'token' => String, 'expiresAt' => Integer}` (milliseconds since the Unix epoch)
28
28
  def sign(options)
29
- api, = call(options) { |*split| split }
29
+ call(options) { UploadTokens.sign_at(@api_key, options, now: (Time.now.to_f * 1000).to_i, nonce: SecureRandom.uuid) }
30
+ end
31
+
32
+ # Signs like {#sign} at a fixed time with a fixed nonce. {MockServerClient.sign_upload_token}
33
+ # exposes it to tests that need exact tokens.
34
+ # @api private
35
+ def self.sign_at(api_key, options, now:, nonce:)
36
+ api, = Internal.options(options)
30
37
  preset_id = api['uploadPresetId']
31
38
  Internal.segment(preset_id)
32
39
  ttl = api.fetch('expiresInSeconds', DEFAULT_TTL_SECONDS)
@@ -39,12 +46,13 @@ module CalmLens
39
46
  raise Internal.invalid('externalId must be a non-empty string')
40
47
  end
41
48
 
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 }
49
+ expires_at = now + (ttl * 1000)
50
+ claims = { 'v' => 1, 'u' => preset_id, 'k' => api_key[0, api_key.start_with?('clsk_') ? 13 : 8],
51
+ 'e' => expires_at, 'n' => nonce }
45
52
  claims['x'] = external_id if external_id
46
- body = [JSON.generate(claims)].pack('m0').tr('+/', '-_').delete('=')
47
- secret = Digest::SHA256.hexdigest(@api_key)
53
+ # Written as JavaScript's JSON.stringify writes it: raw UTF-8, `/` and `<>&` unescaped.
54
+ body = [JSON.generate(claims, script_safe: false, ascii_only: false)].pack('m0').tr('+/', '-_').delete('=')
55
+ secret = Digest::SHA256.hexdigest(api_key)
48
56
  signature = OpenSSL::HMAC.hexdigest('SHA256', secret, SIGNING_CONTEXT + body)
49
57
  { 'token' => "#{TOKEN_PREFIX}#{body}.#{signature}", 'expiresAt' => expires_at }
50
58
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module CalmLens
4
4
  # The gem version. The gemspec and the User-Agent header read it from here.
5
- VERSION = '0.1.0'
5
+ VERSION = '0.17.1'
6
6
  end
@@ -14,23 +14,28 @@ module CalmLens
14
14
  # Deliveries whose timestamp is further than this from the receiver's clock are rejected.
15
15
  WEBHOOK_SIGNATURE_TOLERANCE_SECONDS = 300
16
16
 
17
- # A verified webhook delivery: `{id, type, createdAt, projectId, data}`, where `data` holds the
18
- # event's `severity`, `title`, `description`, `assetId`, `asset` and `facts`.
17
+ # A verified webhook delivery. The body has the shape of an {Events#list} item:
18
+ # `{id, projectId, type, severity, title, description, assetId, asset, facts, createdAt}`.
19
+ # Fields CalmLens adds later are kept in {#payload}, as are unknown asset and fact fields.
19
20
  #
20
21
  # @!attribute id [String] the event ID.
21
- # @!attribute type [String] such as `asset.approved`. New types may appear: ignore unknown ones.
22
- # @!attribute created_at [Integer] milliseconds since the epoch.
23
22
  # @!attribute project_id [String]
24
- # @!attribute data [Hash{String => Object}] the event fields, with the API's camelCase keys.
25
- WebhookEvent = Struct.new(:id, :type, :created_at, :project_id, :data, keyword_init: true) do
26
- # @return [String, nil]
27
- def asset_id = data['assetId']
28
-
29
- # @return [Array<Hash>] `{'key', 'value', 'url'?}` entries registered for the event type.
30
- def facts = data['facts'].is_a?(Array) ? data['facts'] : []
31
-
23
+ # @!attribute type [String] such as `asset.allowed`. New types may appear: ignore unknown ones.
24
+ # @!attribute severity [String, nil] `low` or `high`.
25
+ # @!attribute title [String, nil]
26
+ # @!attribute description [String, nil]
27
+ # @!attribute asset_id [String, nil]
28
+ # @!attribute asset [Hash, nil] the asset's status when the event was created.
29
+ # @!attribute facts [Array<Hash>] `{'key', 'value', 'url'?}` entries registered for the event type.
30
+ # @!attribute created_at [Integer] milliseconds since the epoch.
31
+ # @!attribute payload [Hash{String => Object}] the delivery body as received, with every field.
32
+ WebhookEvent = Struct.new(:id, :project_id, :type, :severity, :title, :description, :asset_id, :asset, :facts,
33
+ :created_at, :payload, keyword_init: true) do
32
34
  # @return [Boolean] true for a test send from the portal or CLI.
33
- def test? = facts.any? { |fact| fact.is_a?(Hash) && fact['key'] == 'test' && fact['value'] == true }
35
+ def test? = Webhooks.test_event?(self)
36
+
37
+ # @return [Hash{String => Object}] the delivery body as received, with every field.
38
+ def to_payload = payload
34
39
  end
35
40
 
36
41
  # Webhook signing and verification. Always verify the exact bytes received: read the raw body
@@ -50,37 +55,88 @@ module CalmLens
50
55
  "v1=#{OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp.to_s.b}.".b + raw)}"
51
56
  end
52
57
 
53
- # Checks a delivery's signature and freshness in constant time.
58
+ # The headers of one signed delivery of a body, as CalmLens sends them, to test a receiver.
59
+ # @param payload [String] the raw body
60
+ # @param delivery_id [String] the `x-calmlens-delivery` value
61
+ # @param secret [String]
62
+ # @param timestamp [Integer] Unix seconds
63
+ # @return [Hash{String => String}]
64
+ def headers(payload:, delivery_id:, secret:, timestamp: Time.now.to_i)
65
+ {
66
+ 'content-type' => 'application/json',
67
+ WEBHOOK_DELIVERY_HEADER => delivery_id.to_s,
68
+ WEBHOOK_TIMESTAMP_HEADER => timestamp.to_s,
69
+ WEBHOOK_SIGNATURE_HEADER => signature_header(payload: payload, timestamp: timestamp, secret: secret)
70
+ }
71
+ end
72
+
73
+ # Whether an event is a test send from the portal or the CLI rather than a real event: its
74
+ # `facts` hold the registered `{ key: "test", value: true }` fact.
75
+ # @param event [WebhookEvent, Hash] a parsed delivery, or a delivery body with string keys
76
+ # @return [Boolean]
77
+ def test_event?(event)
78
+ facts = event.is_a?(Hash) ? event['facts'] : event.facts
79
+ facts.is_a?(Array) && facts.any? { |fact| fact.is_a?(Hash) && fact['key'] == 'test' && fact['value'] == true }
80
+ end
81
+
82
+ # Converts an event from {Events#list} to the JSON body of its webhook delivery, which has the
83
+ # same fields: `description`, `assetId` and `asset` are nil when absent.
84
+ # @param event [Hash] an event with string keys, as {Events#list} returns it
85
+ # @return [Hash] `{ 'id', 'projectId', 'type', 'severity', 'title', 'description', 'assetId', 'asset', 'facts', 'createdAt' }`
86
+ def to_payload(event)
87
+ raise Internal.invalid('event must be a Hash') unless event.is_a?(Hash)
88
+
89
+ { 'id' => event['id'], 'projectId' => event['projectId'], 'type' => event['type'], 'severity' => event['severity'],
90
+ 'title' => event['title'], 'description' => event['description'], 'assetId' => event['assetId'],
91
+ 'asset' => event['asset'], 'facts' => event['facts'], 'createdAt' => event['createdAt'] }
92
+ end
93
+
94
+ # Checks a delivery's signature and freshness in constant time. Pass the `signature` and
95
+ # `timestamp` header values, or the request's `headers` to read them from.
54
96
  # @param payload [String] the raw request body, exactly as received
55
97
  # @param signature [String, nil] the `x-calmlens-signature` header
56
98
  # @param timestamp [String, nil] the `x-calmlens-timestamp` header
99
+ # @param headers [Hash, #to_hash, nil] request headers, as a Hash whose names may have any case
100
+ # and whose values are Strings or Arrays of repeated values (`Net::HTTPHeader#to_hash`), or a
101
+ # Rack env (`HTTP_X_CALMLENS_SIGNATURE`). Repeated values are joined with `,`.
57
102
  # @param secret [String] the webhook's signing secret
58
103
  # @param tolerance_seconds [Integer]
59
104
  # @param now [Integer] Unix seconds
60
105
  # @return [Boolean]
61
106
  # @raise [InvalidArgumentError] when payload is not a String, such as an already parsed Hash.
62
- def verify(payload:, signature:, timestamp:, secret:, tolerance_seconds: WEBHOOK_SIGNATURE_TOLERANCE_SECONDS,
63
- now: Time.now.to_i)
107
+ def verify(payload:, secret:, signature: nil, timestamp: nil, headers: nil,
108
+ tolerance_seconds: WEBHOOK_SIGNATURE_TOLERANCE_SECONDS, now: Time.now.to_i)
64
109
  raw = raw_body(payload)
110
+ signature = header(headers, WEBHOOK_SIGNATURE_HEADER) if signature.nil?
111
+ timestamp = header(headers, WEBHOOK_TIMESTAMP_HEADER) if timestamp.nil?
65
112
  stamp = timestamp.to_s.strip
66
113
  return false if !secret.is_a?(String) || secret.empty? || signature.to_s.empty? || !stamp.match?(/\A[0-9]+\z/)
67
- return false if (now.floor - stamp.to_i).abs > tolerance_seconds
114
+ return false if (now.floor - Integer(stamp, 10)).abs > tolerance_seconds
115
+
116
+ candidates = signature.to_s.split(',').map(&:strip).select { |entry| entry.start_with?('v1=') }
117
+ return false if candidates.empty?
68
118
 
69
- expected = signature_header(payload: raw, timestamp: stamp, secret: secret).delete_prefix('v1=')
70
- signature.to_s.split(',').map(&:strip).select { |entry| entry.start_with?('v1=') }.any? do |entry|
71
- candidate = entry.delete_prefix('v1=').downcase
72
- candidate.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(candidate, expected)
119
+ expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{stamp}.".b + raw)
120
+ # Every candidate is compared, so the time taken does not depend on which one matches.
121
+ candidates.map { |entry| entry.delete_prefix('v1=').downcase }.reduce(false) do |matched, candidate|
122
+ (candidate.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(candidate, expected)) || matched
73
123
  end
74
124
  end
75
125
 
76
- # Verifies a delivery, then parses it.
77
- # @param (see #verify)
126
+ # Verifies a delivery from its request headers. See {verify}.
127
+ # @return [Boolean]
128
+ def verify_request(payload:, headers:, secret:, tolerance_seconds: WEBHOOK_SIGNATURE_TOLERANCE_SECONDS,
129
+ now: Time.now.to_i)
130
+ verify(payload: payload, headers: headers, secret: secret, tolerance_seconds: tolerance_seconds, now: now)
131
+ end
132
+
133
+ # Verifies a delivery, then parses it. Takes the options of {verify}.
78
134
  # @return [WebhookEvent]
79
135
  # @raise [WebhookVerificationError] `sdk/invalid-webhook-signature` when verification fails, or
80
136
  # `sdk/invalid-webhook-payload` when the verified body is not an event.
81
- def parse_event(payload:, signature:, timestamp:, secret:,
137
+ def parse_event(payload:, secret:, signature: nil, timestamp: nil, headers: nil,
82
138
  tolerance_seconds: WEBHOOK_SIGNATURE_TOLERANCE_SECONDS, now: Time.now.to_i)
83
- unless verify(payload: payload, signature: signature, timestamp: timestamp, secret: secret,
139
+ unless verify(payload: payload, signature: signature, timestamp: timestamp, headers: headers, secret: secret,
84
140
  tolerance_seconds: tolerance_seconds, now: now)
85
141
  raise WebhookVerificationError.new(SdkErrorCodes::INVALID_WEBHOOK_SIGNATURE,
86
142
  'Webhook signature or timestamp did not verify')
@@ -91,32 +147,71 @@ module CalmLens
91
147
 
92
148
  def decode_event(payload)
93
149
  body = JSON.parse(payload.dup.force_encoding(Encoding::UTF_8))
94
- unless body.is_a?(Hash) && body['id'].is_a?(String) && body['type'].is_a?(String) && body['createdAt'].is_a?(Integer) &&
95
- body['projectId'].is_a?(String) && body['data'].is_a?(Hash)
96
- raise WebhookVerificationError.new(SdkErrorCodes::INVALID_WEBHOOK_PAYLOAD, 'Webhook body is not a CalmLens event')
97
- end
150
+ raise WebhookVerificationError.new(SdkErrorCodes::INVALID_WEBHOOK_PAYLOAD, 'Webhook body is not a CalmLens event') unless event?(body)
98
151
 
99
- WebhookEvent.new(id: body['id'], type: body['type'], created_at: body['createdAt'],
100
- project_id: body['projectId'], data: body['data'])
152
+ WebhookEvent.new(id: body['id'], project_id: body['projectId'], type: body['type'], severity: body['severity'],
153
+ title: body['title'], description: body['description'], asset_id: body['assetId'],
154
+ asset: body['asset'], facts: body.fetch('facts', []), created_at: body['createdAt'], payload: body)
101
155
  rescue JSON::ParserError, EncodingError => e
102
156
  raise WebhookVerificationError.new(SdkErrorCodes::INVALID_WEBHOOK_PAYLOAD, 'Webhook body is not JSON'), cause: e
103
157
  end
104
158
 
159
+ # The API's rules for a received body: the fields CalmLens always sends, and the types of the
160
+ # other known fields when present. Unknown fields are accepted.
161
+ def event?(body)
162
+ return false unless body.is_a?(Hash) && body['id'].is_a?(String) && body['type'].is_a?(String) &&
163
+ body['projectId'].is_a?(String) && body['createdAt'].is_a?(Numeric)
164
+
165
+ optional = ->(key, *types) { !body.key?(key) || types.any? { |type| type.nil? ? body[key].nil? : body[key].is_a?(type) } }
166
+ asset = body['asset']
167
+ optional.call('severity', String) && optional.call('title', String) && optional.call('description', String, nil) &&
168
+ optional.call('assetId', String, nil) && (asset.nil? || (asset.is_a?(Hash) && asset['id'].is_a?(String))) &&
169
+ (!body.key?('facts') || (body['facts'].is_a?(Array) && body['facts'].all? { |fact| fact?(fact) }))
170
+ end
171
+
172
+ def fact?(fact)
173
+ fact.is_a?(Hash) && fact['key'].is_a?(String) && fact.key?('value') && (!fact.key?('url') || fact['url'].is_a?(String))
174
+ end
175
+
176
+ # A header's value, found case-insensitively; repeated values are joined with `,`.
177
+ def header(headers, name)
178
+ return nil if headers.nil?
179
+
180
+ pairs = headers.respond_to?(:to_hash) ? headers.to_hash : headers.to_h
181
+ rack = "HTTP_#{name.upcase.tr('-', '_')}"
182
+ found = pairs.select { |key, _| key.to_s.casecmp?(name) || key.to_s == rack }.values
183
+ return nil if found.empty?
184
+
185
+ found.flat_map { |value| Array(value) }.join(',')
186
+ end
187
+
105
188
  def raw_body(payload)
106
189
  return payload.b if payload.is_a?(String)
107
190
 
108
191
  raise Internal.invalid("Webhook payload must be the raw body String, not a #{payload.class}. " \
109
192
  'Read request.raw_post (Rails) or request.body.read (Rack, Sinatra) before parsing.')
110
193
  end
111
- private_class_method :decode_event, :raw_body
194
+ private_class_method :decode_event, :event?, :fact?, :header, :raw_body
112
195
  end
113
196
 
114
197
  # @see Webhooks.signature_header
115
198
  def self.create_webhook_signature_header(...) = Webhooks.signature_header(...)
116
199
 
200
+ # @see Webhooks.headers
201
+ def self.create_webhook_headers(...) = Webhooks.headers(...)
202
+
203
+ # @see Webhooks.to_payload
204
+ def self.to_webhook_event_payload(...) = Webhooks.to_payload(...)
205
+
117
206
  # @see Webhooks.verify
118
207
  def self.verify_webhook_signature(...) = Webhooks.verify(...)
119
208
 
209
+ # @see Webhooks.verify_request
210
+ def self.verify_webhook_request(...) = Webhooks.verify_request(...)
211
+
120
212
  # @see Webhooks.parse_event
121
213
  def self.parse_webhook_event(...) = Webhooks.parse_event(...)
214
+
215
+ # @see Webhooks.test_event?
216
+ def self.test_webhook_event?(...) = Webhooks.test_event?(...)
122
217
  end
data/lib/calmlens.rb CHANGED
@@ -15,8 +15,10 @@ require_relative 'calmlens/transport'
15
15
  require_relative 'calmlens/upload_body'
16
16
  require_relative 'calmlens/session'
17
17
  require_relative 'calmlens/resource'
18
+ require_relative 'calmlens/moderation_context'
18
19
  require_relative 'calmlens/assets'
19
20
  require_relative 'calmlens/folders'
21
+ require_relative 'calmlens/hash_lists'
20
22
  require_relative 'calmlens/text'
21
23
  require_relative 'calmlens/upload_tokens'
22
24
  require_relative 'calmlens/events'