calmlens 0.1.1 → 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.
- 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
|
@@ -1,71 +1,133 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'digest'
|
|
3
4
|
require 'json'
|
|
4
5
|
require 'securerandom'
|
|
5
6
|
require 'uri'
|
|
7
|
+
require_relative 'placeholder'
|
|
8
|
+
require_relative 'mock_decision'
|
|
6
9
|
|
|
7
10
|
module CalmLens
|
|
8
11
|
module Testing
|
|
9
12
|
# Raised inside the fake API to answer with a CalmLens error response.
|
|
10
13
|
# @api private
|
|
11
14
|
class Failure < StandardError
|
|
12
|
-
attr_reader :code
|
|
15
|
+
attr_reader :code, :details, :status
|
|
13
16
|
|
|
14
|
-
def initialize(code, message = nil)
|
|
17
|
+
def initialize(code, message = nil, details: nil, status: nil)
|
|
15
18
|
super(message || code)
|
|
16
19
|
@code = code
|
|
20
|
+
@details = details
|
|
21
|
+
@status = status
|
|
17
22
|
end
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
# In-memory records behind {MockServerClient}. Change them to arrange a test, such as setting an
|
|
21
|
-
# asset's `moderation.status`.
|
|
26
|
+
# asset's `classification.moderation.status`.
|
|
22
27
|
#
|
|
23
28
|
# @!attribute assets [Hash{String => Hash}] assets by ID, with API field names.
|
|
24
29
|
# @!attribute folders [Hash{String => Hash}] folders by ID.
|
|
25
30
|
# @!attribute content [Hash{String => Hash}] stored bytes by asset ID: `{'type', 'parts' => {n => bytes}}`.
|
|
26
31
|
# @!attribute events [Array<Hash>] the events feed, oldest first.
|
|
27
32
|
# @!attribute lenses [Hash{String => Hash}] lenses by identifier.
|
|
28
|
-
|
|
33
|
+
# @!attribute hash_lists [Hash{String => Hash}] hash lists by ID.
|
|
34
|
+
# @!attribute hash_list_entries [Hash{String => Hash}] hash list entries by ID.
|
|
35
|
+
State = Struct.new(:assets, :folders, :content, :events, :lenses, :uploads, :idempotency, :hash_lists,
|
|
36
|
+
:hash_list_entries, keyword_init: true)
|
|
29
37
|
|
|
30
38
|
# A stateful stand-in for the CalmLens API and CDN. It stores assets and folders, applies
|
|
31
|
-
# filters and ordering, serves stored bytes, and answers errors with the API's codes, statuses
|
|
32
|
-
# and body shape
|
|
39
|
+
# filters and ordering, serves stored bytes, and answers errors with the API's codes, statuses,
|
|
40
|
+
# details and body shape, checking requests in the API's order. It moderates through the
|
|
41
|
+
# `moderate` callback rather than classifying content, and does not run transforms or index for
|
|
42
|
+
# semantic search.
|
|
33
43
|
# @api private
|
|
34
44
|
class FakeAPI
|
|
35
|
-
JSON_QUERY = %w[queue isFinal sizeInBytes totalSizeInBytes partNumber pageSize limit
|
|
36
|
-
|
|
37
|
-
#
|
|
45
|
+
JSON_QUERY = %w[queue isFinal sizeInBytes totalSizeInBytes partNumber pageSize limit processingOptions metadata
|
|
46
|
+
context].freeze
|
|
47
|
+
# Keyword search fields, in the order matches are reported, and where each value sits in the asset.
|
|
38
48
|
SEARCH_FIELDS = { 'name' => %w[name], 'id' => %w[id], 'externalId' => %w[externalId],
|
|
39
49
|
'description' => %w[description], 'tags' => %w[tags], 'type' => %w[type],
|
|
40
|
-
'
|
|
50
|
+
'content.format' => %w[content format], 'content.extension' => %w[content extension],
|
|
51
|
+
'classification.moderation.status' => %w[classification moderation status] }.freeze
|
|
52
|
+
MAX_SEARCH_TOKENS = 8
|
|
53
|
+
MAX_SEARCH_LIMIT = 20
|
|
54
|
+
MAX_SEARCH_QUERY_LENGTH = 200
|
|
55
|
+
# Every API cursor list returns this many items unless `pageSize` says otherwise.
|
|
56
|
+
DEFAULT_PAGE_SIZE = 50
|
|
57
|
+
MAX_PAGE_SIZE = 100
|
|
58
|
+
REVIEW_DECISIONS = %w[allowed limited age-restricted blocked purged].freeze
|
|
59
|
+
MAX_DECISION_NOTE_LENGTH = 1499
|
|
60
|
+
RESOURCES = %w[original rendered placeholder].freeze
|
|
61
|
+
MIN_PART = 5 * 1024 * 1024
|
|
41
62
|
MIN_MULTIPART_IMAGE = 10 * 1024 * 1024
|
|
42
63
|
MAX_MULTIPART_IMAGE = 20 * 1024 * 1024
|
|
64
|
+
# The ThumbHash the mock gives every classified image, standing in for one computed from its
|
|
65
|
+
# pixels. The `placeholder` resource serves it as a PNG, like the CDN.
|
|
66
|
+
THUMBHASH = '1QcSHQRnh493V4dIh4eXh1h4kJUI'
|
|
67
|
+
# Like the API: 1 to 255 printable ASCII characters, without a space at either end.
|
|
68
|
+
IDEMPOTENCY_KEY = /\A[\x21-\x7e](?:[\x20-\x7e]{0,253}[\x21-\x7e])?\z/
|
|
69
|
+
# Content a browser could run as a document, which the CDN serves as a download.
|
|
70
|
+
ACTIVE_TYPES = %w[image/svg+xml application/xhtml+xml text/html application/xml text/xml].freeze
|
|
43
71
|
|
|
44
72
|
attr_reader :state
|
|
73
|
+
# The mock API key's `{ 'actions', 'folderId', 'includeSubfolders' }` grants.
|
|
74
|
+
attr_accessor :grants
|
|
45
75
|
|
|
46
|
-
def initialize(project_id:, base_url:, cdn_base_url:, now:, create_id:,
|
|
76
|
+
def initialize(project_id:, base_url:, cdn_base_url:, now:, create_id:, moderate:, grants:)
|
|
47
77
|
@project_id = project_id
|
|
48
78
|
@base_url = base_url
|
|
49
79
|
@cdn_base_url = cdn_base_url
|
|
50
80
|
@now = now
|
|
51
81
|
@create_id = create_id
|
|
52
|
-
@
|
|
53
|
-
@
|
|
54
|
-
@state = State.new(assets: {}, folders: {}, content: {}, events: [], lenses: {}, uploads: {}, idempotency: {}
|
|
55
|
-
|
|
82
|
+
@moderate = moderate
|
|
83
|
+
@grants = grants
|
|
84
|
+
@state = State.new(assets: {}, folders: {}, content: {}, events: [], lenses: {}, uploads: {}, idempotency: {},
|
|
85
|
+
hash_lists: {}, hash_list_entries: {})
|
|
86
|
+
@request_count = 0
|
|
56
87
|
end
|
|
57
88
|
|
|
58
89
|
# Adds an event to the feed, filling the fields the API always sets.
|
|
59
90
|
def add_event(event)
|
|
60
|
-
@event_sequence += 1
|
|
61
91
|
time = @now.call
|
|
62
|
-
record = { 'id' => @create_id.call, 'projectId' => @project_id, 'severity' => 'low', '
|
|
63
|
-
'
|
|
92
|
+
record = { 'id' => @create_id.call('evt'), 'projectId' => @project_id, 'severity' => 'low', 'description' => nil,
|
|
93
|
+
'assetId' => nil, 'asset' => nil, 'facts' => [], 'createdAt' => time }.merge(event)
|
|
64
94
|
record['title'] ||= record['type']
|
|
65
|
-
@state.events << record
|
|
95
|
+
@state.events << record
|
|
66
96
|
record
|
|
67
97
|
end
|
|
68
98
|
|
|
99
|
+
# Adds an event like {#add_event}; one about a stored asset carries the asset as it is now.
|
|
100
|
+
def enqueue_event(event)
|
|
101
|
+
item = own_asset(event['assetId'])
|
|
102
|
+
event = event.merge('asset' => snapshot(item)) if item && !event.key?('asset')
|
|
103
|
+
add_event(event)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Applies a reviewer's decision, adds the matching events and returns the asset, or nil when
|
|
107
|
+
# there is no such asset. A purge removes the content with reason `requested`.
|
|
108
|
+
def decide(asset_id, decision)
|
|
109
|
+
unless REVIEW_DECISIONS.include?(decision)
|
|
110
|
+
raise InvalidArgumentError.new(SdkErrorCodes::INVALID_ARGUMENT,
|
|
111
|
+
"Review decisions are #{REVIEW_DECISIONS.join(', ')} (got #{decision.inspect})")
|
|
112
|
+
end
|
|
113
|
+
item = own_asset(asset_id)
|
|
114
|
+
return unless item
|
|
115
|
+
|
|
116
|
+
record_decision(item, decision, nil, nil)
|
|
117
|
+
deep_copy(item)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Finishes processing an asset as the pipeline would, with `decision` or else what `moderate`
|
|
121
|
+
# answers for the `complete` trigger, and returns it; nil when there is no such asset.
|
|
122
|
+
def complete_processing(asset_id, decision)
|
|
123
|
+
item = own_asset(asset_id)
|
|
124
|
+
return unless item
|
|
125
|
+
raise Internal.invalid("Asset #{asset_id} isn't processing") unless item['classification']['status'] == 'processing'
|
|
126
|
+
|
|
127
|
+
moderate_stored(item, 'complete', decision.nil? ? nil : MockDecision.from(decision))
|
|
128
|
+
deep_copy(item)
|
|
129
|
+
end
|
|
130
|
+
|
|
69
131
|
# @return [Response]
|
|
70
132
|
def handle(method, url, headers, body)
|
|
71
133
|
if url.start_with?("#{@cdn_base_url}/")
|
|
@@ -74,35 +136,125 @@ module CalmLens
|
|
|
74
136
|
api(method, url.delete_prefix(@base_url), headers, body)
|
|
75
137
|
end
|
|
76
138
|
rescue Failure => e
|
|
77
|
-
error(e.code, e.message)
|
|
139
|
+
error(e.code, e.message, details: e.details, status: e.status)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# An API error response, as the API sends it: `{ code, message, requestId, details }` with the
|
|
143
|
+
# code's status, `requestId` matching the `x-request-id` header and `details` always an object.
|
|
144
|
+
# `retry_after` is whole seconds for the `Retry-After` header.
|
|
145
|
+
def error(code, message = nil, details: nil, status: nil, retry_after: nil)
|
|
146
|
+
id = request_id
|
|
147
|
+
body = { 'code' => code, 'message' => message || code, 'requestId' => id, 'details' => details || {} }
|
|
148
|
+
headers = retry_after.nil? ? {} : { 'retry-after' => retry_after.to_s }
|
|
149
|
+
respond(status || ErrorCodes::STATUSES.fetch(code, 400), body, headers, id: id)
|
|
78
150
|
end
|
|
79
151
|
|
|
80
152
|
private
|
|
81
153
|
|
|
82
|
-
def respond(status, data, headers = {})
|
|
154
|
+
def respond(status, data, headers = {}, id: request_id)
|
|
83
155
|
Response.new(status: status, body: data.nil? ? '' : JSON.generate(data),
|
|
84
|
-
headers: { 'content-type' => 'application/json', 'x-request-id' =>
|
|
156
|
+
headers: { 'content-type' => 'application/json', 'x-request-id' => id }.merge(headers))
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def request_id = "mock-request-#{@request_count += 1}"
|
|
160
|
+
|
|
161
|
+
def own_asset(asset_id)
|
|
162
|
+
item = @state.assets[asset_id]
|
|
163
|
+
item if item && item['projectId'] == @project_id
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Applies a reviewer's decision like `decideAssetManually` in the API: the status and the review,
|
|
167
|
+
# then (for `purged`) the content removal and its event, then `asset.<decision>`.
|
|
168
|
+
def record_decision(item, decision, note, reviewer_name)
|
|
169
|
+
apply_outcome(item, decision, 'requested')
|
|
170
|
+
review = { 'id' => @create_id.call('rev'), 'decision' => decision, 'note' => note,
|
|
171
|
+
'reviewedAt' => @now.call, 'appliedToAsset' => true }
|
|
172
|
+
reviewer_facts = []
|
|
173
|
+
if reviewer_name
|
|
174
|
+
review.merge!('reviewerName' => reviewer_name, 'reviewerEmail' => nil)
|
|
175
|
+
reviewer_facts = [{ 'key' => 'reviewer-user-id', 'value' => nil }, { 'key' => 'reviewer-name', 'value' => reviewer_name }]
|
|
176
|
+
end
|
|
177
|
+
item['classification']['moderation']['manualReviews'] << review
|
|
178
|
+
lifecycle_event('asset.content-removed', item) if decision == 'purged'
|
|
179
|
+
# Like the event catalog: only allowing and limiting are low severity.
|
|
180
|
+
add_event('type' => "asset.#{decision}", 'severity' => %w[allowed limited].include?(decision) ? 'low' : 'high',
|
|
181
|
+
'title' => "asset.#{decision}", 'assetId' => item['id'], 'asset' => snapshot(item),
|
|
182
|
+
'facts' => [{ 'key' => 'decided-by', 'value' => 'reviewer' }, { 'key' => 'asset-type', 'value' => item['type'] },
|
|
183
|
+
{ 'key' => 'asset-name', 'value' => item['name'], 'url' => "https://calmlens.com/go/asset/#{item['id']}" },
|
|
184
|
+
*reviewer_facts, { 'key' => 'note', 'value' => note }])
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# `POST /assets/:id/decision` as the API validates and applies it. The mock's key is unnamed, so the
|
|
188
|
+
# review and its event name the reviewer `An API key`. An asset still uploading or processing is
|
|
189
|
+
# refused with `asset/processing`.
|
|
190
|
+
def decide_request(item, json)
|
|
191
|
+
decision = json['decision']
|
|
192
|
+
invalid!('validation/invalid-union', 'decision') unless REVIEW_DECISIONS.include?(decision)
|
|
193
|
+
note = json['note']
|
|
194
|
+
invalid!('validation/invalid-type', 'note') unless note.is_a?(String)
|
|
195
|
+
note = note.strip
|
|
196
|
+
invalid!('validation/too-small', 'note') if note.empty?
|
|
197
|
+
invalid!('validation/too-big', 'note') if note.length > MAX_DECISION_NOTE_LENGTH
|
|
198
|
+
invalid!('validation/invalid-value', 'confirmPurge') if decision == 'purged' && json['confirmPurge'] != true
|
|
199
|
+
processing = item['upload']['status'] == 'uploading' || item['classification']['status'] == 'processing'
|
|
200
|
+
fail!(ErrorCodes::ASSET_PROCESSING) if processing
|
|
201
|
+
status = item['classification']['moderation']['status']
|
|
202
|
+
fail!(ErrorCodes::ASSET_DECISION_NOT_ALLOWED) if status == 'pending' && !%w[blocked purged].include?(decision)
|
|
203
|
+
fail!(ErrorCodes::ASSET_DECISION_UNCHANGED) if status == decision
|
|
204
|
+
fail!(ErrorCodes::ASSET_CONTENT_PURGED) if status == 'purged' && decision != 'blocked'
|
|
205
|
+
|
|
206
|
+
record_decision(item, decision, note, 'An API key')
|
|
207
|
+
item
|
|
85
208
|
end
|
|
86
209
|
|
|
87
|
-
|
|
210
|
+
# The asset summary events carry.
|
|
211
|
+
def snapshot(item)
|
|
212
|
+
{ 'id' => item['id'], 'externalId' => item['externalId'], 'uploadPresetId' => item['uploadPresetId'],
|
|
213
|
+
'upload' => { 'status' => item['upload']['status'] },
|
|
214
|
+
'classification' => { 'status' => item['classification']['status'],
|
|
215
|
+
'moderation' => { 'status' => item['classification']['moderation']['status'] } },
|
|
216
|
+
'content' => { 'status' => item['content']['status'] } }
|
|
217
|
+
end
|
|
88
218
|
|
|
89
|
-
|
|
90
|
-
|
|
219
|
+
# The `asset.deleted`, `asset.content-removed` and `asset.retried` events the API records.
|
|
220
|
+
def lifecycle_event(type, item)
|
|
221
|
+
name = { 'key' => 'asset-name', 'value' => item['name'] }
|
|
222
|
+
# A deleted asset has no portal page to link to.
|
|
223
|
+
name['url'] = "https://calmlens.com/go/asset/#{item['id']}" unless type == 'asset.deleted'
|
|
224
|
+
add_event('type' => type, 'severity' => 'low', 'assetId' => item['id'], 'asset' => snapshot(item),
|
|
225
|
+
'facts' => [name, { 'key' => 'asset-type', 'value' => item['type'] }])
|
|
91
226
|
end
|
|
92
227
|
|
|
93
|
-
def fail!(code, message = nil) = raise(Failure.new(code, message))
|
|
228
|
+
def fail!(code, message = nil, details: nil) = raise(Failure.new(code, message, details: details))
|
|
229
|
+
|
|
230
|
+
def route_not_found!(method, parts) = fail!(ErrorCodes::ROUTE_NOT_FOUND, "No route for #{method} /#{parts.join('/')}")
|
|
231
|
+
|
|
232
|
+
# A request the API's schema refuses: `validation/<issue>` with HTTP 400 and the field.
|
|
233
|
+
def invalid!(code, path) = raise(Failure.new(code, "Invalid #{path}", details: { 'path' => path }, status: 400))
|
|
234
|
+
|
|
235
|
+
# Like the API's query schema: an integer from 1 to `max`.
|
|
236
|
+
def check_range(value, path, max)
|
|
237
|
+
invalid!('validation/too-small', path) if !value.is_a?(Numeric) || value < 1
|
|
238
|
+
invalid!('validation/too-big', path) if value > max
|
|
239
|
+
invalid!('validation/invalid-type', path) unless value.is_a?(Integer)
|
|
240
|
+
value
|
|
241
|
+
end
|
|
94
242
|
|
|
95
243
|
def api(method, relative, headers, body)
|
|
96
244
|
uri = URI.parse(relative)
|
|
97
245
|
parts = uri.path.split('/').reject(&:empty?).map { |part| URI.decode_www_form_component(part) }
|
|
98
246
|
query = URI.decode_www_form(uri.query.to_s).to_h.to_h { |key, value| [key, query_value(key, value)] }
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
247
|
+
content_type = header(headers, 'Content-Type')
|
|
248
|
+
json = content_type == 'application/json' && body ? JSON.parse(body) : {}
|
|
249
|
+
# HTTP stacks trim header values, so the API sees the key without surrounding whitespace.
|
|
250
|
+
key = header(headers, 'Idempotency-Key')&.to_s&.gsub(/\A[ \t\r\n]+|[ \t\r\n]+\z/, '')
|
|
251
|
+
upload = header(headers, 'X-CalmLens-Upload')
|
|
102
252
|
return dispatch(method, parts, query, json, body, upload) unless key && method == 'POST'
|
|
103
253
|
|
|
104
|
-
|
|
105
|
-
|
|
254
|
+
fail!(ErrorCodes::INVALID_IDEMPOTENCY_KEY) unless key.match?(IDEMPOTENCY_KEY)
|
|
255
|
+
# Like the API: a successful response is saved and replayed for the same key and request;
|
|
256
|
+
# a failed one frees the key.
|
|
257
|
+
fingerprint = [method, relative, content_type, Digest::SHA256.hexdigest(body.to_s)]
|
|
106
258
|
saved = @state.idempotency[key]
|
|
107
259
|
if saved
|
|
108
260
|
fail!(ErrorCodes::IDEMPOTENCY_KEY_REUSED) unless saved[:fingerprint] == fingerprint
|
|
@@ -110,11 +262,15 @@ module CalmLens
|
|
|
110
262
|
headers: saved[:headers].merge('idempotent-replayed' => 'true', 'x-request-id' => request_id))
|
|
111
263
|
end
|
|
112
264
|
response = dispatch(method, parts, query, json, body, upload)
|
|
113
|
-
|
|
114
|
-
|
|
265
|
+
if response.success?
|
|
266
|
+
@state.idempotency[key] = { fingerprint: fingerprint, status: response.status, body: response.body,
|
|
267
|
+
headers: response.headers }
|
|
268
|
+
end
|
|
115
269
|
response
|
|
116
270
|
end
|
|
117
271
|
|
|
272
|
+
def header(headers, name) = headers.find { |key, _| key.to_s.casecmp?(name) }&.last
|
|
273
|
+
|
|
118
274
|
def query_value(key, value)
|
|
119
275
|
return value unless JSON_QUERY.include?(key)
|
|
120
276
|
|
|
@@ -128,7 +284,9 @@ module CalmLens
|
|
|
128
284
|
when 'projects' then project(method, parts, query, json, body, upload)
|
|
129
285
|
when 'assets' then asset(method, parts, query, json, body)
|
|
130
286
|
when 'folders' then folder(method, parts, json)
|
|
131
|
-
|
|
287
|
+
when 'hash-lists' then hash_list(method, parts, query, json)
|
|
288
|
+
when 'hash-list-entries' then remove_hash_list_entry(method, parts)
|
|
289
|
+
else route_not_found!(method, parts)
|
|
132
290
|
end
|
|
133
291
|
data.is_a?(Response) ? data : respond(200, deep_copy(data))
|
|
134
292
|
end
|
|
@@ -142,67 +300,188 @@ module CalmLens
|
|
|
142
300
|
case [method, parts[2..], upload]
|
|
143
301
|
in ['GET', ['events'], _] then events(query)
|
|
144
302
|
in ['PUT', ['lenses'], _] then lenses(json.fetch('lenses', []))
|
|
145
|
-
in ['GET', ['folders'], _] then
|
|
303
|
+
in ['GET', ['folders'], _] then folder_page(query)
|
|
146
304
|
in ['POST', ['folders'], _] then create_folder(json)
|
|
147
|
-
in ['GET', ['
|
|
305
|
+
in ['GET', ['hash-lists'], _] then page(project_hash_lists, query, JSON.generate([@project_id]))
|
|
306
|
+
in ['POST', ['hash-lists'], _] then create_hash_list(json)
|
|
307
|
+
in ['GET', ['assets'], _] then list(query)
|
|
148
308
|
in ['GET', ['assets', 'search'], _] then search(query)
|
|
309
|
+
in ['POST', ['assets', 'batch'], _] then batch(json)
|
|
149
310
|
in ['POST', ['assets'], nil] then create_asset(json, query['queue'] == true, url: json['url'])
|
|
150
311
|
in ['POST', ['assets'], 'content'] then create_asset(query, query['queue'] == true, data: body)
|
|
151
312
|
in ['POST', ['assets'], 'part'] then start_multipart(query, body)
|
|
152
|
-
else
|
|
313
|
+
else route_not_found!(method, parts)
|
|
153
314
|
end
|
|
154
315
|
end
|
|
155
316
|
|
|
156
317
|
def project_assets = @state.assets.values.select { |item| item['projectId'] == @project_id }
|
|
318
|
+
|
|
319
|
+
# An asset ID as the API's schema accepts it: `asset_` and 20 base62 characters.
|
|
320
|
+
ASSET_ID_PATTERN = /\Aasset_[0-9A-Za-z]{20}\z/
|
|
321
|
+
private_constant :ASSET_ID_PATTERN
|
|
322
|
+
|
|
323
|
+
# POST /projects/:id/assets/batch: like the API, every ID must be an asset ID
|
|
324
|
+
# (`validation/invalid-format` at `assetIds.<index>`) and every asset must exist
|
|
325
|
+
# (`asset/not-found`) before anything happens. The operation then runs at once, in request
|
|
326
|
+
# order, rather than in the background: `delete` deletes each asset with its stored child
|
|
327
|
+
# assets and records `asset.deleted` with the asset as it was; `remove-content` removes the
|
|
328
|
+
# content like DELETE /assets/:id/content. Mock assets carry no safety holds.
|
|
329
|
+
def batch(json)
|
|
330
|
+
ids = json['assetIds']
|
|
331
|
+
invalid!('validation/invalid-type', 'assetIds') unless ids.is_a?(Array)
|
|
332
|
+
invalid!('validation/too-small', 'assetIds') if ids.empty?
|
|
333
|
+
invalid!('validation/too-big', 'assetIds') if ids.size > 500
|
|
334
|
+
ids.each_with_index do |id, index|
|
|
335
|
+
invalid!('validation/invalid-format', "assetIds.#{index}") unless id.is_a?(String) && id.match?(ASSET_ID_PATTERN)
|
|
336
|
+
end
|
|
337
|
+
invalid!('validation/invalid-value', 'operation') unless %w[delete remove-content].include?(json['operation'])
|
|
338
|
+
items = ids.uniq.map do |id|
|
|
339
|
+
item = @state.assets[id]
|
|
340
|
+
fail!(ErrorCodes::ASSET_NOT_FOUND) unless item && item['projectId'] == @project_id
|
|
341
|
+
item
|
|
342
|
+
end
|
|
343
|
+
items.each do |item|
|
|
344
|
+
if json['operation'] == 'delete'
|
|
345
|
+
delete_tree(item)
|
|
346
|
+
lifecycle_event('asset.deleted', item)
|
|
347
|
+
else
|
|
348
|
+
delete_content(item)
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
respond(202, { 'queued' => true })
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Deletes an asset, its stored child assets and their content.
|
|
355
|
+
def delete_tree(item)
|
|
356
|
+
deleting = [item['id']]
|
|
357
|
+
loop do
|
|
358
|
+
added = project_assets.select { |other| deleting.include?(other['parentId']) && !deleting.include?(other['id']) }
|
|
359
|
+
break if added.empty?
|
|
360
|
+
|
|
361
|
+
deleting.concat(added.map { |other| other['id'] })
|
|
362
|
+
end
|
|
363
|
+
deleting.each do |id|
|
|
364
|
+
@state.assets.delete(id)
|
|
365
|
+
@state.content.delete(id)
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def list(query)
|
|
370
|
+
items = project_assets.reject { |item| item['parentId'] }
|
|
371
|
+
items = items.select { |item| item['folderId'] == query['folderId'] } if query.key?('folderId')
|
|
372
|
+
page(RSQL.filter(items, query['filter']), query, JSON.generate([@project_id, query['filter'], query['folderId']]))
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Like the API: one keyset page of a parent's child folders, optionally matching `search`, by
|
|
376
|
+
# `name` ascending unless `ordering` (one entry, `name` or `createdAt`) says otherwise, with an
|
|
377
|
+
# `id` tie-breaker, and the ancestors of the parent and the listed folders. Names compare by
|
|
378
|
+
# code point, so case matters.
|
|
379
|
+
def folder_page(query)
|
|
380
|
+
found = project_folders.select do |f|
|
|
381
|
+
f['parentId'] == query['parentId'] && (!query['search'] || f['name'].downcase.include?(query['search'].downcase))
|
|
382
|
+
end
|
|
383
|
+
invalid!('validation/too-big', 'ordering') if ordering(query, nil).size > 1
|
|
384
|
+
scope = JSON.generate([@project_id, query['parentId'], query['search']])
|
|
385
|
+
result = page(found, query, scope, fields: %w[name createdAt], default: { 'field' => 'name', 'direction' => 'asc' })
|
|
386
|
+
page = result['items']
|
|
387
|
+
ids = ([query['parentId']] + page.map { |f| f['id'] }).compact.uniq
|
|
388
|
+
queue = ids.dup
|
|
389
|
+
until queue.empty?
|
|
390
|
+
parent = @state.folders.dig(queue.pop, 'parentId')
|
|
391
|
+
if parent && !ids.include?(parent)
|
|
392
|
+
ids << parent
|
|
393
|
+
queue << parent
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
result.merge('ancestors' => project_folders.select { |f| ids.include?(f['id']) && !page.include?(f) })
|
|
397
|
+
end
|
|
398
|
+
|
|
157
399
|
def project_folders = @state.folders.values.select { |item| item['projectId'] == @project_id }
|
|
158
400
|
|
|
401
|
+
# Like the API: without a cursor, no events and a `nextCursor` for now; with one, up to `pageSize`
|
|
402
|
+
# events added since, oldest first. `nextCursor` is never nil.
|
|
159
403
|
def events(query)
|
|
160
|
-
|
|
161
|
-
|
|
404
|
+
size = check_range(query.fetch('pageSize', DEFAULT_PAGE_SIZE), 'pageSize', MAX_PAGE_SIZE)
|
|
405
|
+
feed = @state.events
|
|
406
|
+
return { 'items' => [], 'nextCursor' => "mock_#{feed.size}", 'hasMore' => false } unless query['cursor']
|
|
162
407
|
|
|
163
|
-
match = query['
|
|
164
|
-
|
|
408
|
+
match = query['cursor'].to_s.match(/\Amock_(\d+)\z/)
|
|
409
|
+
position = match && Integer(match[1], 10)
|
|
410
|
+
fail!(ErrorCodes::INVALID_CURSOR) unless position && position <= feed.size
|
|
165
411
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
taken = later.first(limit)
|
|
170
|
-
cursor = taken.empty? ? sequence : taken.last['_sequence']
|
|
171
|
-
{ 'events' => taken.map { |event| event.except('_sequence') }, 'cursor' => "mock_#{cursor}",
|
|
172
|
-
'hasMore' => later.size > taken.size }
|
|
412
|
+
taken = feed[position, size]
|
|
413
|
+
following = position + taken.size
|
|
414
|
+
{ 'items' => taken, 'nextCursor' => "mock_#{following}", 'hasMore' => following < feed.size }
|
|
173
415
|
end
|
|
174
416
|
|
|
417
|
+
# Saves lenses in order, like the API: an unchanged definition returns the stored lens, a
|
|
418
|
+
# change needs a greater version, and lenses before a conflict stay saved.
|
|
175
419
|
def lenses(definitions)
|
|
420
|
+
{ 'lenses' => definitions.map { |definition| save_lens(definition) } }
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def save_lens(definition)
|
|
176
424
|
now = @now.call
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
425
|
+
old = @state.lenses[definition['identifier']]
|
|
426
|
+
unless old
|
|
427
|
+
lens = { 'id' => @create_id.call('lens'), 'projectId' => @project_id }.merge(definition)
|
|
428
|
+
return @state.lenses[definition['identifier']] = lens.merge('createdAt' => now, 'updatedAt' => now)
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
same = %w[name type active].all? { |key| old[key] == definition[key] } &&
|
|
432
|
+
old['description'] == definition['description'] && canonical(old['transforms']) == canonical(definition['transforms'])
|
|
433
|
+
return old if same && old['version'] == definition['version']
|
|
434
|
+
if definition['version'].to_i > old['version'].to_i
|
|
435
|
+
return @state.lenses[old['identifier']] =
|
|
436
|
+
old.merge(definition).merge('updatedAt' => now)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
fail!(ErrorCodes::LENS_VERSION_CONFLICT, details: { 'currentVersion' => old['version'], 'minimumVersion' => old['version'] + 1,
|
|
440
|
+
'requestedVersion' => definition['version'] })
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def canonical(value)
|
|
444
|
+
case value
|
|
445
|
+
when Hash then value.sort_by { |key, _| key }.to_h { |key, item| [key, canonical(item)] }
|
|
446
|
+
when Array then value.map { |item| canonical(item) }
|
|
447
|
+
else value
|
|
190
448
|
end
|
|
191
|
-
@state.lenses = pending
|
|
192
|
-
{ 'lenses' => saved }
|
|
193
449
|
end
|
|
194
450
|
|
|
451
|
+
# Keyword search like the API: every whitespace-separated token (case-insensitive, at most 8
|
|
452
|
+
# distinct) must appear in some field. Results rank by the API's score, then newest first.
|
|
195
453
|
def search(query)
|
|
196
|
-
text = query['query'].to_s.strip
|
|
197
|
-
|
|
198
|
-
|
|
454
|
+
text = query['query'].to_s.strip
|
|
455
|
+
invalid!('validation/too-small', 'query') if text.empty?
|
|
456
|
+
invalid!('validation/too-big', 'query') if text.length > MAX_SEARCH_QUERY_LENGTH
|
|
457
|
+
limit = check_range(query.fetch('limit', 10), 'limit', MAX_SEARCH_LIMIT)
|
|
458
|
+
normalized = text.downcase
|
|
459
|
+
tokens = normalized.split(/\s+/).uniq.first(MAX_SEARCH_TOKENS)
|
|
199
460
|
items = project_assets.filter_map do |item|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
461
|
+
values = SEARCH_FIELDS.transform_values { |path| Array(item.dig(*path)).join(' ').downcase }
|
|
462
|
+
next unless tokens.all? { |token| values.each_value.any? { |value| value.include?(token) } }
|
|
463
|
+
|
|
464
|
+
fields = values.filter_map { |field, value| field if tokens.any? { |token| value.include?(token) } }
|
|
465
|
+
{ 'asset' => item, 'score' => search_score(values, normalized), 'matchedFields' => fields }
|
|
466
|
+
end
|
|
467
|
+
items = items.each_with_index.sort_by { |result, index| [-result['score'], -result['asset']['createdAt'].to_i, index] }
|
|
468
|
+
.map(&:first)
|
|
469
|
+
{ 'query' => text, 'items' => items.first(limit), 'semanticStatus' => 'disabled' }
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# The API's keyword score for the whole normalized query.
|
|
473
|
+
def search_score(values, query)
|
|
474
|
+
id, external_id, name = values.values_at('id', 'externalId', 'name')
|
|
475
|
+
if id == query then 1000
|
|
476
|
+
elsif external_id == query then 950
|
|
477
|
+
elsif name == query then 900
|
|
478
|
+
elsif id.start_with?(query) then 800
|
|
479
|
+
elsif external_id.start_with?(query) then 750
|
|
480
|
+
elsif name.start_with?(query) then 700
|
|
481
|
+
elsif name.include?(query) then 600
|
|
482
|
+
elsif values['tags'].include?(query) then 500
|
|
483
|
+
else 100
|
|
204
484
|
end
|
|
205
|
-
{ 'query' => query['query'], 'items' => items.first(query.fetch('limit', 10)), 'semanticStatus' => 'disabled' }
|
|
206
485
|
end
|
|
207
486
|
|
|
208
487
|
def kind(mime, url)
|
|
@@ -215,101 +494,228 @@ module CalmLens
|
|
|
215
494
|
end
|
|
216
495
|
|
|
217
496
|
def create_asset(options, queue, url: nil, data: nil, open: false)
|
|
497
|
+
check_folder(options['folderId']) if options['folderId']
|
|
218
498
|
if options['externalId'] && project_assets.any? { |item| item['externalId'] == options['externalId'] }
|
|
219
499
|
fail!(ErrorCodes::DUPLICATE_EXTERNAL_ID)
|
|
220
500
|
end
|
|
221
|
-
check_folder(options['folderId']) if options['folderId']
|
|
222
501
|
now = @now.call
|
|
223
502
|
mime = options['contentType'].to_s
|
|
224
|
-
|
|
225
|
-
keep =
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
503
|
+
processing = options['processingOptions'] || { 'keepContentAfterProcessing' => false }
|
|
504
|
+
keep = processing.is_a?(Hash) && processing['keepContentAfterProcessing'] == true
|
|
505
|
+
context = options['context'].is_a?(Hash) ? ModerationContext.select_items(options['context'].fetch('items', [])) : []
|
|
506
|
+
# Fields the API returns as null when they are not set.
|
|
507
|
+
asset = { 'id' => @create_id.call('asset'), 'projectId' => @project_id, 'parentId' => nil,
|
|
508
|
+
'folderId' => options['folderId'], 'uploadPresetId' => options['uploadPresetId'],
|
|
509
|
+
'createdAt' => now, 'updatedAt' => now, 'name' => options['name'], 'description' => options['description'],
|
|
510
|
+
'externalId' => options['externalId'], 'externalUrl' => url || options['externalUrl'],
|
|
511
|
+
'type' => kind(mime, url), 'metadata' => options['metadata'], 'authorId' => options['authorId'],
|
|
512
|
+
# Like the API, only the posts CalmLens uses are kept, and only while the content is held.
|
|
513
|
+
'context' => context.empty? ? nil : { 'items' => context }, 'tags' => nil,
|
|
514
|
+
'processingOptions' => processing, 'visibility' => options.fetch('visibility', 'private'),
|
|
515
|
+
**sections(now: now, open: open, keep: keep, format: mime, size: data&.bytesize) }
|
|
231
516
|
@state.assets[asset['id']] = asset
|
|
232
517
|
@state.content[asset['id']] = { 'type' => mime, 'parts' => { 1 => data } } if data
|
|
233
|
-
open ? asset :
|
|
518
|
+
open ? asset : upload_completed(asset, queue, data)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def sections(now:, open:, keep:, format:, size:)
|
|
522
|
+
{
|
|
523
|
+
'content' => { 'status' => keep ? 'stored' : 'temporary', 'removalReason' => nil, 'updatedAt' => now,
|
|
524
|
+
'safetyHold' => nil, 'format' => format.empty? ? nil : format, 'extension' => nil,
|
|
525
|
+
'sizeInBytes' => size, 'checksums' => nil, 'image' => nil, 'extractedText' => nil,
|
|
526
|
+
'fileMetadata' => nil },
|
|
527
|
+
# Like the API, only an open upload counts the parts received.
|
|
528
|
+
'upload' => { 'status' => open ? 'uploading' : 'completed', 'updatedAt' => now, 'partsReceived' => open ? 1 : nil },
|
|
529
|
+
'classification' => { 'status' => open ? 'pending' : 'processing', 'failureReason' => nil, 'updatedAt' => now,
|
|
530
|
+
'completedAt' => nil,
|
|
531
|
+
'moderation' => { 'status' => 'pending', 'manualReviews' => [],
|
|
532
|
+
'flaggedCategories' => [], 'regions' => [] } }
|
|
533
|
+
}
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Like the API: image uploads in parts total 10 MiB (checked at the last part) to 20 MiB.
|
|
537
|
+
def image_size_error(type, size, final)
|
|
538
|
+
return unless type == 'image'
|
|
539
|
+
return ErrorCodes::MULTIPART_IMAGE_TOO_LARGE if size > MAX_MULTIPART_IMAGE
|
|
540
|
+
|
|
541
|
+
ErrorCodes::MULTIPART_IMAGE_TOO_SMALL if final && size < MIN_MULTIPART_IMAGE
|
|
234
542
|
end
|
|
235
543
|
|
|
236
|
-
#
|
|
544
|
+
# Like the API: the parts may not overshoot a declared total, and must reach it at the end.
|
|
545
|
+
def total_size_error(total, received, final)
|
|
546
|
+
return if total.nil?
|
|
547
|
+
|
|
548
|
+
ErrorCodes::UPLOAD_SIZE_MISMATCH if received > total || (final && received != total)
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def check_part_size(size, final)
|
|
552
|
+
return if final || size >= MIN_PART
|
|
553
|
+
|
|
554
|
+
fail!(ErrorCodes::INVALID_UPLOAD_PAYLOAD, 'Multipart upload parts must be at least 5MiB except the final part.')
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# In the API's order: a declared total, then a single final part as the whole upload, then the
|
|
558
|
+
# part itself. The queue mode and declared total are saved with an open upload.
|
|
237
559
|
def start_multipart(query, data)
|
|
238
560
|
total = query['totalSizeInBytes']
|
|
239
|
-
|
|
240
|
-
|
|
561
|
+
type = kind(query['contentType'].to_s, nil)
|
|
562
|
+
final = query['isFinal'] == true
|
|
563
|
+
size = data.to_s.bytesize
|
|
564
|
+
code = (total && image_size_error(type, total, true)) ||
|
|
565
|
+
(final ? total_size_error(total, size, true) || image_size_error(type, size, true) : nil)
|
|
566
|
+
fail!(code) if code
|
|
567
|
+
check_part_size(size, final)
|
|
568
|
+
fail!(total_size_error(total, size, false)) if !final && total_size_error(total, size, false)
|
|
569
|
+
|
|
570
|
+
asset = create_asset(query, query['queue'] == true, data: data, open: !final)
|
|
571
|
+
@state.uploads[asset['id']] = { 'queue' => query['queue'] == true, 'total' => total } unless final
|
|
572
|
+
asset
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# A completed upload of a processing asset: moderated for the `upload` trigger, with
|
|
576
|
+
# `asset.uploaded` (the asset before moderation) and then the processing events.
|
|
577
|
+
def upload_completed(asset, queue, bytes = :stored)
|
|
578
|
+
decision = decision_for(asset, 'upload', queue, bytes)
|
|
579
|
+
uploaded = snapshot(asset)
|
|
580
|
+
format = asset['content']['format']
|
|
581
|
+
decided(asset, decision) unless decision.status == 'pending'
|
|
582
|
+
add_event('type' => 'asset.uploaded', 'severity' => 'low', 'assetId' => asset['id'], 'asset' => uploaded,
|
|
583
|
+
'facts' => [{ 'key' => 'asset-type', 'value' => asset['type'] }, asset_name_fact(asset),
|
|
584
|
+
{ 'key' => 'file-format', 'value' => format }])
|
|
585
|
+
record_processing_events(asset, decision)
|
|
586
|
+
asset
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
# Asks `moderate` how to decide `asset`, with its content: `bytes` when the caller has them,
|
|
590
|
+
# otherwise the stored parts in order (none once the content is removed).
|
|
591
|
+
def decision_for(asset, trigger, queued, bytes = :stored)
|
|
592
|
+
if bytes == :stored
|
|
593
|
+
stored = asset['content']['status'] == 'removed' ? nil : @state.content[asset['id']]
|
|
594
|
+
bytes = stored && stored['parts'].sort.map { |_, part| part.to_s.b }.join.b
|
|
241
595
|
end
|
|
596
|
+
return MockDecision.from(default_moderation(trigger, queued)) unless @moderate
|
|
597
|
+
|
|
598
|
+
text = bytes && asset['type'] == 'plaintext-document' ? bytes.dup.force_encoding(Encoding::UTF_8).scrub : nil
|
|
599
|
+
content = MockModerationInput::Content.new(bytes: bytes&.dup, text: text)
|
|
600
|
+
MockDecision.from(@moderate.call(MockModerationInput.new(asset: deep_copy(asset), content: content, trigger: trigger,
|
|
601
|
+
queued: queued)))
|
|
602
|
+
end
|
|
242
603
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
return complete(asset, upload, nil) if query['isFinal']
|
|
604
|
+
# Without `moderate`, queued work stays pending until completed, which allows it.
|
|
605
|
+
def default_moderation(trigger, queued) = queued && trigger != 'complete' ? 'pending' : 'allowed'
|
|
246
606
|
|
|
247
|
-
|
|
607
|
+
# Applies `decision` to `asset` with its report.
|
|
608
|
+
def decided(asset, decision) = apply_outcome(asset, decision.status, 'policy', decision.report)
|
|
609
|
+
|
|
610
|
+
# Asks `moderate` (unless `decision` is given) and settles a processing asset: a decision and
|
|
611
|
+
# its events, or nothing for `pending`.
|
|
612
|
+
def moderate_stored(asset, trigger, decision = nil)
|
|
613
|
+
decision ||= decision_for(asset, trigger, true)
|
|
614
|
+
return asset if decision.status == 'pending'
|
|
615
|
+
|
|
616
|
+
decided(asset, decision)
|
|
617
|
+
record_processing_events(asset, decision)
|
|
248
618
|
asset
|
|
249
619
|
end
|
|
250
620
|
|
|
251
|
-
|
|
252
|
-
def finish(asset, queue)
|
|
253
|
-
return apply_outcome(asset, @queued_outcome) if queue
|
|
621
|
+
def asset_name_fact(item) = { 'key' => 'asset-name', 'value' => item['name'], 'url' => "https://calmlens.com/go/asset/#{item['id']}" }
|
|
254
622
|
|
|
255
|
-
|
|
256
|
-
|
|
623
|
+
# The events the pipeline records when it settles an asset (AssetClassification.ts and
|
|
624
|
+
# AssetErrorAlert.ts): `asset.classified` and the automated `asset.<status>`, or `asset.failed`.
|
|
625
|
+
def record_processing_events(item, decision)
|
|
626
|
+
return if decision.status == 'pending'
|
|
627
|
+
|
|
628
|
+
if decision.status == 'failed'
|
|
629
|
+
return add_event('type' => 'asset.failed', 'severity' => 'high', 'assetId' => item['id'], 'asset' => snapshot(item),
|
|
630
|
+
'facts' => [asset_name_fact(item), { 'key' => 'asset-type', 'value' => item['type'] },
|
|
631
|
+
{ 'key' => 'previous-status', 'value' => 'processing' },
|
|
632
|
+
{ 'key' => 'failure-reason',
|
|
633
|
+
'value' => item['classification']['failureReason'] || 'processing-error' }])
|
|
634
|
+
end
|
|
257
635
|
|
|
258
|
-
|
|
259
|
-
|
|
636
|
+
flagged = decision.report['flaggedCategories']
|
|
637
|
+
add_event('type' => 'asset.classified', 'severity' => 'low', 'assetId' => item['id'], 'asset' => snapshot(item),
|
|
638
|
+
'facts' => [{ 'key' => 'flagged-categories', 'value' => flagged },
|
|
639
|
+
{ 'key' => 'moderation-status', 'value' => decision.status },
|
|
640
|
+
{ 'key' => 'asset-type', 'value' => item['type'] }, asset_name_fact(item)])
|
|
641
|
+
# Like the event catalog: only allowing and limiting are low severity.
|
|
642
|
+
add_event('type' => "asset.#{decision.status}", 'severity' => %w[allowed limited].include?(decision.status) ? 'low' : 'high',
|
|
643
|
+
'assetId' => item['id'], 'asset' => snapshot(item),
|
|
644
|
+
'facts' => [{ 'key' => 'decided-by', 'value' => 'automated' }, { 'key' => 'flagged-categories', 'value' => flagged },
|
|
645
|
+
{ 'key' => 'asset-type', 'value' => item['type'] }, asset_name_fact(item)])
|
|
260
646
|
end
|
|
261
647
|
|
|
262
|
-
# Moves an asset to an outcome like the pipeline would: a decision finishes processing,
|
|
263
|
-
#
|
|
264
|
-
|
|
648
|
+
# Moves an asset to an outcome like the pipeline would: a decision finishes processing, `purged`
|
|
649
|
+
# deletes the content (with `purge_reason`, `policy` for the pipeline), and finished processing
|
|
650
|
+
# removes content kept only temporarily. A classified image gets a ThumbHash for its
|
|
651
|
+
# placeholder, which goes with its content. `report` is a decision's categories, score and policy.
|
|
652
|
+
def apply_outcome(asset, outcome, purge_reason = 'policy', report = nil)
|
|
265
653
|
processing, moderation = AssetRecord.statuses(outcome)
|
|
266
654
|
now = @now.call
|
|
267
|
-
|
|
268
|
-
asset['
|
|
269
|
-
|
|
655
|
+
decided = processing == 'completed'
|
|
656
|
+
purge = moderation == 'purged' && asset['content']['status'] != 'removed'
|
|
657
|
+
remove = purge || (decided && asset['content']['status'] == 'temporary')
|
|
658
|
+
image = asset['content']['image']
|
|
659
|
+
if decided && asset['type'] == 'image' && asset['content']['status'] != 'removed' && !purge
|
|
660
|
+
image = { 'width' => nil, 'height' => nil, 'perceptualHash' => nil }.merge(image || {})
|
|
661
|
+
.merge('thumbhash' => THUMBHASH,
|
|
662
|
+
'averageColor' => image&.fetch('averageColor',
|
|
663
|
+
nil) || '#7f7f7f')
|
|
664
|
+
elsif moderation == 'purged'
|
|
665
|
+
image = without_placeholder(image)
|
|
666
|
+
end
|
|
667
|
+
asset['content']['image'] = image
|
|
668
|
+
if remove
|
|
669
|
+
forget_content(asset)
|
|
670
|
+
asset['content'].merge!('status' => 'removed', 'removalReason' => purge ? purge_reason : 'retention', 'updatedAt' => now,
|
|
671
|
+
'extractedText' => nil, 'fileMetadata' => nil, 'image' => without_placeholder(image))
|
|
672
|
+
end
|
|
673
|
+
asset['upload'].merge!('status' => 'completed', 'updatedAt' => now)
|
|
674
|
+
update_classification(asset['classification'], processing, moderation, now)
|
|
675
|
+
asset['classification']['moderation'].merge!(deep_copy(report)) if report
|
|
270
676
|
asset['updatedAt'] = now
|
|
271
677
|
asset
|
|
272
678
|
end
|
|
273
679
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
680
|
+
# Like the API, a failed asset says why (the mock has only `processing-error`).
|
|
681
|
+
def update_classification(classification, processing, moderation, now)
|
|
682
|
+
classification.merge!('status' => processing, 'failureReason' => processing == 'failed' ? 'processing-error' : nil,
|
|
683
|
+
'updatedAt' => now, 'completedAt' => processing == 'completed' ? now : nil)
|
|
684
|
+
classification['moderation']['status'] = moderation
|
|
278
685
|
end
|
|
279
686
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
finish(asset, queue_override.nil? ? upload['queue'] : queue_override == true)
|
|
687
|
+
# Like the API, the earlier posts CalmLens used go with the stored content.
|
|
688
|
+
def forget_content(item)
|
|
689
|
+
@state.content.delete(item['id'])
|
|
690
|
+
item['context'] = nil
|
|
285
691
|
end
|
|
286
692
|
|
|
693
|
+
# Removing content removes the placeholder and average colour with it.
|
|
694
|
+
def without_placeholder(image) = image&.merge('thumbhash' => nil, 'averageColor' => nil)
|
|
695
|
+
|
|
287
696
|
def asset(method, parts, query, json, body)
|
|
288
697
|
id = parts[1]
|
|
289
698
|
item = @state.assets[id]
|
|
290
699
|
fail!(ErrorCodes::ASSET_NOT_FOUND) unless item && item['projectId'] == @project_id
|
|
291
700
|
case [method, parts[2]]
|
|
292
701
|
in ['GET', nil] then item
|
|
293
|
-
in ['PATCH', nil] then
|
|
702
|
+
in ['PATCH', nil] then update(item, json)
|
|
294
703
|
in ['DELETE', nil]
|
|
295
704
|
@state.assets.delete(id)
|
|
296
705
|
@state.content.delete(id)
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
in ['
|
|
302
|
-
in ['POST', '
|
|
303
|
-
|
|
304
|
-
fail!(ErrorCodes::ASSET_CONTENT_UNAVAILABLE) if item['content']['status'] == 'removed'
|
|
305
|
-
# Like the API: back to processing, to be moderated again.
|
|
306
|
-
apply_outcome(item, 'pending')
|
|
706
|
+
lifecycle_event('asset.deleted', item)
|
|
707
|
+
nil
|
|
708
|
+
in ['DELETE', 'content'] then delete_content(item)
|
|
709
|
+
in ['GET', 'children'] then page(project_assets.select { |child| child['parentId'] == id }, query, id)
|
|
710
|
+
in ['POST', 'retry'] then retry_asset(item)
|
|
711
|
+
in ['POST', 'explain'] then explain(item, json['rerun'])
|
|
712
|
+
in ['POST', 'decision'] then decide_request(item, json)
|
|
307
713
|
in ['POST', 'move']
|
|
308
714
|
check_folder(json['folderId']) if json['folderId']
|
|
309
715
|
touch(item.merge!('folderId' => json['folderId']))
|
|
310
716
|
in ['POST', 'signed-url'] then signed_url(id, json)
|
|
311
717
|
in ['POST', 'part'] then part(item, query, body)
|
|
312
|
-
else
|
|
718
|
+
else route_not_found!(method, parts)
|
|
313
719
|
end
|
|
314
720
|
end
|
|
315
721
|
|
|
@@ -318,29 +724,100 @@ module CalmLens
|
|
|
318
724
|
item
|
|
319
725
|
end
|
|
320
726
|
|
|
727
|
+
# Like the API: an externalId stays unique in the project; null clears a field.
|
|
728
|
+
def update(item, json)
|
|
729
|
+
if json['externalId'] && project_assets.any? { |other| other['id'] != item['id'] && other['externalId'] == json['externalId'] }
|
|
730
|
+
fail!(ErrorCodes::DUPLICATE_EXTERNAL_ID)
|
|
731
|
+
end
|
|
732
|
+
touch(item.merge!(json.except('id', 'projectId', 'upload', 'classification', 'content')))
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
# Like the API: the record and report stay; stored content, extracted text and the placeholder
|
|
736
|
+
# go. Content already removed keeps its reason, and an `asset.content-removed` event is
|
|
737
|
+
# recorded either way.
|
|
738
|
+
def delete_content(item)
|
|
739
|
+
forget_content(item)
|
|
740
|
+
content = item['content']
|
|
741
|
+
if content['status'] == 'removed'
|
|
742
|
+
content['image'] = without_placeholder(content['image']) if content.dig('image', 'thumbhash')
|
|
743
|
+
else
|
|
744
|
+
now = @now.call
|
|
745
|
+
content.merge!('status' => 'removed', 'removalReason' => 'requested', 'updatedAt' => now, 'extractedText' => nil,
|
|
746
|
+
'fileMetadata' => nil, 'image' => without_placeholder(content['image']))
|
|
747
|
+
item['updatedAt'] = now
|
|
748
|
+
end
|
|
749
|
+
lifecycle_event('asset.content-removed', item)
|
|
750
|
+
item
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
# Like the API: back to processing, to be moderated again, with an `asset.retried` event.
|
|
754
|
+
def retry_asset(item)
|
|
755
|
+
unless item['classification']['status'] == 'failed' && item['classification']['moderation']['status'] == 'pending'
|
|
756
|
+
fail!(ErrorCodes::ASSET_NOT_RETRYABLE)
|
|
757
|
+
end
|
|
758
|
+
fail!(ErrorCodes::ASSET_CONTENT_UNAVAILABLE) if item['content']['status'] == 'removed'
|
|
759
|
+
|
|
760
|
+
apply_outcome(item, 'pending')
|
|
761
|
+
lifecycle_event('asset.retried', item)
|
|
762
|
+
moderate_stored(item, 'retry')
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
def explain(item, rerun)
|
|
766
|
+
fail!(ErrorCodes::ASSET_NOT_DECIDED) unless item['classification']['status'] == 'completed'
|
|
767
|
+
moderation = item['classification']['moderation']
|
|
768
|
+
unless rerun
|
|
769
|
+
# Like the API: the evidence is completed and every action stays as decided.
|
|
770
|
+
return item unless moderation['explanationComplete'] == false
|
|
771
|
+
|
|
772
|
+
moderation['explanationComplete'] = true
|
|
773
|
+
return touch(item)
|
|
774
|
+
end
|
|
775
|
+
fail!(ErrorCodes::ASSET_REVIEWED) unless moderation.fetch('manualReviews', []).empty?
|
|
776
|
+
fail!(ErrorCodes::ASSET_CONTENT_UNAVAILABLE) if item['content']['status'] == 'removed'
|
|
777
|
+
|
|
778
|
+
# Like the API: back to processing, to be moderated again under the current policy.
|
|
779
|
+
apply_outcome(item, 'pending')
|
|
780
|
+
moderate_stored(item, 'rerun')
|
|
781
|
+
end
|
|
782
|
+
|
|
321
783
|
def signed_url(id, json)
|
|
322
784
|
expires = json.fetch('expiresInSeconds', 900)
|
|
323
785
|
params = URI.encode_www_form(json.slice('lens', 'transform').merge('expires' => expires))
|
|
324
|
-
{ 'url' => "#{@cdn_base_url}/signed/#{URI.encode_www_form_component(id)}/#{json.fetch('resource', '
|
|
786
|
+
{ 'url' => "#{@cdn_base_url}/signed/#{URI.encode_www_form_component(id)}/#{json.fetch('resource', 'rendered')}?#{params}",
|
|
325
787
|
'expiresAt' => @now.call + (expires * 1000) }
|
|
326
788
|
end
|
|
327
789
|
|
|
790
|
+
# In the API's order: the upload must be open, parts arrive in order, image totals and the
|
|
791
|
+
# declared total are checked, then the part itself (5 MiB unless it is the last).
|
|
328
792
|
def part(item, query, body)
|
|
329
|
-
|
|
330
|
-
fail!(ErrorCodes::UPLOAD_NOT_OPEN) unless upload && item['processing']['status'] == 'uploading'
|
|
793
|
+
fail!(ErrorCodes::UPLOAD_NOT_OPEN) unless item['upload']['status'] == 'uploading'
|
|
331
794
|
number = query['partNumber']
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
795
|
+
expected = item['upload']['partsReceived'].to_i + 1
|
|
796
|
+
unless number == expected
|
|
797
|
+
fail!(ErrorCodes::INVALID_UPLOAD_PART_NUMBER, "Expected part #{expected}",
|
|
798
|
+
details: { 'expectedPartNumber' => expected, 'receivedPartNumber' => number })
|
|
799
|
+
end
|
|
800
|
+
final = query['isFinal'] == true
|
|
801
|
+
size = body.to_s.bytesize
|
|
802
|
+
received = item['content']['sizeInBytes'].to_i + size
|
|
803
|
+
upload = @state.uploads[item['id']] || {}
|
|
804
|
+
code = image_size_error(item['type'], received, final) || total_size_error(upload['total'], received, final)
|
|
805
|
+
fail!(code) if code
|
|
806
|
+
check_part_size(size, final)
|
|
807
|
+
|
|
808
|
+
stored = (@state.content[item['id']] ||= { 'type' => item['content']['format'].to_s, 'parts' => {} })
|
|
335
809
|
stored['parts'][number] = body.to_s
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
item['
|
|
340
|
-
|
|
810
|
+
now = @now.call
|
|
811
|
+
item['content']['sizeInBytes'] = received
|
|
812
|
+
# Like the API, a completed upload no longer counts parts.
|
|
813
|
+
item['upload'].merge!('updatedAt' => now, 'partsReceived' => final ? nil : number)
|
|
814
|
+
item['updatedAt'] = now
|
|
815
|
+
return item unless final
|
|
341
816
|
|
|
342
817
|
@state.uploads.delete(item['id'])
|
|
343
|
-
|
|
818
|
+
queue = query.key?('queue') ? query['queue'] == true : upload.fetch('queue', false)
|
|
819
|
+
apply_outcome(item, 'pending')
|
|
820
|
+
upload_completed(item, queue)
|
|
344
821
|
end
|
|
345
822
|
|
|
346
823
|
def folder(method, parts, json)
|
|
@@ -349,13 +826,13 @@ module CalmLens
|
|
|
349
826
|
case [method, parts[2]]
|
|
350
827
|
in ['GET', nil] then item
|
|
351
828
|
in ['PATCH', nil]
|
|
352
|
-
unique_name(json['name'], item['parentId'], id) if json.key?('name')
|
|
829
|
+
unique_name(json['name'], item['parentId'], id) if json.key?('name') && json['name'] != item['name']
|
|
353
830
|
touch(item.merge!(json.slice('name')))
|
|
354
831
|
in ['POST', 'move'] then move_folder(item, json['parentId'])
|
|
355
832
|
in ['DELETE', nil]
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
833
|
+
assets = project_assets.any? { |asset| asset['folderId'] == id }
|
|
834
|
+
children = project_folders.any? { |other| other['parentId'] == id }
|
|
835
|
+
fail!(ErrorCodes::FOLDER_NOT_EMPTY, details: { 'hasAssets' => assets, 'hasChildren' => children }) if assets || children
|
|
359
836
|
|
|
360
837
|
@state.folders.delete(id)
|
|
361
838
|
nil
|
|
@@ -372,16 +849,19 @@ module CalmLens
|
|
|
372
849
|
def unique_name(name, parent_id, exclude = nil)
|
|
373
850
|
return unless project_folders.any? { |other| other['id'] != exclude && other['name'] == name && other['parentId'] == parent_id }
|
|
374
851
|
|
|
375
|
-
fail!(ErrorCodes::DUPLICATE_FOLDER_NAME)
|
|
852
|
+
fail!(ErrorCodes::DUPLICATE_FOLDER_NAME, "A folder named \"#{name}\" already exists in this location.")
|
|
376
853
|
end
|
|
377
854
|
|
|
378
855
|
def create_folder(json)
|
|
856
|
+
fail!(ErrorCodes::INVALID_UPLOAD_PAYLOAD, 'Folder name is required') if json['name'].to_s.empty?
|
|
379
857
|
check_folder(json['parentId']) if json['parentId']
|
|
380
858
|
unique_name(json['name'], json['parentId'])
|
|
381
859
|
now = @now.call
|
|
382
|
-
item =
|
|
383
|
-
|
|
860
|
+
item = { 'id' => @create_id.call('fldr'), 'projectId' => @project_id, 'parentId' => json['parentId'], 'name' => json['name'],
|
|
861
|
+
'createdAt' => now, 'updatedAt' => now }
|
|
384
862
|
@state.folders[item['id']] = item
|
|
863
|
+
# Like the API, a new folder is 201 Created.
|
|
864
|
+
respond(201, deep_copy(item))
|
|
385
865
|
end
|
|
386
866
|
|
|
387
867
|
def move_folder(item, parent_id)
|
|
@@ -393,92 +873,284 @@ module CalmLens
|
|
|
393
873
|
visited << current
|
|
394
874
|
current = check_folder(current)['parentId']
|
|
395
875
|
end
|
|
396
|
-
unique_name(item['name'], parent_id, item['id'])
|
|
876
|
+
unique_name(item['name'], parent_id, item['id']) unless parent_id == item['parentId']
|
|
397
877
|
touch(item.merge!('parentId' => parent_id))
|
|
398
878
|
end
|
|
399
879
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
880
|
+
# Hash lists and their entries. Uploads are not compared with them.
|
|
881
|
+
def project_hash_lists = @state.hash_lists.values.select { |item| item['projectId'] == @project_id }
|
|
882
|
+
|
|
883
|
+
def check_hash_list(id)
|
|
884
|
+
item = @state.hash_lists[id]
|
|
885
|
+
fail!(ErrorCodes::HASH_LIST_NOT_FOUND) unless item && item['projectId'] == @project_id
|
|
886
|
+
item
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
def hash_list_action?(action) = %w[flag block].include?(action)
|
|
890
|
+
|
|
891
|
+
def create_hash_list(json)
|
|
892
|
+
name = json['name']
|
|
893
|
+
unless name.is_a?(String) && !name.strip.empty? && hash_list_action?(json['action'])
|
|
894
|
+
fail!(ErrorCodes::INVALID_UPLOAD_PAYLOAD, 'A hash list needs a name and an action of flag or block')
|
|
895
|
+
end
|
|
896
|
+
now = @now.call
|
|
897
|
+
item = { 'id' => @create_id.call('hl'), 'projectId' => @project_id, 'name' => name.strip,
|
|
898
|
+
'description' => json['description'].is_a?(String) ? json['description'] : nil,
|
|
899
|
+
'action' => json['action'], 'createdAt' => now, 'updatedAt' => now }
|
|
900
|
+
@state.hash_lists[item['id']] = item
|
|
901
|
+
# Like the API, a new hash list is 201 Created.
|
|
902
|
+
respond(201, deep_copy(item))
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
def hash_list(method, parts, query, json)
|
|
906
|
+
id = parts[1]
|
|
907
|
+
item = check_hash_list(id)
|
|
908
|
+
case [method, parts[2..]]
|
|
909
|
+
in ['GET', []] then item
|
|
910
|
+
in ['PATCH', []] then update_hash_list(item, json)
|
|
911
|
+
in ['DELETE', []]
|
|
912
|
+
@state.hash_lists.delete(id)
|
|
913
|
+
@state.hash_list_entries.delete_if { |_, entry| entry['hashListId'] == id }
|
|
914
|
+
{}
|
|
915
|
+
in ['GET', ['entries']] then page(@state.hash_list_entries.values.select { |entry| entry['hashListId'] == id }, query, id)
|
|
916
|
+
in ['POST', ['entries']] then add_hash_list_entry(item, json)
|
|
917
|
+
else fail!(ErrorCodes::HASH_LIST_NOT_FOUND, 'Not found')
|
|
918
|
+
end
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
def update_hash_list(item, json)
|
|
922
|
+
item['name'] = json['name'].strip if json['name'].is_a?(String)
|
|
923
|
+
item['description'] = json['description'].is_a?(String) ? json['description'] : nil if json.key?('description')
|
|
924
|
+
item['action'] = json['action'] if hash_list_action?(json['action'])
|
|
925
|
+
touch(item)
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
# Like the API: a hash or an image asset's hash; a hash already in the list returns its entry (200).
|
|
929
|
+
def add_hash_list_entry(list, json)
|
|
930
|
+
hash = json['hash'].is_a?(String) ? json['hash'].strip.downcase : nil
|
|
931
|
+
quality = json['quality'].is_a?(Numeric) ? json['quality'] : nil
|
|
932
|
+
asset_id = json['assetId'].is_a?(String) ? json['assetId'] : nil
|
|
933
|
+
fail!(ErrorCodes::INVALID_UPLOAD_PAYLOAD, 'Send either hash or assetId') if hash.nil? == asset_id.nil?
|
|
934
|
+
if asset_id
|
|
935
|
+
asset = @state.assets[asset_id]
|
|
936
|
+
fail!(ErrorCodes::ASSET_NOT_FOUND) unless asset && asset['projectId'] == @project_id
|
|
937
|
+
image = asset.dig('content', 'image')
|
|
938
|
+
fail!(ErrorCodes::HASH_LIST_ASSET_NOT_HASHED) unless image.is_a?(Hash) && image['perceptualHash']
|
|
939
|
+
hash = image['perceptualHash']
|
|
940
|
+
quality = image['perceptualHashQuality']
|
|
941
|
+
end
|
|
942
|
+
fail!(ErrorCodes::INVALID_UPLOAD_PAYLOAD, 'Use a PDQ hash of 64 hexadecimal characters') unless hash.match?(/\A\h{64}\z/)
|
|
943
|
+
|
|
944
|
+
existing = @state.hash_list_entries.values.find { |entry| entry['hashListId'] == list['id'] && entry['hash'] == hash }
|
|
945
|
+
return existing if existing
|
|
946
|
+
|
|
947
|
+
entry = { 'id' => @create_id.call('hle'), 'projectId' => @project_id, 'hashListId' => list['id'], 'hash' => hash,
|
|
948
|
+
'quality' => quality, 'label' => json['label'].is_a?(String) ? json['label'] : nil,
|
|
949
|
+
'sourceAssetId' => asset_id, 'createdAt' => @now.call }
|
|
950
|
+
@state.hash_list_entries[entry['id']] = entry
|
|
951
|
+
respond(201, deep_copy(entry))
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
def remove_hash_list_entry(method, parts)
|
|
955
|
+
entry = @state.hash_list_entries[parts[1]]
|
|
956
|
+
found = method == 'DELETE' && parts.size == 2 && entry && entry['projectId'] == @project_id
|
|
957
|
+
fail!(ErrorCodes::HASH_LIST_ENTRY_NOT_FOUND) unless found
|
|
958
|
+
|
|
959
|
+
@state.hash_list_entries.delete(parts[1])
|
|
960
|
+
{}
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
# One keyset page like the API's: ordered by `fields` (`createdAt` descending by default; an `id`
|
|
964
|
+
# tie-breaker is added), starting after the cursor's values. The cursor is tied to `scope` (the
|
|
965
|
+
# filter, folder or parent) and the ordering, so it only continues the query it came from.
|
|
966
|
+
def page(items, query, scope, fields: %w[createdAt id], default: { 'field' => 'createdAt', 'direction' => 'desc' })
|
|
967
|
+
size = check_range(query.fetch('pageSize', DEFAULT_PAGE_SIZE), 'pageSize', MAX_PAGE_SIZE)
|
|
968
|
+
ordering = ordering(query, default)
|
|
969
|
+
# Like the API's query schema: each entry's field and direction are enum values.
|
|
970
|
+
ordering.each_with_index do |rule, index|
|
|
971
|
+
invalid!('validation/invalid-value', "ordering.#{index}.field") unless fields.include?(rule['field'])
|
|
972
|
+
invalid!('validation/invalid-value', "ordering.#{index}.direction") unless %w[asc desc].include?(rule['direction'])
|
|
973
|
+
end
|
|
404
974
|
ordering << { 'field' => 'id', 'direction' => ordering.first['direction'] } unless ordering.any? { |o| o['field'] == 'id' }
|
|
405
|
-
|
|
406
|
-
sorted =
|
|
975
|
+
signature = JSON.generate([scope, ordering])
|
|
976
|
+
sorted = items.sort { |a, b| compare_items(a, b, ordering) }
|
|
407
977
|
if query['cursor']
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
valid = boundary.is_a?(Hash) && ordering.all? do |o|
|
|
411
|
-
o['field'] == 'id' ? boundary[o['field']].is_a?(String) : boundary[o['field']].is_a?(Numeric)
|
|
412
|
-
end
|
|
413
|
-
fail!(ErrorCodes::INVALID_CURSOR) unless valid
|
|
414
|
-
rescue ArgumentError, JSON::ParserError
|
|
415
|
-
fail!(ErrorCodes::INVALID_CURSOR)
|
|
416
|
-
end
|
|
417
|
-
sorted = sorted.select { |item| compare(item, boundary, ordering).positive? }
|
|
978
|
+
values = cursor_values(query['cursor'], signature, ordering)
|
|
979
|
+
sorted = sorted.select { |item| after_cursor?(item, values, ordering) }
|
|
418
980
|
end
|
|
419
981
|
selected = sorted.first(size)
|
|
420
982
|
more = sorted.size > size
|
|
421
|
-
cursor = more
|
|
422
|
-
|
|
423
|
-
|
|
983
|
+
cursor = if more
|
|
984
|
+
[JSON.generate({ 'scope' => signature, 'values' => ordering.map do |o|
|
|
985
|
+
selected.last[o['field']]
|
|
986
|
+
end })].pack('m0')
|
|
987
|
+
end
|
|
988
|
+
{ 'items' => selected, 'pageSize' => size, 'hasMore' => more, 'ordering' => ordering, 'nextCursor' => cursor }
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
def cursor_values(cursor, signature, ordering)
|
|
992
|
+
text_fields = %w[id name]
|
|
993
|
+
parsed = JSON.parse(cursor.unpack1('m0').force_encoding(Encoding::UTF_8))
|
|
994
|
+
values = parsed.is_a?(Hash) ? parsed['values'] : nil
|
|
995
|
+
valid = parsed.is_a?(Hash) && parsed['scope'] == signature && values.is_a?(Array) && values.size == ordering.size &&
|
|
996
|
+
values.zip(ordering).all? { |value, o| text_fields.include?(o['field']) ? value.is_a?(String) : value.is_a?(Numeric) }
|
|
997
|
+
fail!(ErrorCodes::INVALID_CURSOR) unless valid
|
|
998
|
+
values
|
|
999
|
+
rescue ArgumentError, JSON::ParserError, EncodingError
|
|
1000
|
+
fail!(ErrorCodes::INVALID_CURSOR)
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
def after_cursor?(item, values, ordering)
|
|
1004
|
+
ordering.each_with_index do |rule, index|
|
|
1005
|
+
result = compare_values(item[rule['field']], values[index])
|
|
1006
|
+
return rule['direction'] == 'desc' ? result.negative? : result.positive? unless result.zero?
|
|
1007
|
+
end
|
|
1008
|
+
false
|
|
424
1009
|
end
|
|
425
1010
|
|
|
426
|
-
def ordering(query)
|
|
1011
|
+
def ordering(query, default)
|
|
427
1012
|
entries = query.keys.filter_map { |key| key.match(/\Aordering\[(\d+)\]\[(field|direction)\]\z/) }
|
|
428
|
-
return [
|
|
1013
|
+
return [default&.dup].compact if entries.empty?
|
|
429
1014
|
|
|
430
1015
|
entries.group_by { |match| Integer(match[1], 10) }.sort.map do |_, matches|
|
|
431
1016
|
matches.to_h { |match| [match[2], query[match[0]]] }
|
|
432
1017
|
end
|
|
433
1018
|
end
|
|
434
1019
|
|
|
435
|
-
def
|
|
436
|
-
ordering.each do |
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
# Missing values sort last in either direction.
|
|
440
|
-
result = if a.nil? || b.nil? then (a.nil? ? 1 : 0) - (b.nil? ? 1 : 0)
|
|
441
|
-
else
|
|
442
|
-
natural = (a <=> b) || (a.to_s <=> b.to_s)
|
|
443
|
-
order['direction'].to_s.casecmp?('DESC') ? -natural : natural
|
|
444
|
-
end
|
|
445
|
-
return result unless result.zero?
|
|
1020
|
+
def compare_items(left, right, ordering)
|
|
1021
|
+
ordering.each do |rule|
|
|
1022
|
+
result = compare_values(left[rule['field']], right[rule['field']])
|
|
1023
|
+
return rule['direction'] == 'desc' ? -result : result unless result.zero?
|
|
446
1024
|
end
|
|
447
|
-
|
|
1025
|
+
0
|
|
448
1026
|
end
|
|
449
1027
|
|
|
1028
|
+
# Like the API: missing values first, numbers by value, everything else as text.
|
|
1029
|
+
def compare_values(left, right)
|
|
1030
|
+
return 0 if left == right
|
|
1031
|
+
return -1 if left.nil?
|
|
1032
|
+
return 1 if right.nil?
|
|
1033
|
+
return left <=> right if left.is_a?(Numeric) && right.is_a?(Numeric)
|
|
1034
|
+
|
|
1035
|
+
left.to_s <=> right.to_s
|
|
1036
|
+
end
|
|
1037
|
+
|
|
1038
|
+
# Answers like the CDN: a placeholder is the asset's ThumbHash as a PNG, public whatever
|
|
1039
|
+
# the asset's visibility or moderation; other resources pass delivery checks first, which refuse
|
|
1040
|
+
# everyone (content still uploading, blocked or purged, failed processing, removed content),
|
|
1041
|
+
# then an age-restricted asset needs `viewerClaims.ageConfirmed=true` whatever the key, and a
|
|
1042
|
+
# private asset needs an API key with `assets:download` for its folder.
|
|
450
1043
|
def serve(method, relative)
|
|
451
|
-
|
|
1044
|
+
path, query = relative.split('#', 2).first.split('?', 2)
|
|
1045
|
+
segments = path.split('/').reject(&:empty?).map { |part| URI.decode_www_form_component(part) }
|
|
452
1046
|
_, project, identifier, resource = segments
|
|
453
|
-
|
|
1047
|
+
item = project == @project_id && project_assets.find { |asset| [asset['id'], asset['externalId']].include?(identifier) }
|
|
1048
|
+
return error('asset/not-found', 'Asset not found', status: 404) unless item && RESOURCES.include?(resource)
|
|
454
1049
|
|
|
455
|
-
|
|
456
|
-
return text(404, 'Asset not found') unless item && %w[content preview].include?(resource)
|
|
457
|
-
return text(403, 'Asset is held') if item['moderation'].values_at('status', 'action') == %w[rejected hide]
|
|
1050
|
+
return placeholder(method, item) if resource == 'placeholder'
|
|
458
1051
|
|
|
1052
|
+
moderation = item['classification']['moderation']['status']
|
|
459
1053
|
stored = @state.content[item['id']]
|
|
460
|
-
|
|
1054
|
+
if stored.nil? || item['upload']['status'] == 'uploading' || %w[blocked purged].include?(moderation) ||
|
|
1055
|
+
item['classification']['status'] == 'failed' || item['content']['status'] == 'removed'
|
|
1056
|
+
return not_available
|
|
1057
|
+
end
|
|
1058
|
+
|
|
1059
|
+
params = URI.decode_www_form(query.to_s).to_h
|
|
1060
|
+
opted_in = viewer_age_confirmed?(params)
|
|
1061
|
+
if moderation == 'age-restricted' && !opted_in
|
|
1062
|
+
return error('viewer/age-confirmation-required', 'Age confirmation required',
|
|
1063
|
+
status: 403)
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
restricted = item['visibility'] != 'public'
|
|
1067
|
+
if restricted && !can_download?(item['folderId'])
|
|
1068
|
+
return error('asset/api-key-missing-permission',
|
|
1069
|
+
'This API key needs assets:download permission for this asset’s folder.', status: 403)
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1072
|
+
type = stored['type'].to_s.empty? ? 'application/octet-stream' : stored['type']
|
|
1073
|
+
cdn_response(method, stored['parts'].sort.map(&:last).join.b,
|
|
1074
|
+
viewer_delivery_headers(item, type, restricted, params))
|
|
1075
|
+
end
|
|
1076
|
+
|
|
1077
|
+
def viewer_age_confirmed?(params)
|
|
1078
|
+
JSON.parse(params.fetch('viewerClaims', '{}'))['ageConfirmed'] == true
|
|
1079
|
+
end
|
|
1080
|
+
|
|
1081
|
+
def viewer_delivery_headers(item, type, restricted, params)
|
|
1082
|
+
headers = delivery_headers(item, type, restricted)
|
|
1083
|
+
headers['Cache-Control'] = 'private, no-store' if params.key?('viewerClaims')
|
|
1084
|
+
headers
|
|
1085
|
+
end
|
|
461
1086
|
|
|
462
|
-
|
|
1087
|
+
def not_available = error('asset/not-available', 'Asset is not available', status: 403)
|
|
1088
|
+
|
|
1089
|
+
# Like the CDN's placeholder handler: an image's ThumbHash, public whatever the asset's
|
|
1090
|
+
# visibility or moderation; refused for purged assets and assets without one (not yet
|
|
1091
|
+
# classified, or content removed).
|
|
1092
|
+
def placeholder(method, item)
|
|
1093
|
+
thumbhash = item.dig('content', 'image', 'thumbhash')
|
|
1094
|
+
return not_available if thumbhash.nil? || item['classification']['moderation']['status'] == 'purged'
|
|
1095
|
+
|
|
1096
|
+
cdn_response(method, Placeholder.png(thumbhash),
|
|
1097
|
+
'content-type' => 'image/png', 'cache-control' => 'public, max-age=3600',
|
|
1098
|
+
'cache-tag' => "asset-#{item['id']}", 'etag' => "\"#{thumbhash}\"")
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
# Like the CDN: an image's bytes are immutable for a year, other content is kept for an hour,
|
|
1102
|
+
# by shared caches only when delivery is public. Active content is served as a download.
|
|
1103
|
+
def delivery_headers(item, type, restricted)
|
|
1104
|
+
image = item['type'] == 'image'
|
|
1105
|
+
headers = { 'content-type' => type,
|
|
1106
|
+
'cache-control' => "#{restricted ? 'private' : 'public'}, #{image ? 'max-age=31536000, immutable' : 'max-age=3600'}",
|
|
1107
|
+
'cache-tag' => "asset-#{item['id']}" }
|
|
1108
|
+
headers.merge!('etag' => "\"#{item['id']}\"", 'expires' => Time.at((@now.call / 1000) + 31_536_000).utc.httpdate) if image
|
|
1109
|
+
headers.merge!('x-content-type-options' => 'nosniff', 'content-security-policy' => "sandbox; default-src 'none'")
|
|
1110
|
+
mime = type.downcase.split(';', 2).first.to_s.strip
|
|
1111
|
+
headers['content-disposition'] = 'attachment' if ACTIVE_TYPES.include?(mime) || mime.end_with?('+xml')
|
|
1112
|
+
headers
|
|
1113
|
+
end
|
|
1114
|
+
|
|
1115
|
+
def cdn_response(method, bytes, headers)
|
|
463
1116
|
Response.new(status: 200, body: method == 'HEAD' ? '' : bytes,
|
|
464
|
-
headers:
|
|
465
|
-
|
|
1117
|
+
headers: headers.merge('content-length' => bytes.bytesize.to_s, 'x-request-id' => request_id))
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
# Whether the mock API key's grants give `assets:download` for `folder_id`.
|
|
1121
|
+
def can_download?(folder_id)
|
|
1122
|
+
@grants.any? do |grant|
|
|
1123
|
+
next false unless Array(grant['actions']).include?('assets:download')
|
|
1124
|
+
next true if grant['folderId'].nil?
|
|
1125
|
+
|
|
1126
|
+
covers?(grant, folder_id)
|
|
1127
|
+
end
|
|
466
1128
|
end
|
|
467
1129
|
|
|
468
|
-
def
|
|
469
|
-
|
|
470
|
-
|
|
1130
|
+
def covers?(grant, folder_id)
|
|
1131
|
+
current = folder_id
|
|
1132
|
+
seen = []
|
|
1133
|
+
while current && !seen.include?(current)
|
|
1134
|
+
return true if current == grant['folderId']
|
|
1135
|
+
return false unless grant['includeSubfolders']
|
|
1136
|
+
|
|
1137
|
+
seen << current
|
|
1138
|
+
current = @state.folders.dig(current, 'parentId')
|
|
1139
|
+
end
|
|
1140
|
+
false
|
|
471
1141
|
end
|
|
472
1142
|
end
|
|
473
1143
|
|
|
474
|
-
# The
|
|
1144
|
+
# The processing and moderation status of each outcome.
|
|
475
1145
|
# @api private
|
|
476
1146
|
module AssetRecord
|
|
477
1147
|
module_function
|
|
478
1148
|
|
|
479
1149
|
# Processing and moderation status for each outcome.
|
|
480
|
-
OUTCOMES = { 'pending' => %w[processing pending], '
|
|
481
|
-
'
|
|
1150
|
+
OUTCOMES = { 'pending' => %w[processing pending], 'allowed' => %w[completed allowed],
|
|
1151
|
+
'limited' => %w[completed limited],
|
|
1152
|
+
'age-restricted' => %w[completed age-restricted], 'blocked' => %w[completed blocked],
|
|
1153
|
+
'purged' => %w[completed purged], 'failed' => %w[failed pending] }.freeze
|
|
482
1154
|
|
|
483
1155
|
def statuses(outcome)
|
|
484
1156
|
OUTCOMES.fetch(outcome) do
|
|
@@ -486,61 +1158,79 @@ module CalmLens
|
|
|
486
1158
|
"MockServerClient outcomes are #{OUTCOMES.keys.join(', ')} (got #{outcome.inspect})")
|
|
487
1159
|
end
|
|
488
1160
|
end
|
|
1161
|
+
end
|
|
489
1162
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
1163
|
+
# The RSQL subset the fake API understands: `==` and `!=` (with `*` wildcards), `=in=` and
|
|
1164
|
+
# `=out=` with a `(value,value)` list, and `=gt=`, `=ge=`, `=lt=`, `=le=`, with quoted values,
|
|
1165
|
+
# combined with `;` (and), which binds tighter than `,` (or), and grouped with `( … )`.
|
|
1166
|
+
# Anything else raises instead of being ignored, so a test never passes on an unfiltered list.
|
|
1167
|
+
# @api private
|
|
1168
|
+
module RSQL
|
|
1169
|
+
module_function
|
|
494
1170
|
|
|
495
|
-
def
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
'reviewerNote' => nil, 'reviewerUserId' => nil, 'reviewedAt' => nil, 'decidedAt' => nil,
|
|
501
|
-
'report' => nil },
|
|
502
|
-
'content' => { 'status' => keep ? 'stored' : 'temporary', 'removalReason' => nil, 'updatedAt' => now,
|
|
503
|
-
'safetyHold' => nil, 'format' => format.empty? ? nil : format, 'extension' => nil,
|
|
504
|
-
'sizeInBytes' => size, 'checksums' => nil, 'image' => nil, 'previewImage' => nil,
|
|
505
|
-
'extractedText' => nil }
|
|
506
|
-
}
|
|
1171
|
+
def filter(items, expression)
|
|
1172
|
+
return items if expression.nil? || expression.to_s.strip.empty?
|
|
1173
|
+
|
|
1174
|
+
test = parse(expression.to_s)
|
|
1175
|
+
items.select { |item| test.call(item) }
|
|
507
1176
|
end
|
|
508
1177
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
1178
|
+
# Compiles `,` (or) of `;` (and) terms, where a term is a comparison or a `( … )` group.
|
|
1179
|
+
def parse(expression)
|
|
1180
|
+
alternatives = split(expression, ',').map do |alternative|
|
|
1181
|
+
split(alternative, ';').map do |term|
|
|
1182
|
+
text = term.strip
|
|
1183
|
+
raise unsupported(expression) if text.empty?
|
|
1184
|
+
|
|
1185
|
+
group = unwrap(text)
|
|
1186
|
+
group ? parse(group) : comparison(text)
|
|
515
1187
|
end
|
|
516
1188
|
end
|
|
517
|
-
|
|
1189
|
+
->(item) { alternatives.any? { |terms| terms.all? { |test| test.call(item) } } }
|
|
518
1190
|
end
|
|
519
|
-
end
|
|
520
1191
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
# @api private
|
|
525
|
-
module RSQL
|
|
526
|
-
module_function
|
|
1192
|
+
# The expression inside `( … )` when the parentheses enclose all of `text`, else nil.
|
|
1193
|
+
def unwrap(text)
|
|
1194
|
+
return unless text.start_with?('(') && text.end_with?(')')
|
|
527
1195
|
|
|
528
|
-
|
|
529
|
-
|
|
1196
|
+
depth = 0
|
|
1197
|
+
quote = nil
|
|
1198
|
+
text.each_char.with_index do |char, index|
|
|
1199
|
+
if quote
|
|
1200
|
+
quote = nil if char == quote
|
|
1201
|
+
elsif ['"', "'"].include?(char)
|
|
1202
|
+
quote = char
|
|
1203
|
+
elsif char == '('
|
|
1204
|
+
depth += 1
|
|
1205
|
+
elsif char == ')'
|
|
1206
|
+
depth -= 1
|
|
1207
|
+
return index == text.length - 1 ? text[1..-2] : nil if depth.zero?
|
|
1208
|
+
end
|
|
1209
|
+
end
|
|
1210
|
+
nil
|
|
1211
|
+
end
|
|
530
1212
|
|
|
531
|
-
|
|
532
|
-
|
|
1213
|
+
def unsupported(term)
|
|
1214
|
+
InvalidArgumentError.new(SdkErrorCodes::INVALID_ARGUMENT,
|
|
1215
|
+
'MockServerClient filters support ==, !=, =in=, =out=, =gt=, =ge=, =lt= and ' \
|
|
1216
|
+
"=le=, joined by ; and , and grouped with parentheses (got #{term.inspect})")
|
|
533
1217
|
end
|
|
534
1218
|
|
|
1219
|
+
# Splits on `separator` outside quotes and parentheses (groups and `=in=(...)` lists).
|
|
535
1220
|
def split(text, separator)
|
|
536
1221
|
parts = ['']
|
|
537
1222
|
quote = nil
|
|
1223
|
+
depth = 0
|
|
538
1224
|
text.each_char do |char|
|
|
539
1225
|
if quote
|
|
540
1226
|
quote = nil if char == quote
|
|
541
1227
|
elsif ['"', "'"].include?(char)
|
|
542
1228
|
quote = char
|
|
543
|
-
elsif char ==
|
|
1229
|
+
elsif char == '('
|
|
1230
|
+
depth += 1
|
|
1231
|
+
elsif char == ')'
|
|
1232
|
+
depth -= 1
|
|
1233
|
+
elsif char == separator && depth.zero?
|
|
544
1234
|
parts << ''
|
|
545
1235
|
next
|
|
546
1236
|
end
|
|
@@ -550,20 +1240,43 @@ module CalmLens
|
|
|
550
1240
|
end
|
|
551
1241
|
|
|
552
1242
|
def comparison(term)
|
|
553
|
-
match = term.strip.match(/\A([A-Za-z][\w.]*)(
|
|
554
|
-
unless match
|
|
555
|
-
raise InvalidArgumentError.new(SdkErrorCodes::INVALID_ARGUMENT,
|
|
556
|
-
"MockServerClient filters support only ==, != with ; and , (got #{term.inspect})")
|
|
557
|
-
end
|
|
1243
|
+
match = term.strip.match(/\A([A-Za-z][\w.]*)(==|!=|=in=|=out=|=gt=|=ge=|=lt=|=le=)(.+)\z/m)
|
|
1244
|
+
raise unsupported(term) unless match
|
|
558
1245
|
|
|
559
1246
|
field, operator, raw = match.captures
|
|
560
|
-
|
|
561
|
-
pattern = Regexp.new("\\A#{Regexp.escape(value).gsub('\*', '.*')}\\z", Regexp::MULTILINE)
|
|
1247
|
+
values = list(operator, raw.strip)
|
|
562
1248
|
lambda do |item|
|
|
563
1249
|
actual = field.split('.').reduce(item) { |object, key| object.is_a?(Hash) ? object[key] : nil }
|
|
564
|
-
|
|
565
|
-
operator
|
|
1250
|
+
entries = Array(actual.nil? ? 'null' : actual).map(&:to_s)
|
|
1251
|
+
case operator
|
|
1252
|
+
when '==', '=in=' then values.any? { |value| matches?(entries, value) }
|
|
1253
|
+
when '!=', '=out=' then values.none? { |value| matches?(entries, value) }
|
|
1254
|
+
else entries.any? { |entry| ordered?(entry, operator, values.first) }
|
|
1255
|
+
end
|
|
1256
|
+
end
|
|
1257
|
+
end
|
|
1258
|
+
|
|
1259
|
+
def list(operator, raw)
|
|
1260
|
+
return [unquote(raw)] unless %w[=in= =out=].include?(operator)
|
|
1261
|
+
|
|
1262
|
+
unless raw.start_with?('(') && raw.end_with?(')')
|
|
1263
|
+
raise InvalidArgumentError.new(SdkErrorCodes::INVALID_ARGUMENT, "#{operator} takes a (value,value) list")
|
|
566
1264
|
end
|
|
1265
|
+
|
|
1266
|
+
split(raw[1..-2], ',').map { |value| unquote(value.strip) }
|
|
1267
|
+
end
|
|
1268
|
+
|
|
1269
|
+
def unquote(value) = value.match?(/\A(["']).*\1\z/m) ? value[1..-2] : value
|
|
1270
|
+
|
|
1271
|
+
def matches?(entries, value)
|
|
1272
|
+
pattern = Regexp.new("\\A#{Regexp.escape(value).gsub('\*', '.*')}\\z", Regexp::MULTILINE)
|
|
1273
|
+
entries.any? { |entry| pattern.match?(entry) }
|
|
1274
|
+
end
|
|
1275
|
+
|
|
1276
|
+
# Numbers compare as numbers and everything else (such as ISO dates) as text.
|
|
1277
|
+
def ordered?(entry, operator, value)
|
|
1278
|
+
left, right = [entry, value].all? { |text| text.match?(/\A-?\d+(\.\d+)?\z/) } ? [entry.to_f, value.to_f] : [entry, value]
|
|
1279
|
+
{ '=gt=' => left > right, '=ge=' => left >= right, '=lt=' => left < right, '=le=' => left <= right }.fetch(operator)
|
|
567
1280
|
end
|
|
568
1281
|
end
|
|
569
1282
|
end
|