calmlens 0.1.1 → 0.18.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.
- checksums.yaml +4 -4
- data/README.md +133 -58
- data/lib/calmlens/assets.rb +169 -50
- data/lib/calmlens/cdn.rb +29 -7
- data/lib/calmlens/error_codes.rb +82 -4
- data/lib/calmlens/errors.rb +26 -17
- data/lib/calmlens/events.rb +15 -12
- data/lib/calmlens/folders.rb +13 -6
- data/lib/calmlens/hash_lists.rb +95 -0
- data/lib/calmlens/internal.rb +13 -7
- data/lib/calmlens/moderation_context.rb +55 -0
- data/lib/calmlens/resource.rb +23 -6
- data/lib/calmlens/server_client.rb +36 -2
- data/lib/calmlens/session.rb +170 -68
- data/lib/calmlens/testing/classification.rb +21 -0
- data/lib/calmlens/testing/fake_api.rb +946 -233
- data/lib/calmlens/testing/mock_decision.rb +158 -0
- data/lib/calmlens/testing/placeholder.rb +111 -0
- data/lib/calmlens/testing.rb +282 -39
- data/lib/calmlens/text.rb +7 -5
- data/lib/calmlens/transforms.rb +37 -18
- data/lib/calmlens/upload_body.rb +20 -9
- data/lib/calmlens/upload_tokens.rb +14 -6
- data/lib/calmlens/version.rb +1 -1
- data/lib/calmlens/webhooks.rb +127 -32
- data/lib/calmlens.rb +2 -0
- data/sig/calmlens.rbs +168 -39
- data/sig/error_codes.rbs +41 -2
- data/sig/qualifiers.rbs +141 -0
- data/sig/types.rbs +340 -162
- metadata +8 -2
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
|
-
# @
|
|
16
|
-
#
|
|
17
|
-
|
|
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
|
|
23
|
+
raise Internal.invalid('Text to classify must be a non-empty string', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
|
|
22
24
|
end
|
|
23
25
|
|
|
24
|
-
@assets.
|
|
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
|
data/lib/calmlens/transforms.rb
CHANGED
|
@@ -6,7 +6,13 @@ module CalmLens
|
|
|
6
6
|
module Transforms
|
|
7
7
|
module_function
|
|
8
8
|
|
|
9
|
-
|
|
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
|
|
18
|
-
#
|
|
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
|
|
26
|
-
text.b.gsub(%r{[^A-Za-z0-9\-_.!~*'()
|
|
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
|
|
30
|
-
# Option keys are sorted so equal transforms share a URL
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
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
|
|
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}
|
|
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
|
|
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 << "#{
|
|
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.
|
data/lib/calmlens/upload_body.rb
CHANGED
|
@@ -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
|
-
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
43
|
-
claims = { 'v' => 1, 'u' => preset_id, 'k' =>
|
|
44
|
-
'e' => expires_at, 'n' =>
|
|
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
|
-
|
|
47
|
-
|
|
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
|
data/lib/calmlens/version.rb
CHANGED
data/lib/calmlens/webhooks.rb
CHANGED
|
@@ -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
|
|
18
|
-
#
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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? =
|
|
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
|
-
#
|
|
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:,
|
|
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
|
|
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 =
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
77
|
-
# @
|
|
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:,
|
|
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
|
-
|
|
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'],
|
|
100
|
-
|
|
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'
|