calmlens 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -43,8 +43,7 @@ module CalmLens
43
43
  # semantic search.
44
44
  # @api private
45
45
  class FakeAPI
46
- JSON_QUERY = %w[wait isFinal sizeInBytes totalSizeInBytes partNumber pageSize processingOptions metadata
47
- tags context].freeze
46
+ JSON_QUERY = %w[wait isFinal sizeInBytes totalSizeInBytes partNumber pageSize].freeze
48
47
  # Keyword search fields, in the order matches are reported, and where each value sits in the asset.
49
48
  SEARCH_FIELDS = { 'name' => %w[name], 'id' => %w[id], 'externalId' => %w[externalId],
50
49
  'description' => %w[description], 'tags' => %w[tags], 'type' => %w[type],
@@ -112,6 +111,35 @@ module CalmLens
112
111
  # The mock API key's `{ 'actions', 'folderId', 'includeSubfolders' }` grants.
113
112
  attr_accessor :grants
114
113
 
114
+ # Splits an upload form as the API reads it: exactly an `options` part, then a `file` part, then
115
+ # the closing boundary.
116
+ # @return [Array(String, String), nil] the options part's text and the file's bytes, or nil
117
+ # when the body isn't such a form.
118
+ def self.upload_form(body, content_type)
119
+ boundary = content_type.to_s.match(%r{\Amultipart/form-data\s*;\s*boundary=(?:"([^"]+)"|([^;\s]+))}i)
120
+ return nil unless boundary
121
+
122
+ framed = "\r\n".b + body.to_s.b
123
+ delimiter = "\r\n--#{boundary[1] || boundary[2]}".b
124
+ return nil unless framed.start_with?(delimiter)
125
+
126
+ parts = []
127
+ at = 0
128
+ until framed.byteslice(at + delimiter.bytesize, 2) == '--'
129
+ start = at + delimiter.bytesize
130
+ header_end = framed.index("\r\n\r\n".b, start)
131
+ following = header_end && framed.index(delimiter, header_end + 4)
132
+ return nil unless following
133
+
134
+ name = framed.byteslice(start, header_end - start)[/content-disposition:\s*form-data\s*;[^\r\n]*?\bname="([^"]*)"/i, 1]
135
+ parts << [name, framed.byteslice(header_end + 4, following - header_end - 4)]
136
+ at = following
137
+ end
138
+ return nil unless parts.map(&:first) == %w[options file]
139
+
140
+ [parts[0][1].force_encoding(Encoding::UTF_8), parts[1][1]]
141
+ end
142
+
115
143
  def initialize(project_id:, base_url:, cdn_base_url:, now:, create_id:, moderate:, api_key:, grants:)
116
144
  @project_id = project_id
117
145
  @api_key = api_key
@@ -127,6 +155,12 @@ module CalmLens
127
155
  # reset `baseline`) and the one the project moderates with.
128
156
  @policies = {}
129
157
  @project_policy_id = nil
158
+ # The project's configuration: upload presets by key, the settings it has set, its metadata
159
+ # fields in display order and the revision the settings and fields share.
160
+ @upload_presets = {}
161
+ @project_settings = {}
162
+ @metadata_fields = []
163
+ @settings_revision = 1
130
164
  @request_count = 0
131
165
  # Each asset's latest `assetSequence`, which the API keeps on the asset row.
132
166
  @asset_event_sequences = Hash.new(0)
@@ -238,6 +272,9 @@ module CalmLens
238
272
  # Like the API: a queued re-run no longer replaces a reviewed decision.
239
273
  @reruns.delete(item['id'])
240
274
  apply_outcome(item, decision, 'requested')
275
+ # Like the API: the human review queue's decision settles its review, one through the API
276
+ # leaves it waiting.
277
+ item['classification']['moderation']['pendingReview'] = nil unless by_api_key
241
278
  review = { 'id' => @create_id.call('rev'), 'decision' => decision, 'note' => note,
242
279
  'decidedBy' => { 'type' => 'api-key', 'id' => nil }, 'reviewedAt' => @now.call, 'applied' => true }
243
280
  actor = by_api_key ? API_KEY_ACTOR : REVIEWER_ACTOR
@@ -357,19 +394,25 @@ module CalmLens
357
394
  json = content_type == 'application/json' && body && upload.nil? ? JSON.parse(body) : {}
358
395
  # HTTP stacks trim header values, so the API sees the key without surrounding whitespace.
359
396
  key = header(headers, 'Idempotency-Key')&.to_s&.gsub(/\A[ \t\r\n]+|[ \t\r\n]+\z/, '')
360
- return dispatch(method, parts, query, json, body, upload) unless key && method == 'POST'
397
+ request = { query: query, json: json, body: body, upload: upload, content_type: content_type }
398
+ return dispatch(method, parts, request) unless key && method == 'POST'
361
399
 
362
400
  fail!(ErrorCodes::INVALID_IDEMPOTENCY_KEY) unless key.match?(IDEMPOTENCY_KEY)
363
401
  # Like the API: a successful response is saved and replayed for the same key and request;
364
- # a failed one frees the key.
365
- fingerprint = [method, relative, content_type, Digest::SHA256.hexdigest(body.to_s)]
402
+ # a failed one frees the key. An upload form counts by its options and file, not its boundary.
403
+ form = upload && FakeAPI.upload_form(body, content_type)
404
+ fingerprint = if form
405
+ [method, relative, 'multipart/form-data', form[0], Digest::SHA256.hexdigest(form[1])]
406
+ else
407
+ [method, relative, content_type, Digest::SHA256.hexdigest(body.to_s)]
408
+ end
366
409
  saved = @state.idempotency[key]
367
410
  if saved
368
411
  fail!(ErrorCodes::IDEMPOTENCY_KEY_REUSED) unless saved[:fingerprint] == fingerprint
369
412
  return Response.new(status: saved[:status], body: saved[:body],
370
413
  headers: saved[:headers].merge('idempotent-replayed' => 'true', 'x-request-id' => request_id))
371
414
  end
372
- response = dispatch(method, parts, query, json, body, upload)
415
+ response = dispatch(method, parts, request)
373
416
  if response.success?
374
417
  @state.idempotency[key] = { fingerprint: fingerprint, status: response.status, body: response.body,
375
418
  headers: response.headers }
@@ -387,15 +430,17 @@ module CalmLens
387
430
  fail!(ErrorCodes::INVALID_UPLOAD_PAYLOAD, "#{key} is not valid JSON")
388
431
  end
389
432
 
390
- def dispatch(method, parts, query, json, body, upload)
433
+ def dispatch(method, parts, request)
434
+ query, json, body = request.values_at(:query, :json, :body)
391
435
  data = case parts.first
392
- when 'projects' then project(method, parts, query, json, body, upload)
436
+ when 'projects' then project(method, parts, request)
393
437
  when 'assets' then asset(method, parts, query, json, body)
394
438
  when 'folders' then folder(method, parts, json)
395
439
  when 'hash-lists' then hash_list(method, parts, query, json)
396
440
  when 'hash-list-entries' then remove_hash_list_entry(method, parts)
397
441
  when 'policies' then policies(method, parts, query, json)
398
442
  when 'lenses' then lens(method, parts, json)
443
+ when 'upload-presets' then upload_preset(method, parts)
399
444
  else route_not_found!(method, parts)
400
445
  end
401
446
  data.is_a?(Response) ? data : respond(200, deep_copy(data))
@@ -403,29 +448,40 @@ module CalmLens
403
448
 
404
449
  def deep_copy(value) = JSON.parse(JSON.generate(value))
405
450
 
406
- # `upload` is the X-CalmLens-Upload header, which picks how POST .../assets reads the
407
- # request: absent is a JSON body, 'content' raw file content and 'part' a raw first part.
408
- def project(method, parts, query, json, body, upload)
451
+ def project(method, parts, request)
409
452
  fail!(ErrorCodes::API_KEY_PROJECT_MISMATCH) unless parts[1] == @project_id
410
- case [method, parts[2..], upload]
411
- in ['GET', ['events'], _] then events(query)
412
- in ['PUT', ['lenses'], _] then lenses(json.fetch('lenses', []))
413
- in ['GET', ['lenses'], _] then page(@state.lenses.values, query, JSON.generate(['lenses', @project_id]))
414
- in ['POST', ['lenses'], _] then create_lens(json)
415
- in ['GET', ['folders'], _] then folder_page(query)
416
- in ['POST', ['folders'], _] then create_folder(json)
417
- in ['GET', ['hash-lists'], _] then page(project_hash_lists, query, JSON.generate([@project_id]))
418
- in ['POST', ['hash-lists'], _] then create_hash_list(json)
419
- in ['GET', ['policy'], _] then show_policy(find_policy(ensure_policies))
420
- in ['PUT', ['policy'], _] then show_policy(assign_policy(json))
421
- in ['GET', ['assets'], _] then list(query)
422
- in ['GET', ['assets', 'search'], _] then search(query)
423
- in ['GET', ['assets', String => asset_id, 'timeline'], _] then timeline(asset_id, query)
424
- in ['POST', ['assets', 'batch'], _] then batch(json)
425
- in ['POST', ['signed-asset-urls'], _] then sign_asset_url(json)
426
- in ['POST', ['assets'], nil] then create_asset(json, query['wait'] == false, url: json['url'])
427
- in ['POST', ['assets'], 'content'] then create_asset(query, query['wait'] == false, data: body)
428
- in ['POST', ['assets'], 'part'] then start_multipart(query, body)
453
+ query, json = request.values_at(:query, :json)
454
+ case [method, parts[2..]]
455
+ in ['GET', ['events']] then events(query)
456
+ in ['PUT', ['lenses']] then lenses(json.fetch('lenses', []))
457
+ in ['GET', ['lenses']] then page(@state.lenses.values, query, JSON.generate(['lenses', @project_id]))
458
+ in ['POST', ['lenses']] then create_lens(json)
459
+ in ['GET', ['folders']] then folder_page(query)
460
+ in ['POST', ['folders']] then create_folder(json)
461
+ in ['GET', ['hash-lists']] then page(project_hash_lists, query, JSON.generate([@project_id]))
462
+ in ['POST', ['hash-lists']] then create_hash_list(json)
463
+ in ['GET', ['policy']] then show_policy(find_policy(ensure_policies))
464
+ in ['PUT', ['policy']] then show_policy(assign_policy(json))
465
+ in ['GET', ['assets']] then list(query)
466
+ in ['GET', ['assets', 'search']] then search(query)
467
+ in ['GET', ['assets', String => asset_id, 'timeline']] then timeline(asset_id, query)
468
+ in ['POST', ['assets', 'batch']] then batch(json)
469
+ in ['POST', ['signed-asset-urls']] then sign_asset_url(json)
470
+ in ['POST', ['assets']] then create_upload(request)
471
+ else configuration(method, parts, query, json)
472
+ end
473
+ end
474
+
475
+ # A project's upload presets, settings and metadata fields.
476
+ def configuration(method, parts, query, json)
477
+ case [method, parts[2..]]
478
+ in ['GET', ['upload-presets']] then upload_preset_page(query)
479
+ in ['PUT', ['upload-presets']] then ensure_upload_presets(json)
480
+ in ['GET', ['settings']] then show_project_settings
481
+ in ['PUT', ['settings']] then update_project_settings(json)
482
+ in ['GET', ['metadata-fields']] then show_metadata_fields
483
+ in ['PUT', ['metadata-fields']] then ensure_metadata_fields(json)
484
+ in ['DELETE', ['metadata-fields', String => field_id]] then delete_metadata_field(field_id, query)
429
485
  else route_not_found!(method, parts)
430
486
  end
431
487
  end
@@ -560,6 +616,7 @@ module CalmLens
560
616
  old = @state.lenses[definition['identifier']]
561
617
  return new_lens(definition) unless old
562
618
 
619
+ definition = active_by_default(definition)
563
620
  same = %w[name type active].all? { |key| old[key] == definition[key] } &&
564
621
  old['description'] == definition['description'] && canonical(old['transform']) == canonical(definition['transform'])
565
622
  return old if same
@@ -569,10 +626,13 @@ module CalmLens
569
626
 
570
627
  def new_lens(definition)
571
628
  now = @now.call
572
- lens = { 'id' => @create_id.call('lens'), 'projectId' => @project_id }.merge(definition)
629
+ lens = { 'id' => @create_id.call('lens'), 'projectId' => @project_id }.merge(active_by_default(definition))
573
630
  @state.lenses[definition['identifier']] = lens.merge('createdAt' => now, 'updatedAt' => now)
574
631
  end
575
632
 
633
+ # Like the API, a lens is served unless its definition says otherwise.
634
+ def active_by_default(definition) = definition['active'].nil? ? definition.merge('active' => true) : definition
635
+
576
636
  # POST /projects/:id/lenses: like the API, the identifier must be new to the project.
577
637
  def create_lens(json)
578
638
  identifier = json['identifier']
@@ -606,6 +666,129 @@ module CalmLens
606
666
  end
607
667
  end
608
668
 
669
+ # A page of the project's upload presets, oldest first unless `sort` says otherwise.
670
+ def upload_preset_page(query)
671
+ page(@upload_presets.values, query, JSON.generate(['upload-presets', @project_id]),
672
+ default: { 'field' => 'createdAt', 'direction' => 'asc' })
673
+ end
674
+
675
+ # Like the API: every preset's revision and settings are checked before any is saved, and the
676
+ # stored presets come back in the order sent.
677
+ def ensure_upload_presets(json)
678
+ definitions = json['uploadPresets']
679
+ invalid!('validation/too-small', 'uploadPresets') unless definitions.is_a?(Array) && !definitions.empty?
680
+
681
+ plans = definitions.each_with_index.map do |definition, index|
682
+ %w[key name].each do |field|
683
+ value = definition[field]
684
+ invalid!('validation/invalid-type', "uploadPresets.#{index}.#{field}") unless value.is_a?(String) && !value.empty?
685
+ end
686
+ existing = @upload_presets[definition['key']]
687
+ stored = existing ? existing['revision'] : 0
688
+ if definition.key?('revision') && definition['revision'] != stored
689
+ fail!(ErrorCodes::UPLOAD_PRESET_REVISION_CONFLICT, details: { 'key' => definition['key'], 'revision' => stored })
690
+ end
691
+ values = upload_preset_values(definition)
692
+ limited = ![nil, 0].include?(values['dailyUploadLimit'])
693
+ fail!(ErrorCodes::UPLOAD_PRESET_DAILY_LIMIT_REQUIRED) if values['access'] == 'anyone' && !limited
694
+
695
+ [existing, values]
696
+ end
697
+ { 'uploadPresets' => plans.map { |existing, values| save_upload_preset(existing, values) } }
698
+ end
699
+
700
+ # The stored values a definition sets: fields left out take their defaults.
701
+ def upload_preset_values(definition)
702
+ given = ->(field, default = nil) { definition[field].nil? ? default : definition[field] }
703
+ list = ->(field) { definition[field].is_a?(Array) && !definition[field].empty? ? definition[field].uniq : nil }
704
+ { 'key' => definition['key'], 'name' => definition['name'], 'access' => given.call('access', 'token'),
705
+ 'enabled' => given.call('enabled', true), 'allowedContentTypes' => list.call('allowedContentTypes'),
706
+ 'maxSizeInBytes' => given.call('maxSizeInBytes'), 'folderId' => given.call('folderId'),
707
+ 'policyId' => given.call('policyId'), 'visibility' => given.call('visibility', 'private'),
708
+ 'processingOptions' => given.call('processingOptions'), 'revealDecision' => given.call('revealDecision', false),
709
+ 'dailyUploadLimit' => given.call('dailyUploadLimit'), 'allowedOrigins' => list.call('allowedOrigins') }
710
+ end
711
+
712
+ # Saves a preset's values under its key; an unchanged preset keeps its revision.
713
+ def save_upload_preset(existing, values)
714
+ return existing if existing && existing.slice(*values.keys) == values
715
+
716
+ now = @now.call
717
+ @upload_presets[values['key']] =
718
+ { 'id' => existing ? existing['id'] : @create_id.call('preset'), 'projectId' => @project_id, 'createdByUserId' => nil,
719
+ **values, 'revision' => (existing ? existing['revision'] : 0) + 1,
720
+ 'createdAt' => existing ? existing['createdAt'] : now, 'updatedAt' => now }
721
+ end
722
+
723
+ # DELETE /upload-presets/:id.
724
+ def upload_preset(method, parts)
725
+ route_not_found!(method, parts) unless method == 'DELETE' && parts.size == 2
726
+ preset = @upload_presets.values.find { |item| item['id'] == parts[1] }
727
+ fail!(ErrorCodes::UPLOAD_PRESET_NOT_FOUND) unless preset
728
+ @upload_presets.delete(preset['key'])
729
+ {}
730
+ end
731
+
732
+ def show_project_settings
733
+ settings = { 'optOutJurisdictions' => [], 'blockUnmoderatedAssets' => true, 'storeSensitiveMetadata' => false,
734
+ 'streamingEnabled' => false, 'aiMediaSearchEnabled' => false, 'remoteUrlRule' => nil,
735
+ 'websiteSubmissionsPerMinute' => nil }.merge(@project_settings)
736
+ settings['humanReview'] = { 'allowedSamplePercent' => 0, 'restrictedSamplePercent' => 0, 'uncertaintyMarginPercent' => 0,
737
+ 'reviewMissingEvidence' => false }.merge(@project_settings['humanReview'] || {})
738
+ { 'projectId' => @project_id, 'revision' => @settings_revision, 'settings' => settings }
739
+ end
740
+
741
+ # A revision to check, from a body or the query; none refuses nothing.
742
+ def check_settings_revision(revision)
743
+ return if revision.nil? || revision.to_s == @settings_revision.to_s
744
+
745
+ fail!(ErrorCodes::PROJECT_SETTINGS_REVISION_CONFLICT)
746
+ end
747
+
748
+ # Replaces the settings: those left out return to their defaults. A real change bumps the revision.
749
+ def update_project_settings(json)
750
+ check_settings_revision(json['revision'])
751
+ invalid!('validation/invalid-type', 'settings') unless json['settings'].is_a?(Hash)
752
+
753
+ before = show_project_settings['settings']
754
+ previous = @project_settings
755
+ @project_settings = json['settings']
756
+ if show_project_settings['settings'] == before
757
+ @project_settings = previous
758
+ else
759
+ @settings_revision += 1
760
+ end
761
+ show_project_settings
762
+ end
763
+
764
+ def show_metadata_fields = { 'projectId' => @project_id, 'revision' => @settings_revision, 'fields' => @metadata_fields }
765
+
766
+ # Creates or replaces fields by ID, keeping the others in order. A real change bumps the revision.
767
+ def ensure_metadata_fields(json)
768
+ check_settings_revision(json['revision'])
769
+ fields = json['fields']
770
+ invalid!('validation/too-small', 'fields') unless fields.is_a?(Array) && !fields.empty?
771
+
772
+ by_id = fields.to_h { |field| [field['id'], field] }
773
+ known = @metadata_fields.map { |field| field['id'] }
774
+ merged = @metadata_fields.map { |field| by_id.fetch(field['id'], field) } + fields.reject { |field| known.include?(field['id']) }
775
+ unless merged == @metadata_fields
776
+ @metadata_fields = merged
777
+ @settings_revision += 1
778
+ end
779
+ show_metadata_fields
780
+ end
781
+
782
+ def delete_metadata_field(field_id, query)
783
+ kept = @metadata_fields.reject { |field| field['id'] == field_id }
784
+ fail!(ErrorCodes::METADATA_FIELD_NOT_FOUND, details: { 'fieldId' => field_id }) if kept.size == @metadata_fields.size
785
+
786
+ check_settings_revision(query['revision'])
787
+ @metadata_fields = kept
788
+ @settings_revision += 1
789
+ show_metadata_fields
790
+ end
791
+
609
792
  # Keyword search like the API: every whitespace-separated token (case-insensitive, at most 8
610
793
  # distinct) must appear in some field. Results rank by the API's score, then newest first.
611
794
  def search(query)
@@ -663,7 +846,7 @@ module CalmLens
663
846
  keep = processing.is_a?(Hash) && processing['keepContentAfterProcessing'] == true
664
847
  context = options['context'].is_a?(Hash) ? ModerationContext.select_items(options['context'].fetch('items', [])) : []
665
848
  # Fields the API returns as null when they are not set.
666
- asset = { 'id' => @create_id.call('asset'), 'projectId' => @project_id, 'parentId' => nil,
849
+ asset = { 'id' => @create_id.call('asset'), 'projectId' => @project_id, 'parentId' => nil, 'parentPages' => [],
667
850
  'folderId' => options['folderId'], 'uploadPresetId' => options['uploadPresetId'],
668
851
  'createdAt' => now, 'updatedAt' => now, 'name' => options['name'], 'description' => options['description'],
669
852
  'externalId' => options['externalId'], 'externalUrl' => url || options['externalUrl'],
@@ -698,7 +881,8 @@ module CalmLens
698
881
  # Like the API, moderation's results are null until it decides, and a
699
882
  # feature's result is null until it has run.
700
883
  'moderation' => { 'status' => 'pending', 'manualReviews' => [], 'automatedStatus' => nil,
701
- 'source' => nil, 'rule' => nil, 'explanationComplete' => nil,
884
+ 'policy' => nil, 'pendingReview' => nil, 'source' => nil, 'rule' => nil,
885
+ 'explanationComplete' => nil,
702
886
  'categories' => nil, 'flaggedCategories' => [], 'regions' => [],
703
887
  'raw' => nil, 'timeline' => nil },
704
888
  'ocr' => nil, 'linkDetection' => nil }
@@ -729,13 +913,122 @@ module CalmLens
729
913
  fail!(ErrorCodes::INVALID_UPLOAD_PAYLOAD, 'Multipart upload parts must be at least 5MiB except the final part.')
730
914
  end
731
915
 
916
+ # `POST .../assets` as the API reads it. Asset fields in the query are refused; then the
917
+ # `X-CalmLens-Upload` header picks the body: absent is JSON (text, a first text part or a URL),
918
+ # `content` an upload form with a whole file and `part` one with a first part.
919
+ def create_upload(request)
920
+ query, json, body, upload = request.values_at(:query, :json, :body, :upload)
921
+ in_query = Assets::UPLOAD_OPTION_KEYS.select { |key| query.key?(key) }
922
+ unless in_query.empty?
923
+ fail!(ErrorCodes::UPLOAD_OPTIONS_IN_QUERY, "Send the asset's fields in the request body, not in the query string",
924
+ details: { 'fields' => in_query })
925
+ end
926
+ queue = query['wait'] == false
927
+ return create_from_json(json, queue) if upload.nil?
928
+
929
+ route_not_found!('POST', ['assets']) unless %w[content part].include?(upload)
930
+
931
+ options, bytes = form_fields(body, request[:content_type])
932
+ fields = options.merge('contentType' => query['contentType'])
933
+ return create_asset(apply_upload_preset(fields, bytes), queue, data: bytes) if upload == 'content'
934
+
935
+ start_multipart(apply_upload_preset(fields.merge(query.slice('isFinal', 'totalSizeInBytes')), bytes), bytes, queue)
936
+ end
937
+
938
+ # An API-key upload through a preset, as the API applies it: the preset gives the folder and
939
+ # visibility, which the upload can't change, and the processing options unless the upload sets
940
+ # its own, and refuses types and sizes it doesn't accept.
941
+ def apply_upload_preset(fields, data = nil)
942
+ return fields unless fields['uploadPresetId']
943
+
944
+ preset = @upload_presets.values.find { |item| item['id'] == fields['uploadPresetId'] }
945
+ fail!(ErrorCodes::UPLOAD_PRESET_NOT_FOUND) unless preset
946
+ fail!(ErrorCodes::UPLOAD_PRESET_DISABLED) unless preset['enabled']
947
+ check_preset_fields(preset, fields)
948
+ check_preset_limits(preset, fields, data)
949
+ fields.merge('folderId' => preset['folderId'] || fields['folderId'], 'visibility' => preset['visibility'],
950
+ 'processingOptions' => fields['processingOptions'] || deep_copy(preset['processingOptions']))
951
+ end
952
+
953
+ def check_preset_fields(preset, fields)
954
+ if preset['folderId'] && fields['folderId'] && fields['folderId'] != preset['folderId']
955
+ fail!(ErrorCodes::UPLOAD_PRESET_FIELD_NOT_ALLOWED, details: { 'field' => 'folderId' })
956
+ end
957
+ return unless fields['visibility'] && fields['visibility'] != preset['visibility']
958
+
959
+ fail!(ErrorCodes::UPLOAD_PRESET_FIELD_NOT_ALLOWED, details: { 'field' => 'visibility' })
960
+ end
961
+
962
+ def check_preset_limits(preset, fields, data)
963
+ allowed = preset['allowedContentTypes']
964
+ type = fields['contentType']
965
+ if allowed && type && allowed.none? { |pattern| preset_accepts?(pattern, fields['name'], type) }
966
+ fail!(ErrorCodes::UNSUPPORTED_UPLOAD_TYPE,
967
+ details: { 'source' => 'preset', 'contentType' => type, 'allowedContentTypes' => allowed })
968
+ end
969
+ limit = preset['maxSizeInBytes']
970
+ size = fields['totalSizeInBytes'] || data&.bytesize
971
+ fail!(ErrorCodes::UPLOAD_TOO_LARGE, details: { 'limitBytes' => limit, 'source' => 'preset' }) if limit && size && size > limit
972
+ end
973
+
974
+ # Whether an upload matches one entry of a preset's list, such as `image/*`, `text/plain` or `.pdf`.
975
+ def preset_accepts?(pattern, name, type)
976
+ pattern = pattern.strip.downcase
977
+ return true if ['*', '*/*'].include?(pattern)
978
+ return type.downcase.start_with?(pattern.delete_suffix('*')) if pattern.end_with?('/*')
979
+ return name.to_s.downcase.end_with?(pattern) if pattern.start_with?('.')
980
+
981
+ type.downcase == pattern
982
+ end
983
+
984
+ # A JSON create: text as `content` or a first `part` (`{ plainText, type }`), or a URL.
985
+ def create_from_json(json, queue)
986
+ key = %w[content part].find { |name| json.key?(name) }
987
+ return create_asset(apply_upload_preset(json), queue, url: json['url']) unless key
988
+
989
+ text = json[key]
990
+ invalid!('validation/invalid-type', key) unless text.is_a?(Hash) && text['plainText'].is_a?(String) && text['type'].is_a?(String)
991
+ fields = json.except('content', 'part', 'sizeInBytes', 'totalSizeInBytes').merge('contentType' => text['type'])
992
+ data = text['plainText'].encode(Encoding::UTF_8).b
993
+ return create_asset(apply_upload_preset(fields, data), queue, data: data) if key == 'content'
994
+
995
+ part = fields.merge('isFinal' => text['isFinal'] == true, 'totalSizeInBytes' => json['totalSizeInBytes'])
996
+ start_multipart(apply_upload_preset(part, data), data, queue)
997
+ end
998
+
999
+ # The asset fields in an upload form's options part, and the file's bytes. Like the API, a raw
1000
+ # upload must be `multipart/form-data` (`request/unsupported-content-type`, 415), holding an
1001
+ # `options` JSON object of known fields with a name, then a `file` part.
1002
+ def form_fields(body, content_type)
1003
+ mime = content_type.to_s.split(';').first.to_s.strip.downcase
1004
+ unless mime == 'multipart/form-data'
1005
+ fail!(ErrorCodes::UNSUPPORTED_CONTENT_TYPE, 'Send a raw upload as multipart/form-data: an options part, then a file part.',
1006
+ details: { 'contentType' => mime.empty? ? nil : mime })
1007
+ end
1008
+ form = FakeAPI.upload_form(body, content_type)
1009
+ fail!(ErrorCodes::INVALID_UPLOAD_PAYLOAD, 'The upload form must hold an options part, then a file part') unless form
1010
+
1011
+ options = begin
1012
+ JSON.parse(form[0])
1013
+ rescue JSON::ParserError
1014
+ nil
1015
+ end
1016
+ unless options.is_a?(Hash)
1017
+ fail!(ErrorCodes::INVALID_UPLOAD_PAYLOAD, "The options part must be a JSON object with the asset's fields")
1018
+ end
1019
+ unknown = options.keys.find { |name| !Assets::UPLOAD_OPTION_KEYS.include?(name) }
1020
+ invalid!('validation/unknown-field', unknown) if unknown
1021
+ invalid!('validation/invalid-type', 'name') unless options['name'].is_a?(String)
1022
+ [options, form[1]]
1023
+ end
1024
+
732
1025
  # In the API's order: a declared total, then a single final part as the whole upload, then the
733
1026
  # part itself. Whether to wait and the declared total are saved with an open upload.
734
- def start_multipart(query, data)
735
- check_upload(query)
736
- total = query['totalSizeInBytes']
737
- type = kind(query['contentType'].to_s, nil)
738
- final = query['isFinal'] == true
1027
+ def start_multipart(fields, data, queue)
1028
+ check_upload(fields)
1029
+ total = fields['totalSizeInBytes']
1030
+ type = kind(fields['contentType'].to_s, nil)
1031
+ final = fields['isFinal'] == true
739
1032
  size = data.to_s.bytesize
740
1033
  code = (total && image_size_error(type, total, true)) ||
741
1034
  (final ? total_size_error(total, size, true) || image_size_error(type, size, true) : nil)
@@ -743,8 +1036,8 @@ module CalmLens
743
1036
  check_part_size(size, final)
744
1037
  fail!(total_size_error(total, size, false)) if !final && total_size_error(total, size, false)
745
1038
 
746
- asset = create_asset(query, query['wait'] == false, data: data, open: !final)
747
- @state.uploads[asset['id']] = { 'queue' => query['wait'] == false, 'total' => total } unless final
1039
+ asset = create_asset(fields, queue, data: data, open: !final)
1040
+ @state.uploads[asset['id']] = { 'queue' => queue, 'total' => total } unless final
748
1041
  asset
749
1042
  end
750
1043
 
@@ -784,7 +1077,7 @@ module CalmLens
784
1077
  # faces in an image, so a decided image reports that the features its upload ran found nothing:
785
1078
  # OCR and person and face detection when turned on, link detection unless turned off.
786
1079
  def decided(asset, decision)
787
- apply_outcome(asset, decision.status, 'policy', decision.report)
1080
+ apply_outcome(asset, decision.status, 'policy', decision.report(@now.call))
788
1081
  classification = asset['classification']
789
1082
  return asset unless classification['status'] == 'completed' && asset['type'] == 'image'
790
1083
 
@@ -1507,9 +1800,9 @@ module CalmLens
1507
1800
  grant, item = resolve_asset(raw_segments[2])
1508
1801
  return grant if grant.is_a?(Response)
1509
1802
  return asset_not_found unless item && RESOURCES.include?(resource)
1510
- if resource == 'original' && grant && !grant[:original]
1511
- return error(ErrorCodes::SIGNED_URL_ORIGINAL_NOT_GRANTED, "This signed asset URL doesn't grant the original file", status: 403)
1512
- end
1803
+
1804
+ refused = refuse_viewer(item, resource, grant, headers)
1805
+ return refused if refused
1513
1806
 
1514
1807
  params = URI.decode_www_form(query.to_s).to_h
1515
1808
  # A grant authorises a private asset without the API key.
@@ -1521,6 +1814,18 @@ module CalmLens
1521
1814
  deliver(method, item, resource, params, selected ? options || {} : nil, authorised)
1522
1815
  end
1523
1816
 
1817
+ # What a grant doesn't cover, or, like the CDN, a private asset for anyone without a grant or the
1818
+ # project's API key: it answers as an unknown asset does, whatever its state, except for its
1819
+ # public placeholder and its info, which has its own rule. An error response, or nil.
1820
+ def refuse_viewer(item, resource, grant, headers)
1821
+ if resource == 'original' && grant && !grant[:original]
1822
+ return error(ErrorCodes::SIGNED_URL_ORIGINAL_NOT_GRANTED, "This signed asset URL doesn't grant the original file", status: 403)
1823
+ end
1824
+ return if grant || item['visibility'] == 'public' || header(headers, 'X-Api-Key') == @api_key
1825
+
1826
+ asset_not_found unless %w[placeholder info].include?(resource)
1827
+ end
1828
+
1524
1829
  # Whether the path after the resource selects a lens or transform, and its options; nil when the
1525
1830
  # CDN refuses it. Like the CDN, the adaptive stream's files sit beneath `/hls/` (its master
1526
1831
  # playlist is `/hls/index.m3u8`). Otherwise the selector after the resource is read
@@ -1559,7 +1864,7 @@ module CalmLens
1559
1864
  # A private asset's info needs a grant or an API key with `assets:download`.
1560
1865
  def info(method, item, authorised, params)
1561
1866
  restricted = item['visibility'] != 'public'
1562
- return error('asset/not-available', 'Asset is not available', status: 404) if restricted && !authorised
1867
+ return asset_not_found if restricted && !authorised
1563
1868
 
1564
1869
  claims = JSON.parse(params.fetch('viewerClaims', '{}'))
1565
1870
  body = { 'id' => item['id'], 'projectId' => item['projectId'], 'type' => item['type'], 'externalUrl' => item['externalUrl'],
@@ -5,11 +5,13 @@ require_relative 'classification'
5
5
  module CalmLens
6
6
  # A moderation decision for {MockServerClient}'s `moderate:` option or
7
7
  # {MockServerClient#complete_processing}, started with {.of}: flag the categories it found, with
8
- # their scores and qualifiers, and mark the categories it couldn't assess. Each call checks its
9
- # names and scores and raises `sdk/invalid-argument` for one CalmLens doesn't have.
8
+ # their scores and qualifiers, mark the categories it couldn't assess, and leave it waiting for
9
+ # human review. Each call checks its names and scores and raises `sdk/invalid-argument` for one
10
+ # CalmLens doesn't have.
10
11
  #
11
12
  # @example
12
13
  # CalmLens::MockDecision.of('blocked').flag('violence', 0.9, 'violence-threat' => 0.9).not_evaluated('profanity')
14
+ # CalmLens::MockDecision.of('limited').flag('violence', 0.5).pending_review('uncertain', ['violence'])
13
15
  class MockDecision
14
16
  # Where the mock's moderation leaves an upload: `pending` (still processing), `failed`
15
17
  # (processing failed, retryable), or a decision with that moderation status (`purged` also
@@ -19,6 +21,9 @@ module CalmLens
19
21
  STATUS_ACTIONS = { 'allowed' => 'allow', 'limited' => 'limit', 'age-restricted' => 'age-restrict', 'blocked' => 'block',
20
22
  'purged' => 'purge' }.freeze
21
23
  private_constant :STATUS_ACTIONS
24
+ # Why a decision waits for human review.
25
+ PENDING_REVIEW_REASONS = %w[sampled uncertain].freeze
26
+ private_constant :PENDING_REVIEW_REASONS
22
27
 
23
28
  # @return [String]
24
29
  attr_reader :status
@@ -81,17 +86,40 @@ module CalmLens
81
86
  self
82
87
  end
83
88
 
89
+ # Leaves the decision waiting for human review, as `classification.moderation.pendingReview`
90
+ # reports: `sampled`, or `uncertain` with the `uncertain_categories` that made it so.
91
+ # {MockServerClient#decide} clears it; {Assets#decide} doesn't, as with the API.
92
+ # @param reason [String] `sampled` or `uncertain`
93
+ # @param uncertain_categories [Array<String>]
94
+ # @return [self]
95
+ # @raise [InvalidArgumentError] for a `pending` or `failed` decision, another reason, a `sampled`
96
+ # reason with uncertain categories, or an unknown category
97
+ def pending_review(reason, uncertain_categories = [])
98
+ raise Internal.invalid("a #{@status} decision can't wait for review") if undecided?
99
+
100
+ reason = reason.to_s
101
+ raise Internal.invalid("reason must be one of #{PENDING_REVIEW_REASONS.join(', ')}") unless PENDING_REVIEW_REASONS.include?(reason)
102
+ raise Internal.invalid('uncertain_categories must be an Array') unless uncertain_categories.is_a?(Array)
103
+ raise Internal.invalid('a sampled review has no uncertain categories') if reason == 'sampled' && !uncertain_categories.empty?
104
+
105
+ @pending_review = { 'reason' => reason, 'uncertainCategories' => uncertain_categories.map { |category| known_category(category) } }
106
+ self
107
+ end
108
+
84
109
  # The report a decided asset gets, as the API sends it: every category (unflagged ones
85
110
  # evaluated with score 0), the policy's actions, the automated status and `flaggedCategories`.
111
+ # @param requested_at [Integer] when a pending review joined the queue, in epoch milliseconds
86
112
  # @return [Hash{String => Object}, nil] `categories`, `score` and `policy`; nil for `pending` and `failed`
87
- def report
113
+ def report(requested_at = 0)
88
114
  return if undecided?
89
115
 
90
116
  evaluated = (MockClassification::CATEGORIES - @not_evaluated).map { |category| [category, @flags.dig(category, :score) || 0] }
91
117
  { 'raw' => { 'categories' => MockClassification::CATEGORIES.to_h { |category| [category, category_report(category)] },
92
118
  'hashMatches' => [] },
93
- 'categories' => evaluated.to_h { |category, value| [category, { 'score' => value, 'action' => action(value) }] },
94
- 'explanationComplete' => true, 'source' => 'policy', 'rule' => nil, 'automatedStatus' => @status,
119
+ 'categories' => evaluated.to_h { |category, value| [category, policy_result(value)] },
120
+ # The mock keeps no policies, so no policy is named.
121
+ 'explanationComplete' => true, 'policy' => nil, 'source' => 'policy', 'rule' => nil, 'automatedStatus' => @status,
122
+ 'pendingReview' => @pending_review && { 'requestedAt' => requested_at, **@pending_review },
95
123
  'regions' => [], 'linkSafety' => nil, 'flaggedCategories' => evaluated.reject { |_, value| action(value) == 'allow' }.map(&:first) }
96
124
  end
97
125
 
@@ -101,9 +129,15 @@ module CalmLens
101
129
 
102
130
  def undecided? = %w[pending failed].include?(@status)
103
131
 
104
- def check_category(category)
132
+ def known_category(category)
105
133
  category = category.to_s
106
134
  raise Internal.invalid("#{category} is not a CalmLens category") unless MockClassification::CATEGORIES.include?(category)
135
+
136
+ category
137
+ end
138
+
139
+ def check_category(category)
140
+ category = known_category(category)
107
141
  raise Internal.invalid("a #{@status} decision has no findings") if undecided?
108
142
  raise Internal.invalid("#{category} is already in this decision") if @flags.key?(category) || @not_evaluated.include?(category)
109
143
 
@@ -114,6 +148,13 @@ module CalmLens
114
148
 
115
149
  def action(score) = @status != 'allowed' && score >= 0.5 ? STATUS_ACTIONS[@status] : 'allow'
116
150
 
151
+ # A category's policy result. The mock keeps no policies: the category's setting is the
152
+ # decision's own action.
153
+ def policy_result(score)
154
+ { 'score' => score, 'action' => action(score), 'source' => { 'kind' => score >= 0.5 ? 'default' : 'below-threshold' },
155
+ 'settings' => { 'action' => STATUS_ACTIONS[@status], 'allowances' => [], 'restrictions' => [] } }
156
+ end
157
+
117
158
  # The API's likelihood for a score (ClassificationCategories.ts, scoreToLikelihood).
118
159
  def likelihood(score)
119
160
  if score >= 0.8 then 'very-likely'