calmlens 0.18.2 → 0.19.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.
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ module CalmLens
6
+ # The image grid behind the signed test images (test_inputs.rb).
7
+ module TestInputs
8
+ # The test image grid of packages/shared/src/TestImageGrid.ts, painted byte for byte: an odd
9
+ # N×N grid with an alternating black-and-white ring, two Gray-coded bits per interior cell as
10
+ # one of four luma levels (colour in chroma only), and Reed–Solomon parity over GF(256).
11
+ # @api private
12
+ module Grid
13
+ # Target luma of the four data levels.
14
+ LEVELS = [72, 128, 184, 240].freeze
15
+ SYMBOL_LEVEL = [0, 1, 3, 2].freeze
16
+ PARITY = 32
17
+ # Category colours, carried in the chroma only.
18
+ COLORS = {
19
+ 'sexual' => [232, 80, 160], 'violence' => [224, 56, 64], 'self-harm' => [24, 168, 152],
20
+ 'harassment' => [240, 128, 48], 'hate' => [144, 80, 216], 'criminal-activity' => [224, 184, 40],
21
+ 'personal-information' => [48, 128, 224], 'profanity' => [136, 192, 48]
22
+ }.freeze
23
+ NEUTRAL = [144, 152, 160].freeze
24
+
25
+ # GF(256) with the polynomial 0x11d and generator 2.
26
+ EXP = Array.new(512, 0)
27
+ LOG = Array.new(256, 0)
28
+ x = 1
29
+ 255.times do |i|
30
+ EXP[i] = x
31
+ LOG[x] = i
32
+ x <<= 1
33
+ x ^= 0x11d if x.anybits?(256)
34
+ end
35
+ (255...512).each { |i| EXP[i] = EXP[i - 255] }
36
+ [EXP, LOG].each(&:freeze)
37
+
38
+ module_function
39
+
40
+ def mul(left, right) = left.zero? || right.zero? ? 0 : EXP[LOG[left] + LOG[right]]
41
+
42
+ # g(x) = (x - a^0)...(x - a^31), highest coefficient first.
43
+ GENERATOR = PARITY.times.reduce([1]) do |g, i|
44
+ following = Array.new(g.size + 1, 0)
45
+ g.each_with_index do |coefficient, j|
46
+ following[j] ^= coefficient
47
+ following[j + 1] ^= mul(coefficient, EXP[i])
48
+ end
49
+ following
50
+ end.freeze
51
+
52
+ def parity(block)
53
+ parity = Array.new(PARITY, 0)
54
+ block.each do |byte|
55
+ factor = byte ^ parity[0]
56
+ parity = parity.drop(1) << 0
57
+ PARITY.times { |j| parity[j] ^= mul(GENERATOR[j + 1], factor) } unless factor.zero?
58
+ end
59
+ parity
60
+ end
61
+
62
+ # The codeword bytes a grid of `cells` holds, its block lengths and its data bytes.
63
+ def layout(cells)
64
+ capacity = (cells - 2) * (cells - 2) / 4
65
+ blocks = (capacity + 254) / 255
66
+ lengths = Array.new(blocks) { |index| (capacity / blocks) + (index < capacity % blocks ? 1 : 0) }
67
+ [capacity, lengths, capacity - (blocks * PARITY)]
68
+ end
69
+
70
+ # The colour's chroma on the level's luma, scaled by the largest s/16 that keeps every
71
+ # channel in 0-255. Each scaled difference is truncated toward zero (not floored).
72
+ def pixel(colour, level)
73
+ target = LEVELS[level]
74
+ own = ((77 * colour[0]) + (150 * colour[1]) + (29 * colour[2])) >> 8
75
+ 16.downto(0) do |scale|
76
+ pixel = colour.map do |channel|
77
+ difference = channel - own
78
+ magnitude = (difference.abs * scale) >> 4
79
+ difference.negative? ? target - magnitude : target + magnitude
80
+ end
81
+ return pixel if pixel.all? { |channel| channel.between?(0, 255) }
82
+ end
83
+ end
84
+
85
+ # The data cell's colour: the palette blended between opposite corners, in integers.
86
+ def blend(palette, column, row, cells)
87
+ span = cells - 1
88
+ denominator = span * span
89
+ position = ((column * (span - row)) + ((span - column) * row)) * (palette.size - 1)
90
+ start = position / denominator
91
+ remainder = position - (start * denominator)
92
+ finish = palette[[start + 1, palette.size - 1].min]
93
+ palette[start].each_with_index.map do |channel, c|
94
+ ((channel * (denominator - remainder)) + (finish[c] * remainder)) / denominator
95
+ end
96
+ end
97
+
98
+ # Paints `data` (a binary String) as a grid of the categories' colours: [width, RGBA String].
99
+ def paint(data, categories)
100
+ cells = (15..127).step(2).find { |size| layout(size)[2] >= data.bytesize }
101
+ raise Internal.invalid('Test scenario is too large for a test image') unless cells
102
+
103
+ stream = codeword_stream(data, cells)
104
+ palette = categories.uniq.map { |category| COLORS.fetch(category) }
105
+ palette = [NEUTRAL] if palette.empty?
106
+ pixels = Array.new(cells * cells) { |cell| [*cell_pixel(stream, palette, cell, cells), 255].pack('C4') }
107
+ cell_size = 2 * (512 / 2 / cells)
108
+ rows = Array.new(cells) { |row| pixels[row * cells, cells].map { |pixel| pixel * cell_size }.join * cell_size }
109
+ [cells * cell_size, rows.join.b]
110
+ end
111
+
112
+ # The padded data with each block's parity, the blocks interleaved byte by byte and whitened, so
113
+ # a uniform area never reads as the all-zero codeword.
114
+ def codeword_stream(data, cells)
115
+ _, lengths, data_bytes = layout(cells)
116
+ padded = data.bytes + Array.new(data_bytes - data.bytesize) { |i| i.odd? ? 0x11 : 0xec }
117
+ offset = 0
118
+ codewords = lengths.map do |length|
119
+ block = padded[offset, length - PARITY]
120
+ offset += block.size
121
+ block + parity(block)
122
+ end
123
+ stream = Array.new(lengths.max) { |i| codewords.filter_map { |codeword| codeword[i] } }.flatten
124
+ stream.each_with_index.map { |byte, i| byte ^ (((i * 151) + 77) & 255) }
125
+ end
126
+
127
+ # A cell's RGB: the ring alternates black and white; data cells hold two bits each.
128
+ def cell_pixel(stream, palette, cell, cells)
129
+ row, column = cell.divmod(cells)
130
+ return [(row + column).odd? ? 255 : 0] * 3 if [row, column].any? { |at| at.zero? || at == cells - 1 }
131
+
132
+ symbol = ((row - 1) * (cells - 2)) + column - 1
133
+ index = symbol >> 2
134
+ level = index < stream.size ? SYMBOL_LEVEL[(stream[index] >> (6 - (2 * (symbol & 3)))) & 3] : 0
135
+ pixel(blend(palette, column, row, cells), level)
136
+ end
137
+ end
138
+
139
+ # The PNG encoding of test images.
140
+ # @api private
141
+ module Png
142
+ module_function
143
+
144
+ # 8-bit RGBA rows without filtering in stored deflate blocks, so every SDK writes the same bytes.
145
+ def png(width, height, rgba)
146
+ stride = width * 4
147
+ raw = Array.new(height) { |row| "\0".b + rgba.byteslice(row * stride, stride) }.join
148
+ zlib = "\x78\x01".b
149
+ blocks = (0...raw.bytesize).step(65_535).map { |offset| raw.byteslice(offset, 65_535) }
150
+ blocks.each_with_index do |block, index|
151
+ zlib << [index == blocks.size - 1 ? 1 : 0, block.bytesize, block.bytesize ^ 0xffff].pack('Cvv') << block
152
+ end
153
+ zlib << [Zlib.adler32(raw)].pack('N')
154
+ chunk = ->(type, body) { [body.bytesize].pack('N') + type.b + body + [Zlib.crc32(type.b + body)].pack('N') }
155
+ "\x89PNG\r\n\x1a\n".b + chunk.call('IHDR', [width, height, 8, 6, 0, 0, 0].pack('NNC5')) +
156
+ chunk.call('IDAT', zlib) + chunk.call('IEND', ''.b)
157
+ end
158
+ end
159
+ private_constant :Grid, :Png
160
+ end
161
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'classification'
4
+
5
+ module CalmLens
6
+ # The scenario checks behind the signed test inputs (test_inputs.rb).
7
+ module TestInputs
8
+ # Checks test input scenarios and writes them as the backend's canonical JSON.
9
+ # @api private
10
+ module Scenario
11
+ ACTIONS = %w[allow limit age-restrict block].freeze
12
+ REGIONAL_ACTIONS = %w[limit age-restrict block].freeze
13
+ # The qualifiers a scenario may list for each category: all but those the input type sets.
14
+ ALLOWED_QUALIFIERS = MockClassification::QUALIFIERS.transform_values do |list|
15
+ (list - MockClassification::INPUT_QUALIFIERS).freeze
16
+ end.freeze
17
+
18
+ module_function
19
+
20
+ # A checked scenario's JSON in the backend's key order. Every string is a validated ASCII token.
21
+ def canonical_json(value)
22
+ case value
23
+ when Hash then "{#{value.map { |key, item| "\"#{key}\":#{canonical_json(item)}" }.join(',')}}"
24
+ when Array then "[#{value.map { |item| canonical_json(item) }.join(',')}]"
25
+ when Float then js_number(value)
26
+ else "\"#{value}\""
27
+ end
28
+ end
29
+
30
+ # A score as JavaScript's JSON.stringify writes it: the shortest round-tripping digits, in
31
+ # exponent form below 1e-6, and 0 for -0.
32
+ def js_number(value)
33
+ return '0' if value.zero?
34
+
35
+ mantissa, exponent = value.to_s.split('e')
36
+ whole, fraction = mantissa.split('.')
37
+ digits = whole + fraction.to_s
38
+ point = whole.size + exponent.to_i
39
+ stripped = digits.sub(/\A0+/, '')
40
+ point -= digits.size - stripped.size
41
+ digits = stripped.sub(/0+\z/, '')
42
+ if value < 1e-6
43
+ "#{digits[0]}#{".#{digits[1..]}" if digits.size > 1}e#{point - 1}"
44
+ elsif point <= 0 then "0.#{'0' * -point}#{digits}"
45
+ elsif point >= digits.size then digits + ('0' * (point - digits.size))
46
+ else "#{digits[0, point]}.#{digits[point..]}"
47
+ end
48
+ end
49
+
50
+ # Checks a scenario and returns it with camelCase String keys in canonical order.
51
+ def validate(input)
52
+ input = fields(input, 'scenario', %w[regionalActions unassessedCategories instances], %w[instances])
53
+ instances = array(input['instances'], 'instances', 128).map { |instance| check_instance(instance) }
54
+ with_action = instances.count { |instance| instance.key?('action') }
55
+ check([0, instances.size].include?(with_action), 'Set an action for every instance or omit all actions')
56
+ checked = {}
57
+ if input.key?('regionalActions')
58
+ checked['regionalActions'] = array(input['regionalActions'], 'regionalActions', REGIONS.size).each_with_object([]) do |item, done|
59
+ item = fields(item, 'regional action', %w[region action], %w[region action])
60
+ check(REGIONS.include?(item['region']) && done.none? { |other| other['region'] == item['region'] },
61
+ "Regional actions need unique regions from #{REGIONS.join(', ')} (got #{item['region'].inspect})")
62
+ check(REGIONAL_ACTIONS.include?(item['action']), "Regional action must be one of #{REGIONAL_ACTIONS.join(', ')}")
63
+ done << { 'region' => item['region'], 'action' => item['action'] }
64
+ end
65
+ end
66
+ if input.key?('unassessedCategories')
67
+ unassessed = array(input['unassessedCategories'], 'unassessedCategories', MockClassification::CATEGORIES.size)
68
+ unassessed.each_with_index do |category, index|
69
+ check(MockClassification::CATEGORIES.include?(category) && !unassessed[0, index].include?(category) &&
70
+ instances.none? { |instance| instance['category'] == category },
71
+ "Unassessed categories must be unique categories without instances (got #{category.inspect})")
72
+ end
73
+ checked['unassessedCategories'] = unassessed
74
+ end
75
+ checked.merge('instances' => instances)
76
+ end
77
+
78
+ def check_instance(instance)
79
+ instance = fields(instance, 'instance', %w[category score action qualifiers], %w[category qualifiers])
80
+ category = instance['category']
81
+ allowed = ALLOWED_QUALIFIERS[category] if category.is_a?(String)
82
+ check(allowed, "Unknown category #{category.inspect}")
83
+ checked = { 'category' => category }
84
+ if instance.key?('score')
85
+ score = instance['score']
86
+ check(score.is_a?(Numeric) && score.real? && score.to_f.between?(0, 1), 'Score must be a number from 0 to 1')
87
+ checked['score'] = score.to_f
88
+ end
89
+ if instance.key?('action')
90
+ check(ACTIONS.include?(instance['action']), "Action must be one of #{ACTIONS.join(', ')}")
91
+ checked['action'] = instance['action']
92
+ end
93
+ qualifiers = array(instance['qualifiers'], 'qualifiers', 64)
94
+ qualifiers.each_with_index do |qualifier, index|
95
+ check(allowed.include?(qualifier) && !qualifiers[0, index].include?(qualifier),
96
+ "Choose unique #{category} qualifiers; input-type qualifiers are automatic (got #{qualifier.inspect})")
97
+ end
98
+ checked.merge('qualifiers' => qualifiers)
99
+ end
100
+
101
+ # A Hash's entries by camelCase key, refusing unknown, duplicate and missing keys.
102
+ def fields(value, name, allowed, required)
103
+ check(value.is_a?(Hash), "A test #{name} must be a Hash (got #{value.class})")
104
+ result = {}
105
+ value.each do |key, item|
106
+ field = Internal.camelize(key) if key.is_a?(String) || key.is_a?(Symbol)
107
+ check(allowed.include?(field), "Unknown test #{name} field #{key.inspect}")
108
+ check(!result.key?(field), "Test #{name} field #{field} is given twice")
109
+ result[field] = item
110
+ end
111
+ missing = required - result.keys
112
+ check(missing.empty?, "A test #{name} needs #{missing.join(', ')}")
113
+ result
114
+ end
115
+
116
+ def array(value, name, max)
117
+ check(value.is_a?(Array), "#{name} must be an Array")
118
+ check(value.size <= max, "#{name} has at most #{max} items")
119
+ value.dup
120
+ end
121
+
122
+ def check(condition, message) = condition || raise(Internal.invalid(message))
123
+ end
124
+
125
+ private_constant :Scenario
126
+ end
127
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require_relative 'classification'
5
+ require_relative 'test_input_scenario'
6
+ require_relative 'test_image_grid'
7
+
8
+ module CalmLens
9
+ # Signed test inputs: text codes, test images and media phrases that a project's moderation
10
+ # recognises as a declared scenario when they are signed with its secret test input key (shown in
11
+ # the portal's project settings), so CalmLens returns that scenario instead of classifying them.
12
+ # The formats match the JavaScript SDK's `calmlens/test-inputs` byte for byte.
13
+ #
14
+ # A scenario is a Hash with the API's keys, as Strings or Symbols (snake_case keys are converted
15
+ # to camelCase like other options):
16
+ # `{ 'regionalActions' => [{ 'region' => 'DE', 'action' => 'block' }], 'unassessedCategories' => ['profanity'],
17
+ # 'instances' => [{ 'category' => 'harassment', 'score' => 0.9, 'action' => 'block', 'qualifiers' => ['harassment-abuse'] }] }`.
18
+ # `instances` (at most 128) and each instance's `qualifiers` (unique qualifiers of its category, at
19
+ # most 64; input-type qualifiers such as `sexual-text` are automatic) are required; `score` (0 to
20
+ # 1) and `action` (`allow`, `limit`, `age-restrict` or `block`, on every instance or none) are
21
+ # optional. `unassessedCategories` lists categories reported as not assessed, none with instances;
22
+ # `regionalActions` gives up to one `limit`, `age-restrict` or `block` action for each of
23
+ # {REGIONS}. Values are Strings and Numerics. An absent list is left out of the signed scenario;
24
+ # an empty one is sent as `[]`. Anything else raises `sdk/invalid-argument`.
25
+ #
26
+ # @example
27
+ # code = CalmLens::TestInputs.generate_text_code('harassment.insult', test_input_key)
28
+ # png = CalmLens::TestInputs.paint_image('violence', test_input_key).png
29
+ # phrase = CalmLens::TestInputs.generate_media_code('violence', test_input_key)
30
+ module TestInputs
31
+ # A painted test image: `width` by `height` pixels of 8-bit RGBA, row by row, in the binary
32
+ # String `rgba`, and the same image as binary PNG bytes in `png`.
33
+ Image = Struct.new(:width, :height, :rgba, :png, keyword_init: true)
34
+
35
+ # The regions a regional action can name.
36
+ REGIONS = %w[DE AT FR GB EU AU BR].freeze
37
+ # The signals {generate_text_code}, {paint_image} and {for_signal} accept: the categories and
38
+ # five named shortcuts.
39
+ SIGNALS = (MockClassification::CATEGORIES +
40
+ %w[sexual.involving-minors harassment.insult sexual.adult hate.regional-symbol violence.graphic]).freeze
41
+ # The signals {generate_media_code} accepts.
42
+ MEDIA_SIGNALS = (MockClassification::CATEGORIES + %w[violence.graphic]).freeze
43
+
44
+ one = ->(category, *qualifiers) { { 'instances' => [{ 'category' => category, 'qualifiers' => qualifiers }] } }
45
+ deep_freeze = lambda do |value|
46
+ value.each_value { |item| deep_freeze.call(item) } if value.is_a?(Hash)
47
+ value.each { |item| deep_freeze.call(item) } if value.is_a?(Array)
48
+ value.freeze
49
+ end
50
+ # Ready-made scenarios (deeply frozen): a clean input, a limited one, adult images and text,
51
+ # regional delivery, content restricted in Germany, and mixed instances in one category.
52
+ PRESETS = deep_freeze.call(
53
+ clean: { 'instances' => [] },
54
+ limited: one.call('harassment', 'harassment-abuse'),
55
+ adult_image: one.call('sexual', 'sexual-pornographic-media'),
56
+ adult_text: one.call('sexual', 'sexual-explicit-language'),
57
+ regional_delivery: { 'instances' => [], 'regionalActions' => [{ 'region' => 'DE', 'action' => 'block' },
58
+ { 'region' => 'GB', 'action' => 'age-restrict' },
59
+ { 'region' => 'FR', 'action' => 'limit' }] },
60
+ germany: one.call('hate', 'hate-symbol', 'hate-nazi-symbols-public-display'),
61
+ mixed: { 'instances' => [{ 'category' => 'harassment', 'qualifiers' => %w[harassment-abuse harassment-documentary] },
62
+ { 'category' => 'harassment', 'qualifiers' => ['harassment-threat'] }] }
63
+ )
64
+
65
+ HEX_WORDS = %w[zero one two three four five six seven eight nine alpha bravo charlie delta echo foxtrot].freeze
66
+ private_constant :HEX_WORDS
67
+
68
+ module_function
69
+
70
+ # The scenario a signal (one of {SIGNALS}) stands for in a text input (`kind` `'text'`) or an
71
+ # image (`'image'`), as a new Hash. {generate_text_code} uses the text kind and {paint_image}
72
+ # the image kind.
73
+ # @param signal [String]
74
+ # @param kind [String] `'text'` or `'image'`
75
+ # @return [Hash{String => Object}]
76
+ # @raise [InvalidArgumentError] `sdk/invalid-argument` for an unknown signal or kind
77
+ def for_signal(signal, kind = 'text')
78
+ raise Internal.invalid("Test input kind must be text or image (got #{kind.inspect})") unless %w[text image].include?(kind)
79
+
80
+ preset = case signal
81
+ when 'harassment.insult' then PRESETS[:limited]
82
+ when 'sexual.adult' then PRESETS[kind == 'image' ? :adult_image : :adult_text]
83
+ when 'hate.regional-symbol' then PRESETS[:germany]
84
+ when 'sexual.involving-minors' then { 'instances' => [{ 'category' => 'sexual', 'qualifiers' => ['sexual-minors'] }] }
85
+ when 'violence.graphic' then { 'instances' => [{ 'category' => 'violence', 'qualifiers' => ['violence-graphic'] }] }
86
+ when *MockClassification::CATEGORIES then { 'instances' => [{ 'category' => signal, 'qualifiers' => [] }] }
87
+ else raise Internal.invalid("Unknown test signal #{signal.inspect}")
88
+ end
89
+ Marshal.load(Marshal.dump(preset))
90
+ end
91
+
92
+ # The signed text code for a scenario or a signal (one of {SIGNALS}): put it anywhere in a text
93
+ # input.
94
+ # @param input [Hash, String] a scenario or a signal
95
+ # @param test_input_key [String] the project's secret test input key from the portal
96
+ # @return [String]
97
+ # @raise [InvalidArgumentError] `sdk/invalid-argument` for an invalid key, scenario or signal
98
+ def generate_text_code(input, test_input_key)
99
+ text_code(scenario(input, 'text', test_input_key), test_input_key)
100
+ end
101
+
102
+ # Paints the signed test image for a scenario or a signal (one of {SIGNALS}): a grid that
103
+ # survives lossy formats and resizing. Upload its `png`, or encode its `rgba` in any format.
104
+ # @param input [Hash, String] a scenario or a signal
105
+ # @param test_input_key [String] the project's secret test input key from the portal
106
+ # @return [Image]
107
+ # @raise [InvalidArgumentError] `sdk/invalid-argument` for an invalid key, scenario or signal, or
108
+ # a scenario too large for a test image
109
+ def paint_image(input, test_input_key)
110
+ checked = scenario(input, 'image', test_input_key)
111
+ signature = text_code(checked, test_input_key).split[1]
112
+ content = Scenario.canonical_json(checked).b
113
+ data = [content.bytesize].pack('n') + [signature].pack('H*') + content
114
+ width, rgba = Grid.paint(data, checked['instances'].map { |instance| instance['category'] })
115
+ Image.new(width: width, height: width, rgba: rgba, png: Png.png(width, width, rgba))
116
+ end
117
+
118
+ # The phrase that triggers a signal (one of {MEDIA_SIGNALS}) in audio or video: read it aloud,
119
+ # or show it as large, high-contrast text in a frame.
120
+ # @param signal [String]
121
+ # @param test_input_key [String] the project's secret test input key from the portal
122
+ # @return [String]
123
+ # @raise [InvalidArgumentError] `sdk/invalid-argument` for an invalid key or signal
124
+ def generate_media_code(signal, test_input_key)
125
+ raise Internal.invalid("Unsupported media test signal #{signal.inspect}") unless MEDIA_SIGNALS.include?(signal)
126
+
127
+ check_key(test_input_key)
128
+ words = tag(test_input_key, "calmlens-test:media:#{signal}").each_char.map { |digit| HEX_WORDS[digit.to_i(16)] }
129
+ ['Calm Lens test', *words, signal.tr('.-', ' '), 'end test'].join(' ')
130
+ end
131
+
132
+ # The checked scenario for an input or signal, in canonical form.
133
+ def scenario(input, kind, test_input_key)
134
+ check_key(test_input_key)
135
+ Scenario.validate(input.is_a?(String) ? for_signal(input, kind) : input)
136
+ end
137
+
138
+ def text_code(checked, test_input_key)
139
+ payload = [Scenario.canonical_json(checked)].pack('m0').tr('+/', '-_').delete('=')
140
+ raise Internal.invalid('Test scenario is too large') if payload.bytesize > 8000
141
+
142
+ "<!calmlens-test #{tag(test_input_key, "calmlens-test:v1:#{payload}")} #{payload}>"
143
+ end
144
+
145
+ def check_key(key)
146
+ return if key.is_a?(String) && key.match?(/\A[a-f0-9]{32}\z/)
147
+
148
+ raise Internal.invalid('The test input key must be 32 lowercase hexadecimal characters')
149
+ end
150
+
151
+ # The first 32 hex characters of HMAC-SHA256 with the key's ASCII bytes.
152
+ def tag(key, message) = OpenSSL::HMAC.hexdigest('SHA256', key, message)[0, 32]
153
+
154
+ private_class_method :scenario, :text_code, :check_key, :tag
155
+ end
156
+ end
@@ -5,6 +5,7 @@ require 'securerandom'
5
5
  require 'uri'
6
6
  require_relative '../calmlens'
7
7
  require_relative 'testing/fake_api'
8
+ require_relative 'testing/test_inputs'
8
9
 
9
10
  module CalmLens
10
11
  # An offline client for tests. Calls run the real client's validation, encoding, retries and
@@ -63,6 +64,7 @@ module CalmLens
63
64
  retry explain wait_for_decision delete_content delete batch],
64
65
  folders: %i[list retrieve create update delete move],
65
66
  hash_lists: %i[list retrieve create update delete list_entries add_entry remove_entry],
67
+ policies: %i[list retrieve create update apply_preset set_automatic_updates delete assign],
66
68
  text: %i[classify], events: %i[list],
67
69
  cdn: %i[serve] }.freeze
68
70
  private_constant :OPERATIONS
@@ -144,7 +146,8 @@ module CalmLens
144
146
  # runs while the mock handles a request, so it must not call the mock.
145
147
  # @param max_retries [Integer] retries after a retryable failure; 0 by default, so a simulated
146
148
  # failure reaches the caller.
147
- # @param options [Hash] other {ServerClient#initialize} settings, except `api_key` and `transport`.
149
+ # @param options [Hash] other {ServerClient#initialize} settings, such as `part_size_bytes`, except
150
+ # `api_key` and `transport`.
148
151
  # Retries and decision checks wait no real time.
149
152
  def initialize(project_id: 'project-1', api_key: MockServerClient.create_api_key, base_url: 'https://api.calmlens.invalid/v1',
150
153
  cdn_base_url: 'https://cdn.calmlens.invalid/v1', now: MockServerClient.monotonic_clock,
@@ -338,6 +341,8 @@ module CalmLens
338
341
  path, query = Internal.split_url(request.url).last.split('?', 2)
339
342
  base = request.url.start_with?("#{cdn_base_url}/") ? URI(cdn_base_url).path : URI(base_url).path
340
343
  call = Thread.current[@call_key]
344
+ # The later parts of a content upload sent in parts count as assets.uploadPart.
345
+ call = { operation: 'assets.uploadPart' } if request.method == 'POST' && path.delete_prefix(base).match?(%r{\A/assets/[^/]+/part\z})
341
346
  @mock_lock.synchronize do
342
347
  record = Request.new(method: request.method, url: request.url, path: decode_path(path.delete_prefix(base)),
343
348
  query: URI.decode_www_form(query.to_s).to_h, headers: request.headers.dup, body: body)
data/lib/calmlens/text.rb CHANGED
@@ -3,6 +3,9 @@
3
3
  module CalmLens
4
4
  # Text moderation shortcuts. Reach it through {ServerClient#text}.
5
5
  class Text < Resource
6
+ # The prefix of this resource's operations in Internal::OPTION_KEYS.
7
+ OPERATIONS = 'text'
8
+
6
9
  # @api private
7
10
  def initialize(session, assets)
8
11
  super(session)
@@ -17,11 +20,9 @@ module CalmLens
17
20
  # @return [Hash] the asset; show it to everyone only when `classification.moderation.status` is
18
21
  # `allowed` or `limited` (reduce the reach of `limited` content).
19
22
  def classify(options)
20
- api, request = call(options) { |*split| split }
23
+ api, request = call(options, __method__) { |*split| split }
21
24
  text = api['text']
22
- unless text.is_a?(String) && !text.strip.empty?
23
- raise Internal.invalid('Text to classify must be a non-empty string', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
24
- end
25
+ raise Internal.invalid('Text to classify must be a non-empty string') unless text.is_a?(String) && !text.strip.empty?
25
26
 
26
27
  @assets.classify(without(api, 'text').merge('name' => api.fetch('name', 'text'),
27
28
  'content' => { 'plainText' => text, 'type' => 'text/plain' },
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CalmLens
4
- # CDN transform selectors: selector Strings and structured steps serialized the way every SDK does.
4
+ # CDN transform selectors: selector Strings and structured options serialized the way every SDK does.
5
5
  # @api private
6
6
  module Transforms
7
7
  module_function
@@ -14,46 +14,39 @@ module CalmLens
14
14
  # </generated: image-transform-colours>
15
15
  HEX_COLOUR = /\A#((?:\h{3}){1,2}|(?:\h{4}){1,2})\z/
16
16
 
17
- # Returns the selector the API and CDN accept for a transform: a selector String unchanged, or
18
- # structured steps (`{type:, options:}` or an Array of them) serialized by {serialize}.
17
+ # Returns the selector the API and CDN accept for a transform: a selector String checked and
18
+ # unchanged, or structured options (`{width: 640, fit: 'cover'}`) serialized by {serialize}.
19
19
  def selector(value)
20
- value.nil? || value.is_a?(String) ? value : serialize(value)
20
+ return serialize(value) unless value.nil? || value.is_a?(String)
21
+ # A transform is one path segment of options: it must not add a segment, lens, query or fragment.
22
+ if value&.match?(%r{[/?#]}) || value&.include?('..') || value&.start_with?('@')
23
+ raise Internal.invalid('Transform cannot contain "/", "..", "?" or "#", or start with "@"')
24
+ end
25
+
26
+ value
21
27
  end
22
28
 
23
- # Builds the CDN path of a transform. A selector String keeps its own step separators and is
24
- # encoded like JavaScript's encodeURI; serialized steps are already path-safe.
29
+ # Builds the CDN path text of a transform. A selector String is encoded like JavaScript's
30
+ # encodeURI; serialized options are already path-safe.
25
31
  def path(value)
26
- return serialize(value) unless value.is_a?(String)
27
-
28
- text = value.sub(%r{\A/+}, '')
29
- raise Internal.invalid('Transform cannot contain "..", "?" or "#"') if text.include?('..') || text.match?(/[?#]/)
32
+ text = selector(value)
33
+ return text unless value.is_a?(String)
30
34
 
31
35
  # Like JavaScript's encodeURI.
32
36
  text.b.gsub(%r{[^A-Za-z0-9\-_.!~*'();/:@&=+$,]}n) { |byte| format('%%%02X', byte.ord) }
33
37
  end
34
38
 
35
- # Serializes structured transforms as a CDN selector, one path segment per step, such as
36
- # `fit=cover,width=640/format=webp`. Option keys are sorted so equal transforms share a URL, nested
37
- # keys are dotted (`border.color`), hex colours are written without `#` and values are percent-encoded.
39
+ # Serializes structured transform options as a CDN selector, such as `fit=cover,width=640`. Option
40
+ # keys are sorted so equal transforms share a URL, nested keys are dotted (`border.color`), hex
41
+ # colours are written without `#` and values are percent-encoded.
38
42
  def serialize(value)
39
- steps = value.is_a?(Array) ? value : [value]
40
- raise Internal.invalid('Transform needs at least one step') if steps.empty?
41
-
42
- steps.map do |step|
43
- raise Internal.invalid('Transform steps must be Hashes') unless step.is_a?(Hash)
44
-
45
- step = step.transform_keys(&:to_s)
46
- raise Internal.invalid('Transform type must be "imageTransform"') unless step['type'] == 'imageTransform'
47
-
48
- options = step['options'] || {}
49
- raise Internal.invalid('Transform options must be a Hash') unless options.is_a?(Hash)
43
+ raise Internal.invalid('Transform options must be a Hash') unless value.is_a?(Hash)
50
44
 
51
- pairs = []
52
- sorted_options(options).each { |key, item| append_pairs(pairs, key, item) }
53
- raise Internal.invalid('Transform steps need at least one option') if pairs.empty?
45
+ pairs = []
46
+ sorted_options(value).each { |key, item| append_pairs(pairs, key, item) }
47
+ raise Internal.invalid('A transform needs at least one option') if pairs.empty?
54
48
 
55
- pairs.join(',')
56
- end.join('/')
49
+ pairs.join(',')
57
50
  end
58
51
 
59
52
  def sorted_options(options, prefix = '')
@@ -90,6 +90,14 @@ module CalmLens
90
90
  @source.rewind unless @source.is_a?(String)
91
91
  end
92
92
 
93
+ # `length` bytes from `offset` as a body of their own, read again from the source for each
94
+ # attempt. Only for a {#replayable?} body of known size: an IO is positioned at the slice.
95
+ def slice(offset, length)
96
+ return UploadBody.new(@source.byteslice(offset, length)) if @source.is_a?(String)
97
+
98
+ UploadBody.new(@source.slice(offset, length))
99
+ end
100
+
93
101
  # The bytes, read fully. Used only by the offline mock.
94
102
  def read_all
95
103
  return @source if @source.is_a?(String)
@@ -114,6 +122,12 @@ module CalmLens
114
122
 
115
123
  def rewindable? = !@start.nil?
116
124
 
125
+ # A reader of `length` bytes from `offset` of this rewindable stream, positioned there.
126
+ def slice(offset, length)
127
+ @io.seek(@start + offset)
128
+ IOReader.new(@io, length)
129
+ end
130
+
117
131
  def rewind
118
132
  @io.seek(@start)
119
133
  @remaining = @size
@@ -9,6 +9,9 @@ module CalmLens
9
9
  # Signs upload tokens, which let a browser upload through an upload preset. Reach it through
10
10
  # {ServerClient#upload_tokens}. Signing happens locally with the API key; no request is made.
11
11
  class UploadTokens < Resource
12
+ # The prefix of this resource's operations in Internal::OPTION_KEYS.
13
+ OPERATIONS = 'uploadTokens'
14
+
12
15
  TOKEN_PREFIX = 'clut_'
13
16
  SIGNING_CONTEXT = 'calmlens-upload-token:v1:'
14
17
  DEFAULT_TTL_SECONDS = 600
@@ -26,7 +29,7 @@ module CalmLens
26
29
  # @option options [Integer] :expires_in_seconds lifetime, 1 to 3600 (default 600)
27
30
  # @return [Hash] `{'token' => String, 'expiresAt' => Integer}` (milliseconds since the Unix epoch)
28
31
  def sign(options)
29
- call(options) { UploadTokens.sign_at(@api_key, options, now: (Time.now.to_f * 1000).to_i, nonce: SecureRandom.uuid) }
32
+ call(options, __method__) { UploadTokens.sign_at(@api_key, options, now: (Time.now.to_f * 1000).to_i, nonce: SecureRandom.uuid) }
30
33
  end
31
34
 
32
35
  # Signs like {#sign} at a fixed time with a fixed nonce. {MockServerClient.sign_upload_token}
@@ -2,5 +2,5 @@
2
2
 
3
3
  module CalmLens
4
4
  # The gem version. The gemspec and the User-Agent header read it from here.
5
- VERSION = '0.18.2'
5
+ VERSION = '0.19.0'
6
6
  end
data/lib/calmlens.rb CHANGED
@@ -8,6 +8,7 @@ end
8
8
  require_relative 'calmlens/version'
9
9
  require_relative 'calmlens/error_codes'
10
10
  require_relative 'calmlens/errors'
11
+ require_relative 'calmlens/option_keys'
11
12
  require_relative 'calmlens/internal'
12
13
  require_relative 'calmlens/transforms'
13
14
  require_relative 'calmlens/response'
@@ -19,6 +20,7 @@ require_relative 'calmlens/moderation_context'
19
20
  require_relative 'calmlens/assets'
20
21
  require_relative 'calmlens/folders'
21
22
  require_relative 'calmlens/hash_lists'
23
+ require_relative 'calmlens/policies'
22
24
  require_relative 'calmlens/text'
23
25
  require_relative 'calmlens/upload_tokens'
24
26
  require_relative 'calmlens/events'