parse-stack-next 5.7.6 → 5.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1148 -0
- data/README.md +39 -23
- data/docs/TEST_SERVER.md +2 -2
- data/docs/acl_clp_guide.md +7 -0
- data/docs/atlas_vector_search_guide.md +181 -13
- data/docs/caching.md +50 -0
- data/docs/client_sdk_guide.md +11 -0
- data/docs/mcp_guide.md +332 -9
- data/docs/mongodb_direct_guide.md +27 -0
- data/docs/usage_guide.md +38 -0
- data/docs/webhooks_guide.md +126 -17
- data/lib/parse/acl_scope.rb +230 -50
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +42 -15
- data/lib/parse/agent/describe.rb +11 -3
- data/lib/parse/agent/field_names.rb +53 -0
- data/lib/parse/agent/field_policy.rb +74 -0
- data/lib/parse/agent/mcp_deployments.rb +426 -0
- data/lib/parse/agent/mcp_dispatcher.rb +8 -1
- data/lib/parse/agent/mcp_rack_app.rb +424 -45
- data/lib/parse/agent/mcp_server.rb +23 -1
- data/lib/parse/agent/mcp_subscriptions.rb +124 -6
- data/lib/parse/agent/metadata_registry.rb +67 -8
- data/lib/parse/agent/prompt_hardening.rb +9 -3
- data/lib/parse/agent/tools.rb +378 -29
- data/lib/parse/agent.rb +298 -21
- data/lib/parse/api/batch.rb +10 -1
- data/lib/parse/api/schema.rb +23 -4
- data/lib/parse/api/sessions.rb +6 -2
- data/lib/parse/api/users.rb +88 -14
- data/lib/parse/atlas_search/index_manager.rb +5 -1
- data/lib/parse/atlas_search/protected_paths.rb +236 -0
- data/lib/parse/atlas_search.rb +144 -24
- data/lib/parse/authorization.rb +289 -4
- data/lib/parse/cache/sub_cache.rb +26 -0
- data/lib/parse/client/authentication.rb +19 -1
- data/lib/parse/client/batch.rb +264 -35
- data/lib/parse/client/body_builder.rb +21 -0
- data/lib/parse/client/caching.rb +371 -27
- data/lib/parse/client/request.rb +26 -14
- data/lib/parse/client/response.rb +49 -6
- data/lib/parse/client.rb +227 -40
- data/lib/parse/clp_scope.rb +317 -25
- data/lib/parse/console.rb +2 -2
- data/lib/parse/embeddings/voyage.rb +181 -17
- data/lib/parse/graphql/type_generator.rb +3 -0
- data/lib/parse/live_query/client.rb +72 -2
- data/lib/parse/lock_backend.rb +4 -3
- data/lib/parse/model/acl.rb +119 -21
- data/lib/parse/model/associations/belongs_to.rb +25 -3
- data/lib/parse/model/associations/collection_proxy.rb +146 -17
- data/lib/parse/model/associations/has_many.rb +49 -9
- data/lib/parse/model/associations/has_one.rb +3 -1
- data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
- data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
- data/lib/parse/model/bytes.rb +13 -5
- data/lib/parse/model/classes/role.rb +95 -0
- data/lib/parse/model/classes/session.rb +304 -1
- data/lib/parse/model/classes/user.rb +104 -8
- data/lib/parse/model/core/actions.rb +287 -70
- data/lib/parse/model/core/builder.rb +100 -8
- data/lib/parse/model/core/create_lock.rb +27 -2
- data/lib/parse/model/core/describe.rb +2 -0
- data/lib/parse/model/core/fetching.rb +50 -5
- data/lib/parse/model/core/field_guards.rb +16 -8
- data/lib/parse/model/core/pluralized_aliases.rb +8 -4
- data/lib/parse/model/core/properties.rb +488 -39
- data/lib/parse/model/core/querying.rb +7 -0
- data/lib/parse/model/core/schema.rb +5 -3
- data/lib/parse/model/core/search_indexing.rb +63 -0
- data/lib/parse/model/core/vector_searchable.rb +35 -6
- data/lib/parse/model/file.rb +9 -2
- data/lib/parse/model/geopoint.rb +61 -13
- data/lib/parse/model/model.rb +160 -9
- data/lib/parse/model/object.rb +317 -18
- data/lib/parse/model/phone.rb +54 -5
- data/lib/parse/model/pointer.rb +40 -6
- data/lib/parse/model/push.rb +11 -1
- data/lib/parse/mongodb.rb +189 -61
- data/lib/parse/pipeline_security.rb +415 -26
- data/lib/parse/query/constraint.rb +31 -1
- data/lib/parse/query/constraints.rb +89 -59
- data/lib/parse/query/cursor.rb +3 -1
- data/lib/parse/query/operation.rb +62 -8
- data/lib/parse/query/ordering.rb +34 -6
- data/lib/parse/query.rb +1871 -167
- data/lib/parse/retrieval/agent_tool.rb +225 -8
- data/lib/parse/retrieval/benchmark.rb +149 -0
- data/lib/parse/retrieval/profiles.rb +320 -0
- data/lib/parse/retrieval/retriever.rb +10 -1
- data/lib/parse/retrieval.rb +2 -0
- data/lib/parse/schema/search_index_migrator.rb +23 -5
- data/lib/parse/schema.rb +74 -18
- data/lib/parse/stack/tasks.rb +6 -4
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +91 -14
- data/lib/parse/two_factor_auth/user_extension.rb +14 -2
- data/lib/parse/two_factor_auth.rb +11 -0
- data/lib/parse/vector_search/hybrid.rb +110 -33
- data/lib/parse/vector_search/index_definition.rb +237 -0
- data/lib/parse/vector_search.rb +259 -23
- data/lib/parse/webhooks/payload.rb +136 -6
- data/lib/parse/webhooks/replay_protection.rb +58 -20
- data/lib/parse/webhooks.rb +715 -40
- metadata +8 -1
data/lib/parse/webhooks.rb
CHANGED
|
@@ -83,7 +83,38 @@ module Parse
|
|
|
83
83
|
class Webhooks
|
|
84
84
|
# The error to be raised in registered trigger or function webhook blocks that
|
|
85
85
|
# will trigger the Parse::Webhooks application to return the proper error response.
|
|
86
|
-
|
|
86
|
+
#
|
|
87
|
+
# An optional numeric `code` is carried on the exception and written into
|
|
88
|
+
# the error body as `"code"`. Note that Parse Server's HTTP webhook adapter
|
|
89
|
+
# (as of 9.10) reports every webhook error to the client as code 141
|
|
90
|
+
# (`SCRIPT_FAILED`) and does not forward a custom code; the code remains
|
|
91
|
+
# visible to in-process callers such as {Parse::Webhooks.run_function}.
|
|
92
|
+
class ResponseError < StandardError
|
|
93
|
+
# @return [Integer, nil] the Parse error code requested by the handler.
|
|
94
|
+
attr_reader :code
|
|
95
|
+
|
|
96
|
+
# @param message [String] the error message.
|
|
97
|
+
# @param code [Integer, nil] an optional Parse error code.
|
|
98
|
+
def initialize(message = nil, code: nil)
|
|
99
|
+
super(message)
|
|
100
|
+
@code = code
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The reply that tells Parse Server to keep a beforeSave write, or the
|
|
105
|
+
# afterFind rows, exactly as they were: a JSON object with no `success`
|
|
106
|
+
# key, so the adapter's `body.success` is `undefined`. A `null` success
|
|
107
|
+
# is not safe for beforeSave (see {Parse::Webhooks.before_save_reply}).
|
|
108
|
+
PASS_THROUGH_BODY = "{}"
|
|
109
|
+
|
|
110
|
+
# Keys never echoed back to Parse Server in a beforeSave reply. These are
|
|
111
|
+
# server-managed (`className`, timestamps) or credential material that a
|
|
112
|
+
# client write cannot legitimately carry.
|
|
113
|
+
# @!visibility private
|
|
114
|
+
BEFORE_SAVE_REPLY_SKIP_KEYS = %w[
|
|
115
|
+
className createdAt updatedAt
|
|
116
|
+
sessionToken session_token _hashed_password _password_history
|
|
117
|
+
].freeze
|
|
87
118
|
|
|
88
119
|
# The authentication-side triggers (local underscore form). These carry a
|
|
89
120
|
# `_User` / `_Session` as the payload object but are NOT object save/delete
|
|
@@ -470,6 +501,8 @@ module Parse
|
|
|
470
501
|
master: payload.master? || false,
|
|
471
502
|
is_new: payload.original.blank?,
|
|
472
503
|
)
|
|
504
|
+
# A guard revert is not the handler assigning the ACL.
|
|
505
|
+
pre_obj.reset_webhook_handler_acl_assigned! if pre_obj.respond_to?(:reset_webhook_handler_acl_assigned!)
|
|
473
506
|
end
|
|
474
507
|
end
|
|
475
508
|
end
|
|
@@ -483,6 +516,12 @@ module Parse
|
|
|
483
516
|
result = invoke_handler(payload, registry)
|
|
484
517
|
end
|
|
485
518
|
|
|
519
|
+
if type == :after_find
|
|
520
|
+
# Parse Server can only keep or deny afterFind rows from an HTTP
|
|
521
|
+
# webhook (see after_find_reply!); the reply always passes them through.
|
|
522
|
+
return after_find_reply!(payload, result)
|
|
523
|
+
end
|
|
524
|
+
|
|
486
525
|
if result.is_a?(Parse::Object)
|
|
487
526
|
# if it is a Parse::Object, we will call the registered ActiveModel callbacks
|
|
488
527
|
if type == :before_save
|
|
@@ -492,6 +531,7 @@ module Parse
|
|
|
492
531
|
# ran ActiveModel before_save callbacks locally. A client-spoofed
|
|
493
532
|
# `_RB_` without master falls through and runs them here.
|
|
494
533
|
unless trusted_ruby_initiated
|
|
534
|
+
adopt_request_user_as_acl_owner!(payload, result)
|
|
495
535
|
before_save_result = result.run_before_save_callbacks
|
|
496
536
|
# If a before_save callback halted the chain (returned false), reject the save.
|
|
497
537
|
if before_save_result == false
|
|
@@ -510,18 +550,37 @@ module Parse
|
|
|
510
550
|
end
|
|
511
551
|
end
|
|
512
552
|
end
|
|
513
|
-
#
|
|
514
|
-
|
|
553
|
+
# Parse Server REPLACES the write with the object a beforeSave
|
|
554
|
+
# webhook returns, so reply with the client's full write plus the
|
|
555
|
+
# handler's changes (or `nil`, "unchanged", when there are none).
|
|
556
|
+
result = before_save_reply(payload, result, include_create_defaults: true)
|
|
515
557
|
elsif type == :before_delete
|
|
516
|
-
|
|
558
|
+
# Run only the BEFORE phase of the destroy chain: the object is not
|
|
559
|
+
# deleted yet, so after_destroy belongs to the afterDelete trigger.
|
|
560
|
+
# A halted chain must deny the delete, and Parse Server only treats
|
|
561
|
+
# an `{error}` body as a denial.
|
|
562
|
+
unless trusted_ruby_initiated
|
|
563
|
+
if result.send(:run_before_phase_callbacks, :destroy) == false
|
|
564
|
+
raise Parse::Webhooks::ResponseError, "Delete halted by before_destroy callback"
|
|
565
|
+
end
|
|
566
|
+
end
|
|
517
567
|
result = true
|
|
518
568
|
end
|
|
519
569
|
elsif type == :before_save && result == false
|
|
520
570
|
# If webhook block returns false, halt the save by throwing an error
|
|
521
571
|
raise Parse::Webhooks::ResponseError, "Save halted by before_save webhook"
|
|
522
|
-
elsif type == :before_save
|
|
523
|
-
#
|
|
524
|
-
|
|
572
|
+
elsif type == :before_save
|
|
573
|
+
# `true` / `nil` (or any non-Hash value) means "allow the write as
|
|
574
|
+
# sent". A Hash is a set of field overrides. Either way the reply is
|
|
575
|
+
# built from the client's write so nothing the client sent is lost;
|
|
576
|
+
# in-place edits to `parse_object` and field-guard reverts are
|
|
577
|
+
# carried along. `nil` tells Parse Server to keep the write as is.
|
|
578
|
+
overrides = result.is_a?(Hash) ? result : nil
|
|
579
|
+
result = before_save_reply(payload, payload&.memoized_parse_object, overrides: overrides)
|
|
580
|
+
elsif type == :before_delete && result == false
|
|
581
|
+
# Parse Server ignores a `{success:false}` body and deletes anyway;
|
|
582
|
+
# only an `{error}` body denies the delete.
|
|
583
|
+
raise Parse::Webhooks::ResponseError, "Delete halted by before_delete webhook"
|
|
525
584
|
end
|
|
526
585
|
|
|
527
586
|
# Auth- and LiveQuery-trigger dispatch (beforeLogin/afterLogin/
|
|
@@ -548,29 +607,10 @@ module Parse
|
|
|
548
607
|
result = true
|
|
549
608
|
end
|
|
550
609
|
|
|
551
|
-
#
|
|
552
|
-
#
|
|
553
|
-
#
|
|
554
|
-
#
|
|
555
|
-
# parse_object's changes_payload entries for any guarded field so the
|
|
556
|
-
# response carries the appropriate revert (Delete op on create, prior
|
|
557
|
-
# value on update). The Parse::Object return path already runs through
|
|
558
|
-
# changes_payload on the same memoized instance and therefore needs no
|
|
559
|
-
# extra injection.
|
|
560
|
-
if type == :before_save && result.is_a?(Hash) && payload && payload.object?
|
|
561
|
-
guard_klass = (className.present? && className != "*") ? Parse::Object.find_class(className) : nil
|
|
562
|
-
if guard_klass && guard_klass.respond_to?(:field_guards) && guard_klass.field_guards.any?
|
|
563
|
-
pre_obj = payload.parse_object # same memoized instance the pre-block step mutated
|
|
564
|
-
if pre_obj.respond_to?(:changes_payload)
|
|
565
|
-
guard_payload = pre_obj.changes_payload
|
|
566
|
-
field_map = guard_klass.respond_to?(:field_map) ? guard_klass.field_map : {}
|
|
567
|
-
guard_klass.field_guards.each_key do |field|
|
|
568
|
-
remote = (field_map[field.to_sym] || field).to_s
|
|
569
|
-
result[remote] = guard_payload[remote] if guard_payload.key?(remote)
|
|
570
|
-
end
|
|
571
|
-
end
|
|
572
|
-
end
|
|
573
|
-
end
|
|
610
|
+
# Field guards need no separate injection step: the pre-block step
|
|
611
|
+
# reverted the guarded fields on the memoized parse_object, and
|
|
612
|
+
# before_save_reply diffs that same instance against the client's
|
|
613
|
+
# write, so every revert is already in the reply.
|
|
574
614
|
|
|
575
615
|
if type == :after_save && payload&.parse_object.present? && payload.parse_object.is_a?(Parse::Object)
|
|
576
616
|
# The chained ActiveModel after_save/after_create callbacks are NOT
|
|
@@ -681,6 +721,594 @@ module Parse
|
|
|
681
721
|
nil
|
|
682
722
|
end
|
|
683
723
|
|
|
724
|
+
# Fires the chained ActiveModel after_destroy callbacks for an afterDelete
|
|
725
|
+
# delivery, exactly once per request (after both the class route and the
|
|
726
|
+
# `"*"` route ran), mirroring {run_after_save_chain}. Skipped for
|
|
727
|
+
# trusted-Ruby-initiated deletes, whose callbacks already ran locally
|
|
728
|
+
# around `destroy`, and when no afterDelete route is registered. The
|
|
729
|
+
# object is already gone, so a raising callback is logged and swallowed.
|
|
730
|
+
#
|
|
731
|
+
# @param payload [Parse::Webhooks::Payload] the afterDelete payload.
|
|
732
|
+
# @return [void]
|
|
733
|
+
def run_after_delete_chain(payload)
|
|
734
|
+
obj = nil
|
|
735
|
+
return unless payload&.after_delete?
|
|
736
|
+
obj = payload.parse_object
|
|
737
|
+
return unless obj.is_a?(Parse::Object)
|
|
738
|
+
return unless route_registered?(:after_delete, payload.parse_class)
|
|
739
|
+
return if payload.ruby_initiated? && payload.master? == true
|
|
740
|
+
obj.run_after_delete_callbacks
|
|
741
|
+
nil
|
|
742
|
+
rescue => e
|
|
743
|
+
warn "[Parse::Webhooks] afterDelete after_destroy callback raised for " \
|
|
744
|
+
"#{obj ? obj.class : "UnknownObject"}##{Parse::TerminalSafe.sanitize_line(obj&.id)} -- the object is " \
|
|
745
|
+
"already deleted; logging and continuing: #{e.class}: " \
|
|
746
|
+
"#{Parse::TerminalSafe.sanitize_line(Parse::Middleware::BodyBuilder.redact(e.message))}"
|
|
747
|
+
nil
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
# Whether a handler is registered for a trigger on a class, either on the
|
|
751
|
+
# class itself or on the generic `"*"` route.
|
|
752
|
+
#
|
|
753
|
+
# @param type [Symbol, String] the trigger (or `:function`).
|
|
754
|
+
# @param class_name [String, nil] the class (or function) name.
|
|
755
|
+
# @return [Boolean]
|
|
756
|
+
def route_registered?(type, class_name)
|
|
757
|
+
type = type.to_s.underscore.to_sym
|
|
758
|
+
table = routes[type]
|
|
759
|
+
return false if table.blank?
|
|
760
|
+
return table[class_name.to_s].present? if type == :function
|
|
761
|
+
table[class_name.to_s].present? || table["*"].present?
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
# Build the object a beforeSave webhook replies with.
|
|
765
|
+
#
|
|
766
|
+
# Parse Server's HTTP webhook adapter REPLACES the pending write with
|
|
767
|
+
# whatever object a beforeSave webhook returns (`this.data =
|
|
768
|
+
# response.object`). Replying with only the handler's changes therefore
|
|
769
|
+
# erases every other field the client sent. This method returns:
|
|
770
|
+
#
|
|
771
|
+
# - `nil` when nothing differs from the client's write. The router
|
|
772
|
+
# replies with an empty object (`{}`, no `success` key), which Parse
|
|
773
|
+
# Server treats as "keep the write exactly as sent", preserving every
|
|
774
|
+
# atomic operator. (`{"success": null}` is NOT equivalent: Parse
|
|
775
|
+
# Server's beforeSave adapter checks `typeof result === "object"`,
|
|
776
|
+
# which is true for null, and then throws deleting a key from it.)
|
|
777
|
+
# - otherwise a Hash: the client's write (rebuilt from the payload, with
|
|
778
|
+
# `Increment` / `Add` / `Remove` / `Delete` / relation operators passed
|
|
779
|
+
# through untouched) with the handler's changes layered on top. A field
|
|
780
|
+
# the handler (or a field guard) returned to its stored value is
|
|
781
|
+
# dropped from the write rather than rewritten.
|
|
782
|
+
#
|
|
783
|
+
# Fields are compared against a fresh build of the payload, so only the
|
|
784
|
+
# fields the handler actually changed are encoded from the Ruby object.
|
|
785
|
+
# A field the handler rewrites is written as an absolute value. An
|
|
786
|
+
# operator on a dotted sub-key (`"meta.count"`) reaches the webhook only
|
|
787
|
+
# as its resulting sub-document, so on an update a changed sub-document
|
|
788
|
+
# is written back as dotted keys for just the sub-keys that differ from
|
|
789
|
+
# the stored one. A concurrent write to another sub-key survives; the
|
|
790
|
+
# changed sub-key itself is written as its resulting value. The split
|
|
791
|
+
# is one level deep (a changed sub-key is written whole at
|
|
792
|
+
# `field.sub`), and a field whose writes would carry a typed value is
|
|
793
|
+
# written whole.
|
|
794
|
+
#
|
|
795
|
+
# @param payload [Parse::Webhooks::Payload] the beforeSave payload.
|
|
796
|
+
# @param obj [Parse::Object, nil] the handler's object (nil when none).
|
|
797
|
+
# @param overrides [Hash, nil] field values a handler returned as a Hash.
|
|
798
|
+
# @param include_create_defaults [Boolean] on a create, also write the
|
|
799
|
+
# object's dirty fields the client did not send (declared defaults and
|
|
800
|
+
# the ACL the class policy resolves), matching what an SDK-side create
|
|
801
|
+
# sends.
|
|
802
|
+
# @return [Hash, nil] the reply object, or nil for "unchanged".
|
|
803
|
+
def before_save_reply(payload, obj, overrides: nil, include_create_defaults: false)
|
|
804
|
+
return nil unless payload && payload.object?
|
|
805
|
+
raw_object = payload.raw_object
|
|
806
|
+
return nil unless raw_object.is_a?(Hash)
|
|
807
|
+
raw_original = payload.raw_original
|
|
808
|
+
raw_original = nil unless raw_original.is_a?(Hash) && raw_original.present?
|
|
809
|
+
|
|
810
|
+
changes = {}
|
|
811
|
+
drops = []
|
|
812
|
+
if obj.is_a?(Parse::Object)
|
|
813
|
+
changes, drops = handler_field_changes(payload, obj, raw_original)
|
|
814
|
+
if include_create_defaults && raw_original.nil?
|
|
815
|
+
client_keys = raw_object.keys.map(&:to_s)
|
|
816
|
+
dirty = obj.changed.map(&:to_sym) & snapshot_fields(obj)
|
|
817
|
+
# This includes the ACL. A create with no `ACL` key is stored by
|
|
818
|
+
# Parse Server as public read and write, so a class with an ACL
|
|
819
|
+
# policy always replies with the ACL that policy resolved (owned
|
|
820
|
+
# by the requesting user where the policy names an owner; see
|
|
821
|
+
# {adopt_request_user_as_acl_owner!}) or the ACL the handler set.
|
|
822
|
+
# An ACL the client sent is kept as sent.
|
|
823
|
+
wire_values(obj, dirty).each do |remote, value|
|
|
824
|
+
next if client_keys.include?(remote) || changes.key?(remote)
|
|
825
|
+
changes[remote] = value
|
|
826
|
+
end
|
|
827
|
+
end
|
|
828
|
+
# An ACL the handler assigned on a create is written even when it
|
|
829
|
+
# equals the default stamp or the client's ACL, whatever the handler
|
|
830
|
+
# returned (the object, `true`, `nil`, or a Hash, whose own `ACL`
|
|
831
|
+
# still wins below). Diffing alone misses `obj.acl = Parse::ACL.new`
|
|
832
|
+
# under a `{}` default, and a create with no `ACL` key is stored
|
|
833
|
+
# public read and write.
|
|
834
|
+
if handler_assigned_acl?(obj) && !changes.key?("ACL")
|
|
835
|
+
changes.merge!(wire_values(obj, [:acl]).slice("ACL"))
|
|
836
|
+
end
|
|
837
|
+
end
|
|
838
|
+
overrides = overrides.as_json if overrides.is_a?(Hash)
|
|
839
|
+
return nil if changes.empty? && drops.empty? && overrides.blank?
|
|
840
|
+
|
|
841
|
+
reply = client_write_data(raw_object, raw_original)
|
|
842
|
+
overrides = overrides.present? ? remote_override_keys(payload, overrides) : {}
|
|
843
|
+
# A field the handler wrote or dropped replaces the client's dotted
|
|
844
|
+
# sub-key writes for it; MongoDB refuses `meta` and `meta.x` together.
|
|
845
|
+
(drops + changes.keys + overrides.keys).each do |remote|
|
|
846
|
+
reply.delete_if { |key, _| key.start_with?("#{remote}.") }
|
|
847
|
+
end
|
|
848
|
+
drops.each { |remote| reply.delete(remote) }
|
|
849
|
+
reply.merge!(changes)
|
|
850
|
+
reply.merge!(overrides)
|
|
851
|
+
fold_dotted_overrides!(reply, overrides.keys, raw_object: raw_object, create: raw_original.nil?)
|
|
852
|
+
reply
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
# A handler's Hash override keyed by Ruby names (`{acl: ...}`,
|
|
856
|
+
# `{author_name: ...}`) is mapped to the remote field names the reply
|
|
857
|
+
# uses, so it replaces the matching field instead of sitting beside it.
|
|
858
|
+
# A dotted key maps its first segment.
|
|
859
|
+
# @!visibility private
|
|
860
|
+
def remote_override_keys(payload, overrides)
|
|
861
|
+
klass = begin
|
|
862
|
+
name = payload.parse_class
|
|
863
|
+
name.present? ? Parse::Object.find_class(name) : nil
|
|
864
|
+
rescue StandardError
|
|
865
|
+
nil
|
|
866
|
+
end
|
|
867
|
+
map = klass.respond_to?(:field_map) ? klass.field_map : {}
|
|
868
|
+
overrides.each_with_object({}) do |(key, value), out|
|
|
869
|
+
key = key.to_s
|
|
870
|
+
head, rest = key.split(".", 2)
|
|
871
|
+
remote = map[head.to_sym]
|
|
872
|
+
head = remote.to_s if remote
|
|
873
|
+
out[rest ? "#{head}.#{rest}" : head] = value
|
|
874
|
+
end
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
# A handler's Hash override may name a sub-key (`"meta.y"`) of a field
|
|
878
|
+
# the reply writes whole (a create, or a field written whole on an
|
|
879
|
+
# update). MongoDB refuses `meta` and `meta.y` in one update, so the
|
|
880
|
+
# sub-key write is folded into the whole value. An override deeper
|
|
881
|
+
# than one level (`"meta.count.value"`) is folded into a `field.sub`
|
|
882
|
+
# write seeded from the pending object, because Parse Server rebuilds
|
|
883
|
+
# the afterSave object from a dotted key only one level deep. On a
|
|
884
|
+
# create every dotted override folds into its whole field.
|
|
885
|
+
#
|
|
886
|
+
# When the value it folds into is not a plain sub-document, a client's
|
|
887
|
+
# value is replaced by the handler's sub-key, and two of the handler's
|
|
888
|
+
# own overrides that conflict (`"meta.a" => 5` with `"meta.a.b" => 6`)
|
|
889
|
+
# raise {ResponseError}.
|
|
890
|
+
# @!visibility private
|
|
891
|
+
def fold_dotted_overrides!(reply, override_keys, raw_object: {}, create: false)
|
|
892
|
+
handler_keys = override_keys.to_set
|
|
893
|
+
override_keys.sort_by { |k| k.count(".") }.each do |key|
|
|
894
|
+
next unless key.include?(".") && reply.key?(key)
|
|
895
|
+
segments = key.split(".")
|
|
896
|
+
# The nearest ancestor path the reply writes whole, if any.
|
|
897
|
+
parent = (1...segments.length).map { |n| segments.first(n).join(".") }.reverse.find { |p| reply.key?(p) }
|
|
898
|
+
unless parent
|
|
899
|
+
next if !create && segments.length <= 2
|
|
900
|
+
parent = create ? segments.first : segments.first(2).join(".")
|
|
901
|
+
seed = parent.split(".").reduce(raw_object) { |cur, seg| cur.is_a?(Hash) ? cur[seg] : nil }
|
|
902
|
+
reply[parent] = plain_sub_document?(seed) ? seed.deep_dup : {}
|
|
903
|
+
end
|
|
904
|
+
value = reply.delete(key)
|
|
905
|
+
whole = reply[parent]
|
|
906
|
+
if plain_sub_document?(whole)
|
|
907
|
+
whole = whole.deep_dup
|
|
908
|
+
elsif handler_keys.include?(parent)
|
|
909
|
+
raise Parse::Webhooks::ResponseError,
|
|
910
|
+
"before_save reply: #{key} conflicts with #{parent} in the handler's reply"
|
|
911
|
+
else
|
|
912
|
+
whole = {}
|
|
913
|
+
end
|
|
914
|
+
*dirs, leaf = segments.drop(parent.count(".") + 1)
|
|
915
|
+
node = dirs.reduce(whole) do |cur, dir|
|
|
916
|
+
cur[dir] = {} unless plain_sub_document?(cur[dir])
|
|
917
|
+
cur[dir]
|
|
918
|
+
end
|
|
919
|
+
if value.is_a?(Hash) && value.key?("__op")
|
|
920
|
+
# A whole write stores its value as data, so an operator folded
|
|
921
|
+
# into it is applied here rather than written as a Hash.
|
|
922
|
+
result = fold_override_op(key, node[leaf], value)
|
|
923
|
+
if result.equal?(FOLD_DELETE)
|
|
924
|
+
node.delete(leaf)
|
|
925
|
+
else
|
|
926
|
+
node[leaf] = result
|
|
927
|
+
end
|
|
928
|
+
else
|
|
929
|
+
node[leaf] = value
|
|
930
|
+
end
|
|
931
|
+
reply[parent] = whole
|
|
932
|
+
end
|
|
933
|
+
reply
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
# @!visibility private
|
|
937
|
+
FOLD_DELETE = Object.new.freeze
|
|
938
|
+
|
|
939
|
+
# Apply a handler's sub-key operator to the value it replaces inside a
|
|
940
|
+
# sub-document the reply writes whole, with Parse Server's semantics
|
|
941
|
+
# for that operator. Raises {ResponseError} (an `{error}` reply, so
|
|
942
|
+
# Parse Server refuses the save) for an operator that cannot be applied
|
|
943
|
+
# to a plain value, rather than storing the operator Hash as data.
|
|
944
|
+
# @param key [String] the dotted override key, for the error message.
|
|
945
|
+
# @param current [Object] the value at that path in the whole write.
|
|
946
|
+
# @param op [Hash] the operator Hash (`{"__op" => ...}`).
|
|
947
|
+
# @return [Object] the new value, or {FOLD_DELETE} to remove the key.
|
|
948
|
+
# @!visibility private
|
|
949
|
+
def fold_override_op(key, current, op)
|
|
950
|
+
name = op["__op"]
|
|
951
|
+
case name
|
|
952
|
+
when "Delete"
|
|
953
|
+
FOLD_DELETE
|
|
954
|
+
when "Increment"
|
|
955
|
+
amount = op["amount"]
|
|
956
|
+
unless amount.is_a?(Numeric) && (current.nil? || current.is_a?(Numeric))
|
|
957
|
+
raise Parse::Webhooks::ResponseError,
|
|
958
|
+
"before_save reply: cannot apply Increment to #{key} (#{current.class})"
|
|
959
|
+
end
|
|
960
|
+
(current || 0) + amount
|
|
961
|
+
when "Add", "AddUnique", "Remove"
|
|
962
|
+
objects = op["objects"]
|
|
963
|
+
unless objects.is_a?(Array) && (current.nil? || current.is_a?(Array))
|
|
964
|
+
raise Parse::Webhooks::ResponseError,
|
|
965
|
+
"before_save reply: cannot apply #{name} to #{key} (#{current.class})"
|
|
966
|
+
end
|
|
967
|
+
base = current || []
|
|
968
|
+
case name
|
|
969
|
+
when "Add" then base + objects
|
|
970
|
+
when "AddUnique" then base + objects.reject { |o| base.include?(o) }.uniq
|
|
971
|
+
else base.reject { |o| objects.include?(o) }
|
|
972
|
+
end
|
|
973
|
+
else
|
|
974
|
+
raise Parse::Webhooks::ResponseError,
|
|
975
|
+
"before_save reply: operator #{name.inspect} on #{key} cannot be combined " \
|
|
976
|
+
"with a whole write of its parent field"
|
|
977
|
+
end
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
# @!visibility private
|
|
981
|
+
def handler_assigned_acl?(obj)
|
|
982
|
+
return true if obj.instance_variable_get(:@_webhook_reply_acl) == true
|
|
983
|
+
obj.respond_to?(:webhook_handler_acl_assigned?) && obj.webhook_handler_acl_assigned?
|
|
984
|
+
end
|
|
985
|
+
|
|
986
|
+
# On a client create, an owner-based ACL policy (`:owner_else_private`,
|
|
987
|
+
# the shipped default, `:owner_else_public`, `:owner_but_public_read`)
|
|
988
|
+
# resolves its owner the way an SDK create `as:` the requesting user
|
|
989
|
+
# does: when the declared owner field holds no value, the user who made
|
|
990
|
+
# the request owns the record. A request without a user (anonymous, or
|
|
991
|
+
# master key) gets the policy's fallback. Leaves the object alone when
|
|
992
|
+
# the handler or the client already set an ACL.
|
|
993
|
+
# @!visibility private
|
|
994
|
+
def adopt_request_user_as_acl_owner!(payload, obj)
|
|
995
|
+
return unless payload && payload.original.nil?
|
|
996
|
+
if handler_assigned_acl?(obj)
|
|
997
|
+
# The handler's ACL is final: keep the save-time policy resolver
|
|
998
|
+
# from replacing it, even when it equals the default stamp.
|
|
999
|
+
obj.instance_variable_set(:@_acl_pristine, false)
|
|
1000
|
+
return
|
|
1001
|
+
end
|
|
1002
|
+
return unless obj.is_a?(Parse::Object) && obj.instance_variable_get(:@_acl_pristine)
|
|
1003
|
+
return if obj.instance_variable_get(:@_acl_owner_override)
|
|
1004
|
+
klass = obj.class
|
|
1005
|
+
return if klass.respond_to?(:builtin_acl_default_active?) && klass.builtin_acl_default_active?
|
|
1006
|
+
return unless klass.respond_to?(:acl_policy_setting) && klass.acl_policy_setting.to_s.start_with?("owner_")
|
|
1007
|
+
field = klass.acl_owner_field
|
|
1008
|
+
if field == :self
|
|
1009
|
+
adopt_self_owned_acl!(obj, klass)
|
|
1010
|
+
return
|
|
1011
|
+
end
|
|
1012
|
+
# A master-key request is not the user's own write.
|
|
1013
|
+
return if payload.master?
|
|
1014
|
+
user = payload.user
|
|
1015
|
+
return unless user.is_a?(Parse::User) && user.id.present?
|
|
1016
|
+
if field && obj.respond_to?(field)
|
|
1017
|
+
return if obj.send(field).present?
|
|
1018
|
+
end
|
|
1019
|
+
obj.instance_variable_set(:@_acl_owner_override, user)
|
|
1020
|
+
end
|
|
1021
|
+
|
|
1022
|
+
# A self-owned user (`acl_policy ..., owner: :self`) owns its own
|
|
1023
|
+
# record, never the requesting user. Its objectId is assigned by Parse
|
|
1024
|
+
# Server after beforeSave, and Parse Server adds the new user's own
|
|
1025
|
+
# read and write to the ACL of every `_User` create. So the reply
|
|
1026
|
+
# carries the policy's ACL without the owner entry (`{}`, or public
|
|
1027
|
+
# read under `:owner_but_public_read`), which Parse Server completes
|
|
1028
|
+
# with the user's own grant. The save-time resolver is skipped, since
|
|
1029
|
+
# it would pre-generate an objectId the server does not use.
|
|
1030
|
+
# @!visibility private
|
|
1031
|
+
def adopt_self_owned_acl!(obj, klass)
|
|
1032
|
+
acl = if klass.acl_policy_setting == :owner_but_public_read
|
|
1033
|
+
Parse::ACL.everyone(true, false)
|
|
1034
|
+
else
|
|
1035
|
+
Parse::ACL.new
|
|
1036
|
+
end
|
|
1037
|
+
klass.instance_method(:acl=).bind_call(obj, acl)
|
|
1038
|
+
obj.instance_variable_set(:@_acl_pristine, false)
|
|
1039
|
+
obj.instance_variable_set(:@_webhook_reply_acl, true)
|
|
1040
|
+
end
|
|
1041
|
+
|
|
1042
|
+
# The client's write, rebuilt from a beforeSave payload. Parse Server
|
|
1043
|
+
# serializes the pending object with `toJSON()`, which reports every
|
|
1044
|
+
# pending top-level operator as its operator hash, so the operators
|
|
1045
|
+
# survive here as sent. On an update, a field whose value equals the
|
|
1046
|
+
# stored one was not written by the client and is left out, and a
|
|
1047
|
+
# changed sub-document is split into dotted sub-key writes (see
|
|
1048
|
+
# {sub_document_write}).
|
|
1049
|
+
#
|
|
1050
|
+
# @param raw_object [Hash] the unscrubbed `object` hash.
|
|
1051
|
+
# @param raw_original [Hash, nil] the unscrubbed `original` hash.
|
|
1052
|
+
# @return [Hash]
|
|
1053
|
+
# @!visibility private
|
|
1054
|
+
def client_write_data(raw_object, raw_original)
|
|
1055
|
+
data = {}
|
|
1056
|
+
raw_object.each do |key, value|
|
|
1057
|
+
key = key.to_s
|
|
1058
|
+
next if BEFORE_SAVE_REPLY_SKIP_KEYS.include?(key)
|
|
1059
|
+
if raw_original
|
|
1060
|
+
# Parse Server drops objectId from an update write itself.
|
|
1061
|
+
next if key == Parse::Model::OBJECT_ID
|
|
1062
|
+
next if raw_original.key?(key) && raw_original[key] == value
|
|
1063
|
+
dotted = sub_document_write(key, value, raw_original[key])
|
|
1064
|
+
if dotted
|
|
1065
|
+
data.merge!(dotted)
|
|
1066
|
+
next
|
|
1067
|
+
end
|
|
1068
|
+
end
|
|
1069
|
+
data[key] = value
|
|
1070
|
+
end
|
|
1071
|
+
data
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
# Keys never split into dotted sub-key writes: their values are
|
|
1075
|
+
# replaced as a whole by Parse Server.
|
|
1076
|
+
# @!visibility private
|
|
1077
|
+
BEFORE_SAVE_REPLY_WHOLE_KEYS = %w[ACL authData].freeze
|
|
1078
|
+
|
|
1079
|
+
# Dotted sub-key writes for a sub-document the client changed.
|
|
1080
|
+
#
|
|
1081
|
+
# Parse Server applies a client's `"meta.count"` operator to its pending
|
|
1082
|
+
# object and sends the webhook only the resulting `meta`, which matches
|
|
1083
|
+
# a whole-object write of the same value. Writing just the sub-keys that
|
|
1084
|
+
# differ from the stored sub-document gives the same result as either
|
|
1085
|
+
# client write, without overwriting sub-keys another request changed in
|
|
1086
|
+
# the meantime. The split is one level deep: a changed sub-key is
|
|
1087
|
+
# written whole at `field.sub`, nested objects included, because Parse
|
|
1088
|
+
# Server rebuilds the afterSave object from a non-operator dotted key
|
|
1089
|
+
# only one level deep (`"meta.count.value"` would set `meta.count` to
|
|
1090
|
+
# the leaf). A sub-key missing from the new value is written as a
|
|
1091
|
+
# `Delete` operator.
|
|
1092
|
+
#
|
|
1093
|
+
# Parse Server stores a dotted value as sent, without the type transform
|
|
1094
|
+
# a whole write gets, so a Date would land as a plain sub-document
|
|
1095
|
+
# instead of a BSON date. When any write would carry a typed value
|
|
1096
|
+
# (`{"__type": ...}`), the field is written whole instead.
|
|
1097
|
+
#
|
|
1098
|
+
# @param key [String] the top-level field name.
|
|
1099
|
+
# @param value [Object] the field's value in the pending object.
|
|
1100
|
+
# @param stored [Object] the field's stored value.
|
|
1101
|
+
# @return [Hash, nil] the dotted writes, or nil to write the field whole.
|
|
1102
|
+
# @!visibility private
|
|
1103
|
+
def sub_document_write(key, value, stored)
|
|
1104
|
+
return nil if key.start_with?("_") || BEFORE_SAVE_REPLY_WHOLE_KEYS.include?(key)
|
|
1105
|
+
return nil unless splittable_level?(value, stored)
|
|
1106
|
+
writes = {}
|
|
1107
|
+
diff_sub_document(key, value, stored, writes)
|
|
1108
|
+
return nil if writes.empty? || writes.values.any? { |v| typed_value?(v) }
|
|
1109
|
+
writes
|
|
1110
|
+
end
|
|
1111
|
+
|
|
1112
|
+
# Collect the one-level dotted writes that turn `stored` into `value`
|
|
1113
|
+
# under `path`.
|
|
1114
|
+
# @!visibility private
|
|
1115
|
+
def diff_sub_document(path, value, stored, writes)
|
|
1116
|
+
(value.keys | stored.keys).each do |sub|
|
|
1117
|
+
new_present = value.key?(sub)
|
|
1118
|
+
old_present = stored.key?(sub)
|
|
1119
|
+
next if new_present && old_present && value[sub] == stored[sub]
|
|
1120
|
+
sub_path = "#{path}.#{sub}"
|
|
1121
|
+
writes[sub_path] = new_present ? value[sub] : { "__op" => "Delete" }
|
|
1122
|
+
end
|
|
1123
|
+
end
|
|
1124
|
+
|
|
1125
|
+
# Whether a pair of values can be diffed key by key: both plain JSON
|
|
1126
|
+
# objects, with sub-keys that are usable as path segments.
|
|
1127
|
+
# @!visibility private
|
|
1128
|
+
def splittable_level?(value, stored)
|
|
1129
|
+
return false unless plain_sub_document?(value) && plain_sub_document?(stored)
|
|
1130
|
+
keys = value.keys | stored.keys
|
|
1131
|
+
return false if keys.empty?
|
|
1132
|
+
keys.none? { |k| k.to_s.empty? || k.to_s.include?(".") || k.to_s.start_with?("$") }
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
# Whether a JSON value is, or contains, a Parse typed value
|
|
1136
|
+
# (`{"__type": ...}`: Date, Bytes, Pointer, File, GeoPoint, ...).
|
|
1137
|
+
# @!visibility private
|
|
1138
|
+
def typed_value?(value)
|
|
1139
|
+
case value
|
|
1140
|
+
when Hash then value.key?("__type") || value.values.any? { |v| typed_value?(v) }
|
|
1141
|
+
when Array then value.any? { |v| typed_value?(v) }
|
|
1142
|
+
else false
|
|
1143
|
+
end
|
|
1144
|
+
end
|
|
1145
|
+
|
|
1146
|
+
# A JSON object field value: a Hash that is not a Parse operator or a
|
|
1147
|
+
# typed value (Pointer, Date, File, GeoPoint, ...).
|
|
1148
|
+
# @!visibility private
|
|
1149
|
+
def plain_sub_document?(value)
|
|
1150
|
+
value.is_a?(Hash) && !value.key?("__op") && !value.key?("__type")
|
|
1151
|
+
end
|
|
1152
|
+
|
|
1153
|
+
# Diff the handler's object against a fresh build of the same payload.
|
|
1154
|
+
#
|
|
1155
|
+
# Only fields that are dirty on either object can differ: a field the
|
|
1156
|
+
# client did not write and the handler did not touch is clean on both.
|
|
1157
|
+
# Comparing just those keeps the getters off fields the payload never
|
|
1158
|
+
# carried (which would otherwise autofetch).
|
|
1159
|
+
#
|
|
1160
|
+
# @return [Array(Hash, Array<String>)] the changed wire fields with their
|
|
1161
|
+
# encoded values, and the wire fields to drop from the write because
|
|
1162
|
+
# the handler returned them to their stored value.
|
|
1163
|
+
# @!visibility private
|
|
1164
|
+
def handler_field_changes(payload, obj, raw_original)
|
|
1165
|
+
fresh = payload.unmemoized_parse_object
|
|
1166
|
+
fresh = nil unless fresh.instance_of?(obj.class)
|
|
1167
|
+
candidates = obj.changed.map(&:to_sym)
|
|
1168
|
+
candidates |= fresh.changed.map(&:to_sym) if fresh
|
|
1169
|
+
candidates &= snapshot_fields(obj)
|
|
1170
|
+
after = wire_values(obj, candidates)
|
|
1171
|
+
before = fresh ? wire_values(fresh, candidates) : {}
|
|
1172
|
+
|
|
1173
|
+
changes = {}
|
|
1174
|
+
drops = []
|
|
1175
|
+
after.each do |remote, value|
|
|
1176
|
+
next if before.key?(remote) && before[remote] == value
|
|
1177
|
+
if raw_original
|
|
1178
|
+
stored = raw_original.key?(remote) ? raw_original[remote].as_json : nil
|
|
1179
|
+
unchanged = raw_original.key?(remote) ? stored == value : (value.is_a?(Hash) && value["__op"] == "Delete")
|
|
1180
|
+
if unchanged
|
|
1181
|
+
drops << remote
|
|
1182
|
+
next
|
|
1183
|
+
end
|
|
1184
|
+
end
|
|
1185
|
+
changes[remote] = value
|
|
1186
|
+
end
|
|
1187
|
+
|
|
1188
|
+
# Relation edits are tracked on the proxies, not as field values. A
|
|
1189
|
+
# relation field whose pending operation differs from the client's
|
|
1190
|
+
# was changed by the handler or reverted by a field guard: write the
|
|
1191
|
+
# handler's operation, or drop the client's when none is left.
|
|
1192
|
+
ops_after = relation_ops(obj)
|
|
1193
|
+
ops_before = fresh ? relation_ops(fresh) : {}
|
|
1194
|
+
(ops_after.keys | ops_before.keys).each do |remote|
|
|
1195
|
+
next if ops_after[remote] == ops_before[remote]
|
|
1196
|
+
if ops_after.key?(remote)
|
|
1197
|
+
changes[remote] = ops_after[remote]
|
|
1198
|
+
else
|
|
1199
|
+
drops << remote
|
|
1200
|
+
end
|
|
1201
|
+
end
|
|
1202
|
+
[changes, drops]
|
|
1203
|
+
end
|
|
1204
|
+
|
|
1205
|
+
# Pending relation operations of an object, keyed by remote field.
|
|
1206
|
+
# Additions win over removals for the same field, matching
|
|
1207
|
+
# `changes_payload`.
|
|
1208
|
+
# @!visibility private
|
|
1209
|
+
def relation_ops(obj)
|
|
1210
|
+
return {} unless obj.respond_to?(:relation_changes?) && obj.relation_changes?
|
|
1211
|
+
additions, removals = obj.relation_change_operations.as_json
|
|
1212
|
+
(removals || {}).merge(additions || {})
|
|
1213
|
+
end
|
|
1214
|
+
|
|
1215
|
+
# Declared, non-base, non-relation fields of an object.
|
|
1216
|
+
# @!visibility private
|
|
1217
|
+
def snapshot_fields(obj)
|
|
1218
|
+
klass = obj.class
|
|
1219
|
+
return [] unless klass.respond_to?(:fields)
|
|
1220
|
+
klass.fields.reject do |name, type|
|
|
1221
|
+
Parse::Properties::BASE_KEYS.include?(name.to_sym) || type == :relation
|
|
1222
|
+
end.keys.map(&:to_sym)
|
|
1223
|
+
end
|
|
1224
|
+
|
|
1225
|
+
# Encode the given fields of an object in Parse wire form, keyed by
|
|
1226
|
+
# remote field name, using the same encoder the SDK uses for a save
|
|
1227
|
+
# (`attribute_updates`). A nil value encodes as a Delete operator.
|
|
1228
|
+
# Autofetch is suspended so reading a field never reaches the network.
|
|
1229
|
+
# @!visibility private
|
|
1230
|
+
def wire_values(obj, fields)
|
|
1231
|
+
return {} if fields.empty?
|
|
1232
|
+
names = fields.map(&:to_s).freeze
|
|
1233
|
+
prior_autofetch = obj.instance_variable_get(:@_autofetch_disabled)
|
|
1234
|
+
obj.instance_variable_set(:@_autofetch_disabled, true)
|
|
1235
|
+
obj.define_singleton_method(:changed) { names }
|
|
1236
|
+
begin
|
|
1237
|
+
obj.attribute_updates.as_json
|
|
1238
|
+
ensure
|
|
1239
|
+
singleton = obj.singleton_class
|
|
1240
|
+
singleton.send(:remove_method, :changed) if singleton.method_defined?(:changed, false)
|
|
1241
|
+
obj.instance_variable_set(:@_autofetch_disabled, prior_autofetch)
|
|
1242
|
+
end
|
|
1243
|
+
end
|
|
1244
|
+
|
|
1245
|
+
# Resolve an afterFind handler result.
|
|
1246
|
+
#
|
|
1247
|
+
# Parse Server's afterFind handling maps each returned row through
|
|
1248
|
+
# `toJSONwithObjects`, which turns any plain JSON object (everything an
|
|
1249
|
+
# HTTP webhook can send) into `{}`, and crashes on a non-array body. A
|
|
1250
|
+
# missing result keeps the matched rows. So an HTTP afterFind can observe
|
|
1251
|
+
# the rows or deny the query, but it cannot rewrite them. The reply is
|
|
1252
|
+
# always `nil` (sent as `{}`, with no `success` key), and a handler that tries
|
|
1253
|
+
# to drop or add rows is refused with an error rather than having the
|
|
1254
|
+
# rows it meant to hide returned anyway.
|
|
1255
|
+
#
|
|
1256
|
+
# @param payload [Parse::Webhooks::Payload] the afterFind payload.
|
|
1257
|
+
# @param result [Object] the handler's return value.
|
|
1258
|
+
# @return [nil]
|
|
1259
|
+
# @raise [Parse::Webhooks::ResponseError] when the handler returned
|
|
1260
|
+
# `false` or a different set of rows.
|
|
1261
|
+
# @!visibility private
|
|
1262
|
+
def after_find_reply!(payload, result)
|
|
1263
|
+
if result == false
|
|
1264
|
+
raise Parse::Webhooks::ResponseError, "afterFind rejected by webhook handler"
|
|
1265
|
+
end
|
|
1266
|
+
if result.is_a?(Array) && payload
|
|
1267
|
+
returned = result.map { |row| after_find_row_id(row) }
|
|
1268
|
+
matched = Array(payload.objects).map { |row| after_find_row_id(row) }
|
|
1269
|
+
unless returned.size == matched.size && returned.sort_by(&:to_s) == matched.sort_by(&:to_s)
|
|
1270
|
+
warn "[Parse::Webhooks] an afterFind handler for " \
|
|
1271
|
+
"#{Parse::TerminalSafe.sanitize_line(payload.parse_class)} returned a different " \
|
|
1272
|
+
"set of rows. Parse Server cannot apply row changes from an HTTP afterFind " \
|
|
1273
|
+
"webhook, so the find is denied instead. Filter in beforeFind, or deny with error!."
|
|
1274
|
+
raise Parse::Webhooks::ResponseError,
|
|
1275
|
+
"afterFind webhooks cannot filter or replace results"
|
|
1276
|
+
end
|
|
1277
|
+
end
|
|
1278
|
+
nil
|
|
1279
|
+
end
|
|
1280
|
+
|
|
1281
|
+
# @!visibility private
|
|
1282
|
+
def after_find_row_id(row)
|
|
1283
|
+
case row
|
|
1284
|
+
when Hash then row["objectId"] || row[:objectId] || row["id"] || row[:id]
|
|
1285
|
+
else row.respond_to?(:id) ? row.id : row
|
|
1286
|
+
end
|
|
1287
|
+
end
|
|
1288
|
+
|
|
1289
|
+
# The value sent as `success` for a routed (or unrouted) trigger result.
|
|
1290
|
+
# `nil` for beforeSave / afterFind means "pass through": the Rack app
|
|
1291
|
+
# then replies `{}` (see {PASS_THROUGH_BODY}).
|
|
1292
|
+
#
|
|
1293
|
+
# @param payload [Parse::Webhooks::Payload] the request payload.
|
|
1294
|
+
# @param result [Object] the dispatch result.
|
|
1295
|
+
# @return [Object] the success value.
|
|
1296
|
+
# @!visibility private
|
|
1297
|
+
def trigger_success_value(payload, result)
|
|
1298
|
+
# beforeSave: nil means "keep the write as sent", a Hash replaces it.
|
|
1299
|
+
return (result.is_a?(Hash) ? result : nil) if payload.before_save?
|
|
1300
|
+
# afterFind: only "no result" is safe (keeps the rows); see after_find_reply!.
|
|
1301
|
+
return nil if payload.after_find?
|
|
1302
|
+
result.nil? ? true : result
|
|
1303
|
+
end
|
|
1304
|
+
|
|
1305
|
+
# Whether a dispatch result should be sent as {PASS_THROUGH_BODY}.
|
|
1306
|
+
# @!visibility private
|
|
1307
|
+
def pass_through_reply?(payload, result)
|
|
1308
|
+
result.nil? && payload.respond_to?(:trigger?) && payload.trigger? &&
|
|
1309
|
+
(payload.before_save? || payload.after_find?)
|
|
1310
|
+
end
|
|
1311
|
+
|
|
684
1312
|
# Generates a success response for Parse Server.
|
|
685
1313
|
# @param data [Object] the data to send back with the success.
|
|
686
1314
|
# @return [Hash] a success data payload
|
|
@@ -690,9 +1318,12 @@ module Parse
|
|
|
690
1318
|
|
|
691
1319
|
# Generates an error response for Parse Server.
|
|
692
1320
|
# @param data [Object] the data to send back with the error.
|
|
1321
|
+
# @param code [Integer, nil] an optional Parse error code to include.
|
|
693
1322
|
# @return [Hash] a error data payload
|
|
694
|
-
def error(data = false)
|
|
695
|
-
{ error: data }
|
|
1323
|
+
def error(data = false, code = nil)
|
|
1324
|
+
body = { error: data }
|
|
1325
|
+
body[:code] = code unless code.nil?
|
|
1326
|
+
body.to_json
|
|
696
1327
|
end
|
|
697
1328
|
|
|
698
1329
|
# @!attribute key
|
|
@@ -862,6 +1493,14 @@ module Parse
|
|
|
862
1493
|
return response.finish
|
|
863
1494
|
end
|
|
864
1495
|
|
|
1496
|
+
# A trigger whose body names a different class than the one the
|
|
1497
|
+
# request was routed to (the URL path) is forged or misrouted. Refuse
|
|
1498
|
+
# it before any handler runs or any typed object is built from it.
|
|
1499
|
+
if payload.trigger? && payload.payload_class_mismatch?
|
|
1500
|
+
response.write error("Webhook payload class does not match the trigger class.")
|
|
1501
|
+
return response.finish
|
|
1502
|
+
end
|
|
1503
|
+
|
|
865
1504
|
if self.logging.present?
|
|
866
1505
|
# Everything interpolated below arrives in the webhook request body:
|
|
867
1506
|
# the trigger/function names, the object id, and the whole payload are
|
|
@@ -887,7 +1526,14 @@ module Parse
|
|
|
887
1526
|
begin
|
|
888
1527
|
result = true
|
|
889
1528
|
if payload.function? && payload.function_name.present?
|
|
1529
|
+
# An unknown function must fail. Answering success would tell the
|
|
1530
|
+
# caller that a function ran when nothing did.
|
|
1531
|
+
unless Parse::Webhooks.route_registered?(:function, payload.function_name)
|
|
1532
|
+
raise Parse::Webhooks::ResponseError,
|
|
1533
|
+
"Webhook function #{payload.function_name} is not registered."
|
|
1534
|
+
end
|
|
890
1535
|
result = Parse::Webhooks.call_route(:function, payload.function_name, payload)
|
|
1536
|
+
result = true if result.nil?
|
|
891
1537
|
elsif payload.trigger? && payload.parse_class.present? && payload.trigger_name.present?
|
|
892
1538
|
# call hooks subscribed to the specific class
|
|
893
1539
|
result = Parse::Webhooks.call_route(payload.trigger_name, payload.parse_class, payload)
|
|
@@ -896,25 +1542,42 @@ module Parse
|
|
|
896
1542
|
generic_result = Parse::Webhooks.call_route(payload.trigger_name, "*", payload)
|
|
897
1543
|
result = generic_result if generic_result.present? && result.nil?
|
|
898
1544
|
|
|
899
|
-
# Fire the chained ActiveModel after_save/after_create
|
|
900
|
-
# exactly once per delivery -- after BOTH
|
|
901
|
-
# app that registers both a class route
|
|
902
|
-
# double-fire them.
|
|
1545
|
+
# Fire the chained ActiveModel after_save/after_create (or
|
|
1546
|
+
# after_destroy) callbacks exactly once per delivery -- after BOTH
|
|
1547
|
+
# route calls above -- so an app that registers both a class route
|
|
1548
|
+
# and a `"*"` route doesn't double-fire them. Each is a no-op for
|
|
1549
|
+
# every other trigger.
|
|
903
1550
|
Parse::Webhooks.run_after_save_chain(payload)
|
|
1551
|
+
Parse::Webhooks.run_after_delete_chain(payload)
|
|
1552
|
+
|
|
1553
|
+
# An unrouted trigger (or a handler that returned nil) passes the
|
|
1554
|
+
# operation through unchanged, in the shape each trigger needs.
|
|
1555
|
+
result = Parse::Webhooks.trigger_success_value(payload, result)
|
|
1556
|
+
elsif payload.trigger?
|
|
1557
|
+
# A trigger the router cannot attribute to a class: pass it through
|
|
1558
|
+
# unchanged rather than failing the client's operation.
|
|
1559
|
+
if self.logging.present?
|
|
1560
|
+
puts "[Webhooks] --> Could not find mapping route for " \
|
|
1561
|
+
"#{Parse::TerminalSafe.sanitize_line(Parse::Middleware::BodyBuilder.redact(payload.to_json))}"
|
|
1562
|
+
end
|
|
1563
|
+
result = Parse::Webhooks.trigger_success_value(payload, nil)
|
|
904
1564
|
else
|
|
905
1565
|
if self.logging.present?
|
|
906
1566
|
puts "[Webhooks] --> Could not find mapping route for " \
|
|
907
1567
|
"#{Parse::TerminalSafe.sanitize_line(Parse::Middleware::BodyBuilder.redact(payload.to_json))}"
|
|
908
1568
|
end
|
|
1569
|
+
raise Parse::Webhooks::ResponseError, "Unrecognized webhook request."
|
|
909
1570
|
end
|
|
910
1571
|
|
|
911
|
-
|
|
1572
|
+
body = pass_through_reply?(payload, result) ? PASS_THROUGH_BODY : success(result)
|
|
912
1573
|
if self.logging.present?
|
|
1574
|
+
# The reply can echo the client's write (passwords, auth data,
|
|
1575
|
+
# tokens a handler returned), so it is redacted like the request.
|
|
913
1576
|
puts "[Webhooks::Response] ----------------------------"
|
|
914
|
-
puts Parse::TerminalSafe.sanitize(
|
|
1577
|
+
puts Parse::TerminalSafe.sanitize(Parse::Middleware::BodyBuilder.redact(body))
|
|
915
1578
|
puts "----------------------------------------------------\n"
|
|
916
1579
|
end
|
|
917
|
-
response.write
|
|
1580
|
+
response.write body
|
|
918
1581
|
# Schedule any after_response work to run once this reply is flushed,
|
|
919
1582
|
# off the client's critical path. Registered on the success path so the
|
|
920
1583
|
# deferred work overlaps a response Parse Server will act on.
|
|
@@ -930,7 +1593,19 @@ module Parse
|
|
|
930
1593
|
puts "[Webhooks::ResponseError] >> #{Parse::TerminalSafe.sanitize_line(payload.function_name)}: " \
|
|
931
1594
|
"#{Parse::TerminalSafe.sanitize_line(e.to_s)}"
|
|
932
1595
|
end
|
|
933
|
-
|
|
1596
|
+
code = e.respond_to?(:code) ? e.code : nil
|
|
1597
|
+
response.write error(e.to_s, code)
|
|
1598
|
+
return response.finish
|
|
1599
|
+
rescue StandardError => e
|
|
1600
|
+
# Anything else a handler (or the router) raised. Answer with a JSON
|
|
1601
|
+
# error so Parse Server denies the operation cleanly, and keep the
|
|
1602
|
+
# exception message out of the reply: it can quote record data or
|
|
1603
|
+
# internals. The log line carries the class and a redacted message.
|
|
1604
|
+
where = payload.trigger? ? "#{payload.trigger_name} #{payload.parse_class}" : payload.function_name
|
|
1605
|
+
warn "[Webhooks::Error] >> #{Parse::TerminalSafe.sanitize_line(where.to_s)}: #{e.class}: " \
|
|
1606
|
+
"#{Parse::TerminalSafe.sanitize_line(Parse::Middleware::BodyBuilder.redact(e.message.to_s))}"
|
|
1607
|
+
response = Rack::Response.new
|
|
1608
|
+
response.write error("Webhook handler failed.")
|
|
934
1609
|
return response.finish
|
|
935
1610
|
end
|
|
936
1611
|
|