calmlens 0.1.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- State = Struct.new(:assets, :folders, :content, :events, :lenses, :uploads, :idempotency, keyword_init: true)
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. It does not classify content, run transforms or index for semantic search.
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
- processingOptions metadata].freeze
37
- # Search match fields and where each one's value sits in the asset.
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
- 'status' => %w[moderation status] }.freeze
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:, queued_outcome:, analyzed_outcome:)
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
- @queued_outcome = queued_outcome
53
- @analyzed_outcome = analyzed_outcome
54
- @state = State.new(assets: {}, folders: {}, content: {}, events: [], lenses: {}, uploads: {}, idempotency: {})
55
- @event_sequence = 0
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', 'facts' => [],
63
- 'createdAt' => time, 'updatedAt' => time }.merge(event)
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.merge('_sequence' => @event_sequence)
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' => request_id }.merge(headers))
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
- def request_id = "mock-#{SecureRandom.hex(8)}"
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
- def error(code, message)
90
- respond(ErrorCodes::STATUSES.fetch(code, 400), { 'code' => code, 'message' => message })
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
- json = headers['Content-Type'] == 'application/json' && body ? JSON.parse(body) : {}
100
- key = headers['Idempotency-Key']
101
- upload = headers.find { |name, _| name.to_s.casecmp?('X-CalmLens-Upload') }&.last
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
- # Like the API: a successful response is saved and replayed for the same key and request.
105
- fingerprint = [method, relative, headers['Content-Type'], body&.bytesize]
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
- @state.idempotency[key] = { fingerprint: fingerprint, status: response.status, body: response.body,
114
- headers: response.headers }
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
- else fail!(ErrorCodes::ASSET_NOT_FOUND, 'Not found')
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 { 'folders' => project_folders.sort_by { |item| item['name'] } }
303
+ in ['GET', ['folders'], _] then folder_page(query)
146
304
  in ['POST', ['folders'], _] then create_folder(json)
147
- in ['GET', ['assets', 'page'], _] then page(project_assets.reject { |item| item['parentId'] }, query)
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 fail!(ErrorCodes::ASSET_NOT_FOUND, 'Not found')
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
- # Without a cursor the feed starts from now, like the API.
161
- return { 'events' => [], 'cursor' => "mock_#{@event_sequence}", 'hasMore' => false } unless query.key?('after')
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['after'].to_s.match(/\Amock_(\d+)\z/)
164
- fail!(ErrorCodes::INVALID_EVENT_CURSOR) unless match
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
- sequence = Integer(match[1], 10)
167
- limit = query.fetch('limit', 50)
168
- later = @state.events.select { |event| event['_sequence'] > sequence }
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
- pending = deep_copy(@state.lenses)
178
- saved = definitions.map do |definition|
179
- old = pending[definition['identifier']]
180
- same = old && %w[name description type active transforms version].all? { |key| old[key] == definition[key] }
181
- fail!(ErrorCodes::LENS_VERSION_CONFLICT) if old && !same && definition['version'].to_i <= old['version'].to_i
182
-
183
- lens = if same
184
- old
185
- else
186
- definition.merge('id' => old ? old['id'] : @create_id.call, 'projectId' => @project_id,
187
- 'createdAt' => old ? old['createdAt'] : now, 'updatedAt' => now)
188
- end
189
- pending[lens['identifier']] = lens
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.downcase
197
- fail!(ErrorCodes::INVALID_UPLOAD_PAYLOAD, 'query is required') if text.empty?
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
- fields = SEARCH_FIELDS.filter_map do |field, path|
201
- field if Array(item.dig(*path)).any? { |value| value.to_s.downcase.include?(text) }
202
- end
203
- { 'asset' => item, 'score' => 1.0, 'matchedFields' => fields } unless fields.empty?
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
- asset = options.slice('name', 'description', 'metadata', 'externalId', 'externalUrl', 'processingOptions')
225
- keep = options['processingOptions'].is_a?(Hash) && options['processingOptions']['keepContentAfterProcessing'] == true
226
- asset.merge!('id' => @create_id.call, 'projectId' => @project_id, 'folderId' => options['folderId'],
227
- 'parentId' => nil, 'type' => kind(mime, url), 'visibility' => options.fetch('visibility', 'private'),
228
- 'createdAt' => now, 'updatedAt' => now,
229
- **AssetRecord.sections(now: now, open: open, keep: keep, format: mime, size: data&.bytesize))
230
- asset['externalUrl'] = url if url
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 : finish(asset, queue)
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
- # The queue mode and declared total are saved with the upload and apply to its final part.
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
- if query['contentType'].to_s.start_with?('image/') && [total, data.bytesize].compact.max > MAX_MULTIPART_IMAGE
240
- fail!(ErrorCodes::MULTIPART_IMAGE_TOO_LARGE)
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
- asset = create_asset(query, query['queue'] == true, data: data, open: true)
244
- upload = { 'queue' => query['queue'] == true, 'total' => total }
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
- @state.uploads[asset['id']] = upload
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
- # Applies queue mode: queued assets get `queued_outcome`; others `analyzed_outcome` now.
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
- decision = @analyzed_outcome.respond_to?(:call) ? @analyzed_outcome.call(deep_copy(asset)) : @analyzed_outcome
256
- return apply_outcome(asset, decision) unless decision.is_a?(Hash)
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
- fields = deep_copy(decision)
259
- AssetRecord.deep_merge!(apply_outcome(asset, AssetRecord.outcome_of(fields)), fields)
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, and
263
- # finished processing removes content kept only temporarily.
264
- def apply_outcome(asset, outcome)
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
- asset['processing'].merge!('status' => processing, 'updatedAt' => now)
268
- asset['moderation'].merge!('status' => moderation, 'decidedAt' => processing == 'done' ? now : nil)
269
- remove_content(asset, 'retention', now) if processing == 'done' && asset['content']['status'] == 'temporary'
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
- def remove_content(asset, reason, now)
275
- @state.content.delete(asset['id'])
276
- asset['content'].merge!('status' => 'removed', 'removalReason' => reason, 'updatedAt' => now,
277
- 'extractedText' => nil)
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
- def complete(asset, upload, queue_override)
281
- size = asset.dig('content', 'sizeInBytes').to_i
282
- fail!(ErrorCodes::UPLOAD_SIZE_MISMATCH) if upload['total'] && upload['total'] != size
283
- fail!(ErrorCodes::MULTIPART_IMAGE_TOO_SMALL) if (asset['type'] == 'image') && (size < MIN_MULTIPART_IMAGE)
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 touch(item.merge!(json.except('id', 'projectId', 'processing', 'moderation', 'content')))
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
- in ['DELETE', 'content']
299
- remove_content(item, 'requested', @now.call) unless item['content']['status'] == 'removed'
300
- touch(item)
301
- in ['GET', 'children'] then page(project_assets.select { |child| child['parentId'] == id }, query)
302
- in ['POST', 'retry']
303
- fail!(ErrorCodes::ASSET_NOT_RETRYABLE) unless item['processing']['status'] == 'failed'
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 fail!(ErrorCodes::ASSET_NOT_FOUND, 'Not found')
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', 'content')}?#{params}",
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
- upload = @state.uploads[item['id']]
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
- fail!(ErrorCodes::INVALID_UPLOAD_PART_NUMBER) unless number.is_a?(Integer) && number >= 2
333
-
334
- stored = (@state.content[item['id']] ||= { 'type' => '', 'parts' => {} })
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
- size = stored['parts'].values.sum(&:bytesize)
337
- fail!(ErrorCodes::MULTIPART_IMAGE_TOO_LARGE) if item['type'] == 'image' && size > MAX_MULTIPART_IMAGE
338
- item['content']['sizeInBytes'] = size
339
- item['processing']['uploadPartsReceived'] = number
340
- return touch(item) unless query['isFinal']
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
- complete(item, upload, query['queue'])
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
- if project_assets.any? { |asset| asset['folderId'] == id } || project_folders.any? { |other| other['parentId'] == id }
357
- fail!(ErrorCodes::FOLDER_NOT_EMPTY)
358
- end
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 = json.slice('name').merge('id' => @create_id.call, 'projectId' => @project_id,
383
- 'parentId' => json['parentId'], 'createdAt' => now, 'updatedAt' => now)
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
- def page(items, query)
401
- size = query.fetch('pageSize', 10)
402
- ordering = ordering(query)
403
- fail!(ErrorCodes::INVALID_UPLOAD_PAYLOAD) if ordering.any? { |o| !%w[createdAt id].include?(o['field']) }
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
- matched = RSQL.filter(items, query['filter'])
406
- sorted = matched.sort { |a, b| compare(a, b, ordering) }
975
+ signature = JSON.generate([scope, ordering])
976
+ sorted = items.sort { |a, b| compare_items(a, b, ordering) }
407
977
  if query['cursor']
408
- begin
409
- boundary = JSON.parse(query['cursor'].unpack1('m0'))
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 ? [JSON.generate(selected.last.slice(*ordering.map { |o| o['field'] }))].pack('m0') : nil
422
- { 'items' => selected, 'nextCursor' => cursor,
423
- 'pageSize' => size, 'ordering' => ordering, 'hasMore' => more }
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 [{ 'field' => 'createdAt', 'direction' => 'DESC' }] if entries.empty?
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 compare(left, right, ordering)
436
- ordering.each do |order|
437
- a = left[order['field']]
438
- b = right[order['field']]
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
- left['id'].to_s <=> right['id'].to_s
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
- segments = relative.split(/[?#]/, 2).first.split('/').reject(&:empty?).map { |part| URI.decode_www_form_component(part) }
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
- return text(404, 'Asset not found') unless project == @project_id
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
- item = project_assets.find { |asset| asset['id'] == identifier || asset['externalId'] == identifier }
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
- return text(404, 'Asset content not found') unless stored
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
- bytes = stored['parts'].sort.map(&:last).join.b
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: { 'content-type' => stored['type'], 'content-length' => bytes.bytesize.to_s,
465
- 'x-request-id' => request_id })
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 text(status, message)
469
- Response.new(status: status, body: message,
470
- headers: { 'content-type' => 'text/plain; charset=utf-8', 'x-request-id' => request_id })
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 nested state of a new mock asset, and merging test fields into it.
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], 'approved' => %w[done approved],
481
- 'rejected' => %w[done rejected], 'failed' => %w[failed pending] }.freeze
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
- # The outcome of analyzed fields: `failed` when they fail processing, else their moderation status.
491
- def outcome_of(fields)
492
- fields.dig('processing', 'status') == 'failed' ? 'failed' : fields.dig('moderation', 'status') || 'approved'
493
- end
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 sections(now:, open:, keep:, format:, size:)
496
- {
497
- 'processing' => { 'status' => open ? 'uploading' : 'processing', 'updatedAt' => now,
498
- 'uploadPartsReceived' => open ? 1 : nil },
499
- 'moderation' => { 'status' => 'pending', 'action' => nil, 'note' => nil, 'reviewerAction' => nil,
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
- def deep_merge!(target, fields)
510
- fields.each do |key, value|
511
- if value.is_a?(Hash) && target[key].is_a?(Hash)
512
- deep_merge!(target[key], value)
513
- else
514
- target[key] = value
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
- target
1189
+ ->(item) { alternatives.any? { |terms| terms.all? { |test| test.call(item) } } }
518
1190
  end
519
- end
520
1191
 
521
- # The RSQL subset the fake API understands: `field==value` and `field!=value` (with `*`
522
- # wildcards and quoted values), combined with `;` (and) and `,` (or). Anything else raises
523
- # instead of being ignored, so a test never passes on an unfiltered list.
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
- def filter(items, expression)
529
- return items if expression.nil? || expression.to_s.strip.empty?
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
- groups = split(expression.to_s, ',').map { |group| split(group, ';').map { |term| comparison(term) } }
532
- items.select { |item| groups.any? { |terms| terms.all? { |test| test.call(item) } } }
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 == separator
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.]*)(==|!=)(.+)\z/m)
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
- value = raw.strip.match?(/\A(["']).*\1\z/m) ? raw.strip[1..-2] : raw.strip
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
- equal = Array(actual.nil? ? 'null' : actual).any? { |entry| pattern.match?(entry.to_s) }
565
- operator == '==' ? equal : !equal
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