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.
- checksums.yaml +4 -4
- data/README.md +2 -1
- data/lib/calmlens/assets.rb +48 -17
- data/lib/calmlens/cdn.rb +2 -2
- data/lib/calmlens/error_codes.rb +20 -0
- data/lib/calmlens/errors.rb +9 -0
- data/lib/calmlens/lenses.rb +1 -1
- data/lib/calmlens/option_keys.rb +10 -1
- data/lib/calmlens/project_configuration.rb +113 -0
- data/lib/calmlens/server_client.rb +10 -1
- data/lib/calmlens/session.rb +2 -3
- data/lib/calmlens/testing/classification.rb +1 -1
- data/lib/calmlens/testing/fake_api.rb +350 -45
- data/lib/calmlens/testing/mock_decision.rb +47 -6
- data/lib/calmlens/testing/test_inputs.rb +5 -5
- data/lib/calmlens/testing.rb +30 -9
- data/lib/calmlens/transform_schema.rb +0 -14
- data/lib/calmlens/transport.rb +3 -0
- data/lib/calmlens/upload_body.rb +85 -2
- data/lib/calmlens/version.rb +1 -1
- data/lib/calmlens.rb +1 -0
- data/sig/calmlens.rbs +29 -1
- data/sig/error_codes.rbs +10 -0
- data/sig/qualifiers.rbs +1 -0
- data/sig/types.rbs +173 -23
- metadata +3 -2
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
in ['
|
|
413
|
-
in ['
|
|
414
|
-
in ['
|
|
415
|
-
in ['
|
|
416
|
-
in ['
|
|
417
|
-
in ['
|
|
418
|
-
in ['
|
|
419
|
-
in ['
|
|
420
|
-
in ['
|
|
421
|
-
in ['
|
|
422
|
-
in ['GET', ['assets'
|
|
423
|
-
in ['GET', ['assets',
|
|
424
|
-
in ['
|
|
425
|
-
in ['POST', ['
|
|
426
|
-
in ['POST', ['
|
|
427
|
-
in ['POST', ['assets']
|
|
428
|
-
|
|
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
|
-
'
|
|
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(
|
|
735
|
-
check_upload(
|
|
736
|
-
total =
|
|
737
|
-
type = kind(
|
|
738
|
-
final =
|
|
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(
|
|
747
|
-
@state.uploads[asset['id']] = { 'queue' =>
|
|
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
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
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
|
|
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,
|
|
9
|
-
# names and scores and raises `sdk/invalid-argument` for one
|
|
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,
|
|
94
|
-
|
|
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
|
|
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'
|