phlex-reactive 0.15.1 → 0.15.3
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/CHANGELOG.md +188 -0
- data/README.md +82 -9
- data/app/controllers/phlex/reactive/actions_controller.rb +4 -1
- data/app/javascript/phlex/reactive/compute.js +14 -1
- data/app/javascript/phlex/reactive/compute.min.js +2 -2
- data/app/javascript/phlex/reactive/compute.min.js.map +3 -3
- data/app/javascript/phlex/reactive/core.min.js +4 -3
- data/app/javascript/phlex/reactive/core.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/bindings.js +66 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/form.js +54 -22
- data/app/javascript/phlex/reactive/features/form.min.js +2 -2
- data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/persist.js +31 -2
- data/app/javascript/phlex/reactive/features/persist.min.js +2 -2
- data/app/javascript/phlex/reactive/features/persist.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +6 -6
- data/app/javascript/phlex/reactive/runtime.js +476 -13
- data/lib/phlex/reactive/component/helpers.rb +24 -5
- data/lib/phlex/reactive/component/identity.rb +34 -1
- data/lib/phlex/reactive/component/image_fallback_binding.rb +54 -0
- data/lib/phlex/reactive/component/media_binding.rb +103 -0
- data/lib/phlex/reactive/component/media_pattern.rb +79 -0
- data/lib/phlex/reactive/doctor.rb +20 -8
- data/lib/phlex/reactive/held_fields.rb +87 -0
- data/lib/phlex/reactive/js.rb +153 -29
- data/lib/phlex/reactive/streamable.rb +26 -2
- data/lib/phlex/reactive/test_helpers.rb +11 -2
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +5 -1
|
@@ -18,6 +18,16 @@ module Phlex
|
|
|
18
18
|
module Identity
|
|
19
19
|
extend ActiveSupport::Concern
|
|
20
20
|
|
|
21
|
+
# Whether the installed GlobalID gives a class its own app
|
|
22
|
+
# (global_id_app, then GlobalID.app — rails/globalid after 1.4).
|
|
23
|
+
# Read once: it is a fact about the loaded gem.
|
|
24
|
+
def self.per_class_gid_app?
|
|
25
|
+
return @per_class_gid_app if defined?(@per_class_gid_app)
|
|
26
|
+
|
|
27
|
+
@per_class_gid_app = ::GlobalID::Identification.const_defined?(:ClassMethods, false) &&
|
|
28
|
+
::GlobalID::Identification::ClassMethods.method_defined?(:global_id_app)
|
|
29
|
+
end
|
|
30
|
+
|
|
21
31
|
class_methods do
|
|
22
32
|
# The record's instance-variable symbol (e.g. :@todo), computed once.
|
|
23
33
|
# reactive_token reads it on every render; interpolating :"@#{key}" each
|
|
@@ -74,7 +84,7 @@ module Phlex
|
|
|
74
84
|
# before to_gid (nil.to_gid would raise).
|
|
75
85
|
if (record_ivar = klass.reactive_record_ivar) &&
|
|
76
86
|
(record = instance_variable_get(record_ivar)) && signable_gid?(record)
|
|
77
|
-
payload["gid"] = record
|
|
87
|
+
payload["gid"] = reactive_gid(record)
|
|
78
88
|
end
|
|
79
89
|
|
|
80
90
|
state_ivars = klass.reactive_state_ivars
|
|
@@ -95,6 +105,29 @@ module Phlex
|
|
|
95
105
|
def signable_gid?(record)
|
|
96
106
|
!(record.respond_to?(:persisted?) && !record.persisted?)
|
|
97
107
|
end
|
|
108
|
+
|
|
109
|
+
# record.to_gid.to_s, without the URI. For a record whose to_gid is
|
|
110
|
+
# GlobalID's own (not overridden on its class or on itself) and whose
|
|
111
|
+
# id is an Integer, GlobalID's string is exactly
|
|
112
|
+
# "gid://<GlobalID.app>/<class name>/<id>": an Integer needs no
|
|
113
|
+
# escaping and there are no params. Building it directly skips a
|
|
114
|
+
# validated URI::GID (~37 objects, about half a record token's cost),
|
|
115
|
+
# once per record-backed row per render. Anything else goes through
|
|
116
|
+
# to_gid unchanged, its errors included.
|
|
117
|
+
def reactive_gid(record)
|
|
118
|
+
if record.is_a?(::GlobalID::Identification) &&
|
|
119
|
+
record.method(:to_gid).owner.equal?(::GlobalID::Identification) &&
|
|
120
|
+
(id = record.id).is_a?(Integer) && (app = reactive_gid_app(record.class)) && (name = record.class.name)
|
|
121
|
+
"gid://#{app}/#{name}/#{id}"
|
|
122
|
+
else
|
|
123
|
+
record.to_gid.to_s
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The app to_gid would use: the class's own where GlobalID has one.
|
|
128
|
+
def reactive_gid_app(klass)
|
|
129
|
+
(Identity.per_class_gid_app? && klass.global_id_app) || ::GlobalID.app
|
|
130
|
+
end
|
|
98
131
|
end
|
|
99
132
|
end
|
|
100
133
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# An <img> that falls back when its src fails and recovers when the src
|
|
7
|
+
# changes (issue #383).
|
|
8
|
+
#
|
|
9
|
+
# img(id: "logo", alt: "", **reactive_image_fallback(hide: true))
|
|
10
|
+
# img(id: "logo", alt: "Bank", **reactive_image_fallback(show_alt: "#bank-name"))
|
|
11
|
+
# span(id: "bank-name", hidden: true)
|
|
12
|
+
#
|
|
13
|
+
# On `error` the client sets data-reactive-image-state="error", adds
|
|
14
|
+
# `hidden` (hide: true, the default) and, with show_alt:, writes the
|
|
15
|
+
# img's alt into that node (a CSS selector inside the root) and shows it.
|
|
16
|
+
# On `load` the state is "loaded". A later src — copy_media,
|
|
17
|
+
# reactive_media, preview_file, a morph, or any src that loads — clears
|
|
18
|
+
# the error and shows the img again; an img with no src is never an
|
|
19
|
+
# error. hide: false leaves visibility alone: style the state with CSS.
|
|
20
|
+
# Composes with reactive_media (its **attrs mix the data attribute in):
|
|
21
|
+
#
|
|
22
|
+
# img(**reactive_media(from: :rib, pool: POOL, **reactive_image_fallback))
|
|
23
|
+
#
|
|
24
|
+
# Named ImageFallbackBinding for the reason MediaBinding is (issue #233).
|
|
25
|
+
module ImageFallbackBinding
|
|
26
|
+
def reactive_image_fallback(hide: true, show_alt: nil, **attrs)
|
|
27
|
+
wire = { "hide" => image_fallback_hide!(hide) }
|
|
28
|
+
wire["alt"] = image_fallback_alt!(show_alt) unless show_alt.nil?
|
|
29
|
+
mix({ data: { reactive_image_fallback: wire.to_json } }, attrs)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def image_fallback_hide!(hide)
|
|
35
|
+
return hide if [true, false].include?(hide)
|
|
36
|
+
|
|
37
|
+
raise ArgumentError, "reactive_image_fallback hide: must be true or false, got #{hide.inspect}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A selector the client passes to querySelector, never a URL.
|
|
41
|
+
def image_fallback_alt!(selector)
|
|
42
|
+
if selector == JS::ROOT_SENTINEL
|
|
43
|
+
raise ArgumentError,
|
|
44
|
+
"reactive_image_fallback show_alt: #{selector} names the whole root — pass the selector of a node inside it"
|
|
45
|
+
end
|
|
46
|
+
return selector if selector.is_a?(String) && !selector.strip.empty?
|
|
47
|
+
|
|
48
|
+
raise ArgumentError,
|
|
49
|
+
"reactive_image_fallback show_alt: must be a CSS selector (e.g. \"#bank-name\"), got #{selector.inspect}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# A preview image that follows a field (issue #373), with no URL in the
|
|
7
|
+
# page's ops or attributes: the server renders every candidate <img>
|
|
8
|
+
# (hidden), and the binding copies the one whose key matches.
|
|
9
|
+
#
|
|
10
|
+
# input(name: "code")
|
|
11
|
+
# img(id: "logo", alt: "", **reactive_media(
|
|
12
|
+
# from: :code, take: 3,
|
|
13
|
+
# pool: { "007" => "#logo-007", "011" => "#logo-011" },
|
|
14
|
+
# label: "#caption", blank_label: "Unknown"
|
|
15
|
+
# ))
|
|
16
|
+
# img(id: "logo-007", src: "/logos/007.svg", alt: "North", hidden: true)
|
|
17
|
+
#
|
|
18
|
+
# The field is read the way reactive_show reads one (owned fields, a
|
|
19
|
+
# radio group's checked value, reactive_scope applied). `take:` keeps the
|
|
20
|
+
# first n characters as the key; omit it to use the whole value. A pool
|
|
21
|
+
# value is a CSS SELECTOR for an <img> inside the root, never a URL: the
|
|
22
|
+
# client copies that element's src, srcset and alt the way
|
|
23
|
+
# js.copy_media does. A key, selector or element that misses (or is not
|
|
24
|
+
# an <img>) clears the preview. A pool <img> with an alt and no src is a
|
|
25
|
+
# hit without an image: the preview drops its src and takes the alt
|
|
26
|
+
# (issue #381). `label:` names a node whose textContent becomes the
|
|
27
|
+
# chosen image's alt (`blank_label`, or "", on a miss). Client-only: no token,
|
|
28
|
+
# no round trip.
|
|
29
|
+
#
|
|
30
|
+
# Until the field has `take` characters, or while `match:` (a Regexp,
|
|
31
|
+
# anchored to the whole key) rejects the key, the binding is PENDING
|
|
32
|
+
# (issue #380): never `blank_label`. `pending: :clear` (the default)
|
|
33
|
+
# clears the preview and empties the label; `pending: :keep` leaves both
|
|
34
|
+
# alone. The preview's data-reactive-media-state reads pending, hit or
|
|
35
|
+
# miss, so a row can hide itself with CSS until there is an answer.
|
|
36
|
+
#
|
|
37
|
+
# Named MediaBinding, not Media: this module sits in every reactive
|
|
38
|
+
# component's ancestry, and a bare `Media` would shadow a host app's kit
|
|
39
|
+
# component of that name (issue #233).
|
|
40
|
+
module MediaBinding
|
|
41
|
+
def reactive_media(from:, pool: nil, take: nil, match: nil, pending: :clear, label: nil, blank_label: nil, **attrs)
|
|
42
|
+
wire = { "field" => media_field!(from) }
|
|
43
|
+
wire["take"] = media_take!(take) unless take.nil?
|
|
44
|
+
wire["match"] = MediaPattern.to_js(match) unless match.nil?
|
|
45
|
+
wire["pending"] = "keep" if media_pending!(pending) == "keep"
|
|
46
|
+
wire["pool"] = media_pool!(pool)
|
|
47
|
+
wire["label"] = media_selector!(:label, label) unless label.nil?
|
|
48
|
+
wire["blank"] = blank_label.to_s unless blank_label.nil?
|
|
49
|
+
mix({ data: { reactive_media: wire.to_json } }, attrs)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def media_field!(from)
|
|
55
|
+
name = from.to_s if from.is_a?(Symbol) || from.is_a?(String)
|
|
56
|
+
return name unless name.nil? || name.strip.empty?
|
|
57
|
+
|
|
58
|
+
raise ArgumentError, "reactive_media from: needs the field name to watch (e.g. from: :code), got #{from.inspect}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def media_take!(take)
|
|
62
|
+
return take if take.is_a?(Integer) && take.positive?
|
|
63
|
+
|
|
64
|
+
raise ArgumentError, "reactive_media take: must be a positive Integer (or omitted for the whole value), " \
|
|
65
|
+
"got #{take.inspect}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def media_pending!(pending)
|
|
69
|
+
mode = pending.to_s if pending.is_a?(Symbol) || pending.is_a?(String)
|
|
70
|
+
return mode if %w[clear keep].include?(mode)
|
|
71
|
+
|
|
72
|
+
raise ArgumentError, "reactive_media pending: must be :clear or :keep, got #{pending.inspect}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def media_pool!(pool)
|
|
76
|
+
if pool.nil?
|
|
77
|
+
raise ArgumentError, "reactive_media needs pool: — a Hash of key => selector of a server-rendered <img> " \
|
|
78
|
+
"(e.g. pool: { \"007\" => \"#logo-007\" })"
|
|
79
|
+
end
|
|
80
|
+
unless pool.is_a?(Hash) && !pool.empty?
|
|
81
|
+
raise ArgumentError,
|
|
82
|
+
"reactive_media pool: must be a non-empty Hash of key => <img> selector, got #{pool.inspect}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
pool.to_h { |key, selector| [key.to_s, media_selector!(:pool, selector)] }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A pool value or label: is a selector, never a URL — the client only
|
|
89
|
+
# ever passes it to querySelector.
|
|
90
|
+
def media_selector!(kind, selector)
|
|
91
|
+
if selector == JS::ROOT_SENTINEL
|
|
92
|
+
raise ArgumentError,
|
|
93
|
+
"reactive_media #{kind}: #{selector} names the whole root — pass the selector of an element inside it"
|
|
94
|
+
end
|
|
95
|
+
return selector if selector.is_a?(String) && !selector.strip.empty?
|
|
96
|
+
|
|
97
|
+
raise ArgumentError,
|
|
98
|
+
"reactive_media #{kind}: values are CSS selectors (e.g. \"#logo-007\"), got #{selector.inspect}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# reactive_media's match: (issue #380) — a Ruby Regexp as the JavaScript
|
|
7
|
+
# source the client anchors as ^(?:…)$ and compiles without flags. The
|
|
8
|
+
# source is tokenized (an escape is one token, so an escaped backslash is
|
|
9
|
+
# never mistaken for one): a leading \A and a trailing \z/\Z are dropped,
|
|
10
|
+
# and anything JavaScript would read differently raises at render rather
|
|
11
|
+
# than matching silently wrong in the browser.
|
|
12
|
+
module MediaPattern
|
|
13
|
+
# Escapes Ruby reads as an anchor or a class and JS reads as a letter,
|
|
14
|
+
# plus \s/\S, whose JS class also holds Unicode spaces Ruby's does not.
|
|
15
|
+
RUBY_ONLY_ESCAPES = %w[\A \z \Z \G \h \H \R \X \K \p \P \s \S].freeze
|
|
16
|
+
# The group openers both engines read alike: (?: (?= (?! (?<= (?<! (?<name>
|
|
17
|
+
SHARED_GROUPS = [":", "=", "!", "<"].freeze
|
|
18
|
+
OPTIONS = Regexp::IGNORECASE | Regexp::EXTENDED | Regexp::MULTILINE
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def to_js(match)
|
|
23
|
+
unless match.is_a?(Regexp)
|
|
24
|
+
raise ArgumentError,
|
|
25
|
+
"reactive_media match: must be a Regexp (e.g. /\\A\\d{3}\\z/), got #{match.inspect}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
unless match.options.nobits?(OPTIONS)
|
|
29
|
+
refuse!(match,
|
|
30
|
+
"has options (i, m, x) the client would read differently — drop them")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
tokens = match.source.scan(/\\.|./m)
|
|
34
|
+
tokens.shift if tokens.first == "\\A"
|
|
35
|
+
final = tokens.pop if %w[\z \Z].include?(tokens.last)
|
|
36
|
+
check!(tokens, match)
|
|
37
|
+
# Ruby's \Z also matches before one final newline.
|
|
38
|
+
final == "\\Z" ? "#{tokens.join}\\n?" : tokens.join
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def check!(tokens, match)
|
|
42
|
+
in_class = false
|
|
43
|
+
tokens.each_with_index do |token, i|
|
|
44
|
+
if RUBY_ONLY_ESCAPES.include?(token)
|
|
45
|
+
refuse!(match,
|
|
46
|
+
"uses #{token}, which is Ruby-only (JavaScript reads it differently)")
|
|
47
|
+
end
|
|
48
|
+
refuse!(match, "has a character above U+FFFF (the client pattern has no u flag)") if token.ord > 0xFFFF
|
|
49
|
+
if token == "\\u" && tokens[i + 1] == "{"
|
|
50
|
+
refuse!(match,
|
|
51
|
+
"uses \\u{…}, which JavaScript without the u flag reads as a repeated u")
|
|
52
|
+
end
|
|
53
|
+
if in_class
|
|
54
|
+
in_class = false if token == "]"
|
|
55
|
+
class_syntax!(tokens, i, match)
|
|
56
|
+
elsif token == "["
|
|
57
|
+
in_class = true
|
|
58
|
+
elsif token == "(" && tokens[i + 1] == "?" && !SHARED_GROUPS.include?(tokens[i + 2])
|
|
59
|
+
refuse!(match, "uses (?#{tokens[i + 2]}…), a group JavaScript does not read the same way")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A [ inside a class (a nested class or a POSIX [:digit:]) and && (an
|
|
65
|
+
# intersection) are Ruby-only: JavaScript reads them as literals.
|
|
66
|
+
def class_syntax!(tokens, index, match)
|
|
67
|
+
token = tokens[index]
|
|
68
|
+
return unless token == "[" || (token == "&" && tokens[index + 1] == "&")
|
|
69
|
+
|
|
70
|
+
refuse!(match, "has a nested character class, a POSIX [:class:] or && — JavaScript reads them literally")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def refuse!(match, why)
|
|
74
|
+
raise ArgumentError, "reactive_media match: #{match.inspect} #{why}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -248,7 +248,15 @@ module Phlex
|
|
|
248
248
|
# authorization / mark_authorized! call. It is :unknown, NEVER a hard fail:
|
|
249
249
|
# a helper may authorize indirectly, so this is a hint, not a verdict.
|
|
250
250
|
def authorization_check(components)
|
|
251
|
-
|
|
251
|
+
infos = nil
|
|
252
|
+
suspects = components.flat_map do
|
|
253
|
+
next [] if component_defines_authorization_method?(it)
|
|
254
|
+
|
|
255
|
+
# Read once, and only when needed: Inspector.components Prism-scans
|
|
256
|
+
# every component's actions.
|
|
257
|
+
infos ||= inspector_infos
|
|
258
|
+
unauthorized_action_labels(it, infos)
|
|
259
|
+
end
|
|
252
260
|
return Check.new(:ok, "every mutating action appears to authorize", name: :authorization) if suspects.empty?
|
|
253
261
|
|
|
254
262
|
Check.new(:unknown, "actions with no detected authorization call: #{suspects.join(", ")}",
|
|
@@ -394,13 +402,11 @@ module Phlex
|
|
|
394
402
|
end
|
|
395
403
|
|
|
396
404
|
# "Klass#action" for every declared action on `klass` that (advisory)
|
|
397
|
-
# appears unauthorized
|
|
398
|
-
# ancestry
|
|
399
|
-
# Inspector) finds no authorization / mark_authorized! call in its body.
|
|
400
|
-
def unauthorized_action_labels(klass)
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
info = Phlex::Reactive::Inspector.components.find { it.klass == klass }
|
|
405
|
+
# appears unauthorized (the caller has already ruled out an authorization
|
|
406
|
+
# method in its ancestry): the action isn't skipped, and the Prism scan (via
|
|
407
|
+
# the shared Inspector) finds no authorization / mark_authorized! call in its body.
|
|
408
|
+
def unauthorized_action_labels(klass, infos)
|
|
409
|
+
info = infos[klass]
|
|
404
410
|
return [] unless info
|
|
405
411
|
|
|
406
412
|
info.actions
|
|
@@ -417,6 +423,12 @@ module Phlex
|
|
|
417
423
|
# Phlex::Reactive.authorization_methods and NOT the Inspector's set, which
|
|
418
424
|
# folds in mark_authorized! — every component inherits that instance helper,
|
|
419
425
|
# so including it would silence the check for everything.
|
|
426
|
+
def inspector_infos
|
|
427
|
+
Phlex::Reactive::Inspector.components.to_h { [it.klass, it] }
|
|
428
|
+
rescue StandardError
|
|
429
|
+
{}
|
|
430
|
+
end
|
|
431
|
+
|
|
420
432
|
def component_defines_authorization_method?(klass)
|
|
421
433
|
Phlex::Reactive.authorization_methods.any? do
|
|
422
434
|
klass.method_defined?(it) || klass.private_method_defined?(it)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# The fields a morph held over a changed server value (issue #370).
|
|
6
|
+
#
|
|
7
|
+
# Since #338 a morph keeps the value of the field being typed in and writes
|
|
8
|
+
# the render's value as the field's default. When that render differs from
|
|
9
|
+
# the old default, someone else changed the field while the user typed.
|
|
10
|
+
# The client marks such a field and the root's next action request names it
|
|
11
|
+
# in the X-Reactive-Held header (a JSON array of field names). Inside the
|
|
12
|
+
# action, `reactive_held_fields` returns those names, so the app can refuse
|
|
13
|
+
# or merge instead of silently overwriting the other change:
|
|
14
|
+
#
|
|
15
|
+
# def save(title: nil)
|
|
16
|
+
# authorize!(@note, to: :update?)
|
|
17
|
+
# return conflict! if reactive_held_fields.include?("title") && title != @note.title
|
|
18
|
+
# ...
|
|
19
|
+
#
|
|
20
|
+
# The header is UNTRUSTED input, like every request field: it carries names
|
|
21
|
+
# only, never values, and is read as data — capped in bytes, count and
|
|
22
|
+
# length, non-strings dropped, and filtered to the names the running action
|
|
23
|
+
# declares as params (matched on the base name, so `title[en]` counts for a
|
|
24
|
+
# declared `title`). A name the action can't receive can't be overwritten
|
|
25
|
+
# by it. Malformed input reads as nothing held; it never fails a request.
|
|
26
|
+
# It is a hint about what the user saw, not authorization.
|
|
27
|
+
module HeldFields
|
|
28
|
+
HEADER = "X-Reactive-Held"
|
|
29
|
+
|
|
30
|
+
# A raw header longer than this is ignored unparsed.
|
|
31
|
+
MAX_BYTES = 4_096
|
|
32
|
+
# At most this many names are read …
|
|
33
|
+
MAX_NAMES = 64
|
|
34
|
+
# … each at most this long.
|
|
35
|
+
MAX_NAME_LENGTH = 128
|
|
36
|
+
|
|
37
|
+
EMPTY = [].freeze
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
# The held field names during the current action ([] outside one).
|
|
41
|
+
def current
|
|
42
|
+
Thread.current[:phlex_reactive_held_fields] || EMPTY
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Expose `names` for the block, restoring the previous list after.
|
|
46
|
+
def with(names)
|
|
47
|
+
previous = Thread.current[:phlex_reactive_held_fields]
|
|
48
|
+
Thread.current[:phlex_reactive_held_fields] = names
|
|
49
|
+
yield
|
|
50
|
+
ensure
|
|
51
|
+
Thread.current[:phlex_reactive_held_fields] = previous
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The header's names that `action_def` declares, frozen; [] for
|
|
55
|
+
# anything absent, oversized or malformed.
|
|
56
|
+
def parse(raw, action_def)
|
|
57
|
+
return EMPTY if raw.nil? || raw.empty? || raw.bytesize > MAX_BYTES
|
|
58
|
+
|
|
59
|
+
names = JSON.parse(raw)
|
|
60
|
+
names.is_a?(Array) ? filter(names, action_def) : EMPTY
|
|
61
|
+
rescue JSON::ParserError
|
|
62
|
+
EMPTY
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Keep the strings among `names` that `action_def` declares as params.
|
|
66
|
+
def filter(names, action_def)
|
|
67
|
+
declared = declared_names(action_def)
|
|
68
|
+
return EMPTY if declared.empty?
|
|
69
|
+
|
|
70
|
+
names.first(MAX_NAMES).select do
|
|
71
|
+
it.is_a?(String) && it.length <= MAX_NAME_LENGTH && declared.include?(base_name(it))
|
|
72
|
+
end.uniq.freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def declared_names(action_def)
|
|
78
|
+
params = action_def.params
|
|
79
|
+
params.is_a?(Hash) ? params.keys.map(&:to_s) : EMPTY
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# "title" for "title", "title[]" and "title[en]".
|
|
83
|
+
def base_name(name) = name.split("[", 2).first
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|