parse-stack-next 5.8.1 → 5.8.2
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 +312 -0
- data/README.md +6 -0
- data/docs/caching.md +40 -5
- data/docs/mcp_guide.md +7 -2
- data/docs/mongodb_direct_guide.md +29 -0
- data/docs/webhooks_guide.md +35 -2
- data/lib/parse/agent/constraint_translator.rb +22 -21
- data/lib/parse/agent/errors.rb +17 -0
- data/lib/parse/agent/mcp_server.rb +47 -6
- data/lib/parse/agent.rb +316 -50
- data/lib/parse/api/batch.rb +70 -2
- data/lib/parse/atlas_search.rb +1 -0
- data/lib/parse/client/batch.rb +231 -12
- data/lib/parse/client/body_builder.rb +100 -15
- data/lib/parse/client/caching.rb +53 -3
- data/lib/parse/client/request.rb +59 -0
- data/lib/parse/client/response.rb +63 -5
- data/lib/parse/client.rb +147 -31
- data/lib/parse/live_query/client.rb +70 -9
- data/lib/parse/model/classes/session.rb +44 -16
- data/lib/parse/model/core/actions.rb +22 -1
- data/lib/parse/model/file.rb +96 -19
- data/lib/parse/mongodb.rb +24 -1
- data/lib/parse/pipeline_security.rb +157 -21
- data/lib/parse/query/constraints.rb +634 -14
- data/lib/parse/query.rb +9 -5
- data/lib/parse/stack/tasks.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +6 -2
- data/lib/parse/vector_search/hybrid.rb +4 -1
- data/lib/parse/vector_search.rb +1 -0
- data/lib/parse/webhooks/payload.rb +39 -8
- data/lib/parse/webhooks/registration.rb +26 -6
- data/lib/parse/webhooks/replay_protection.rb +19 -9
- data/lib/parse/webhooks.rb +17 -4
- metadata +1 -1
|
@@ -264,19 +264,77 @@ module Parse
|
|
|
264
264
|
self
|
|
265
265
|
end
|
|
266
266
|
|
|
267
|
+
# A summary that never prints result values: login, signup, and
|
|
268
|
+
# `users/me` responses carry a live `sessionToken` (and sometimes
|
|
269
|
+
# `authData`), and any other row can carry private data, so inspect
|
|
270
|
+
# shows only the shape of the result.
|
|
267
271
|
# @!visibility private
|
|
268
272
|
def inspect
|
|
269
273
|
if error?
|
|
270
|
-
"#<#{self.class} @code=#{code} @error='#{
|
|
274
|
+
"#<#{self.class} @code=#{code} @error='#{safe_error_text}' @http_status=#{http_status.inspect}>"
|
|
271
275
|
else
|
|
272
|
-
"#<#{self.class} @
|
|
276
|
+
"#<#{self.class} @http_status=#{http_status.inspect} @result=#{result_summary}>"
|
|
273
277
|
end
|
|
274
278
|
end
|
|
275
279
|
|
|
276
|
-
# @return [String] JSON encoded object, or an error string.
|
|
280
|
+
# @return [String] JSON encoded object, or an error string. Credential
|
|
281
|
+
# fields (`sessionToken`, `password`, `authData`, MFA recovery codes
|
|
282
|
+
# and secrets, keys) are replaced with a placeholder, and credential
|
|
283
|
+
# text inside string values (`password=x`, `X-Parse-Master-Key: mk`)
|
|
284
|
+
# is filtered too, so printing the response with `to_s` or `inspect`
|
|
285
|
+
# does not leak a live session token. The error form has its text redacted and control characters
|
|
286
|
+
# escaped. `#result`, `#to_json`, and `#as_json` return the raw values
|
|
287
|
+
# by design; do not log those.
|
|
277
288
|
def to_s
|
|
278
|
-
|
|
279
|
-
|
|
289
|
+
if error?
|
|
290
|
+
return "[E-#{@code}] #{safe_request_text} : #{safe_error_text} (#{@http_status})"
|
|
291
|
+
end
|
|
292
|
+
result = redacted_result
|
|
293
|
+
return Parse::Middleware::BodyBuilder.redact_patterns(result) if result.is_a?(String)
|
|
294
|
+
# Text patterns run on string values before encoding, never on the
|
|
295
|
+
# encoded JSON, so escaped quotes cannot defeat them or corrupt it.
|
|
296
|
+
Parse::Middleware::BodyBuilder.redact_string_values!(result).to_json
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
private
|
|
300
|
+
|
|
301
|
+
# Maximum error text length kept in `to_s` / `inspect`.
|
|
302
|
+
SAFE_ERROR_TEXT_LENGTH = 1_000
|
|
303
|
+
|
|
304
|
+
# The server's error text with credentials redacted, truncated, and
|
|
305
|
+
# control characters escaped, matching what the client logs.
|
|
306
|
+
def safe_error_text
|
|
307
|
+
text = Parse::Middleware::BodyBuilder.redact(@error.to_s)[0, SAFE_ERROR_TEXT_LENGTH]
|
|
308
|
+
Parse::TerminalSafe.sanitize_line(text)
|
|
309
|
+
rescue StandardError
|
|
310
|
+
"[unprintable error]"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# The request line (method and path, including any query string) with
|
|
314
|
+
# credentials redacted and control characters escaped.
|
|
315
|
+
def safe_request_text
|
|
316
|
+
Parse::TerminalSafe.sanitize_line(Parse::Middleware::BodyBuilder.redact(@request.to_s))
|
|
317
|
+
rescue StandardError
|
|
318
|
+
"[unprintable request]"
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Class and size of the result, without its values.
|
|
322
|
+
def result_summary
|
|
323
|
+
case @result
|
|
324
|
+
when Array then "Array(#{@result.size})"
|
|
325
|
+
when Hash then "Hash(#{@result.size} keys)"
|
|
326
|
+
when nil then "nil"
|
|
327
|
+
else @result.class.name
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# A deep copy of the result with credential fields replaced.
|
|
332
|
+
def redacted_result
|
|
333
|
+
return @result unless @result.is_a?(Hash) || @result.is_a?(Array)
|
|
334
|
+
copy = JSON.parse(@result.to_json)
|
|
335
|
+
Parse::Middleware::BodyBuilder.scrub_sensitive!(copy)
|
|
336
|
+
rescue StandardError
|
|
337
|
+
"[unprintable result]"
|
|
280
338
|
end
|
|
281
339
|
end
|
|
282
340
|
end
|
data/lib/parse/client.rb
CHANGED
|
@@ -419,6 +419,39 @@ module Parse
|
|
|
419
419
|
"session_token=#{@session_token ? "[FILTERED]" : "nil"}>"
|
|
420
420
|
end
|
|
421
421
|
|
|
422
|
+
# Redacted JSON form. ActiveSupport's default `as_json` serializes every
|
|
423
|
+
# instance variable, so a client in a JSON log or error-tracker context
|
|
424
|
+
# (or inside an object that serializes it, such as a {Parse::Agent})
|
|
425
|
+
# would emit the master key, REST key, and bound session token.
|
|
426
|
+
# @return [Hash]
|
|
427
|
+
def as_json(*)
|
|
428
|
+
{
|
|
429
|
+
"server_url" => @server_url,
|
|
430
|
+
"app_id" => @application_id,
|
|
431
|
+
"master_key" => @master_key ? "[FILTERED]" : nil,
|
|
432
|
+
"session_token" => @session_token ? "[FILTERED]" : nil,
|
|
433
|
+
}
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# @return [String] the redacted {#as_json} summary as JSON.
|
|
437
|
+
def to_json(*args)
|
|
438
|
+
as_json.to_json(*args)
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# YAML (Psych) serializes instance variables too; emit the redacted
|
|
442
|
+
# summary instead.
|
|
443
|
+
def encode_with(coder)
|
|
444
|
+
as_json.each { |k, v| coder[k] = v }
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# A client holds the master key, REST key, and session tokens, so it is
|
|
448
|
+
# never marshaled (Rails.cache, DRb, or a job payload would store them in
|
|
449
|
+
# the clear). Store the configuration and build a new client instead.
|
|
450
|
+
# @raise [TypeError]
|
|
451
|
+
def marshal_dump
|
|
452
|
+
raise TypeError, "Parse::Client cannot be marshaled"
|
|
453
|
+
end
|
|
454
|
+
|
|
422
455
|
# A NEW non-master {Parse::Client} that mirrors THIS client's connection
|
|
423
456
|
# settings (`server_url` / `application_id` / `api_key`) but carries no
|
|
424
457
|
# master key and binds `session_token`, so it acts on the server as that
|
|
@@ -645,6 +678,12 @@ module Parse
|
|
|
645
678
|
# middleware. The default value is 3 seconds. If :expires is set to 0,
|
|
646
679
|
# caching will be disabled. You can always clear the current state of the
|
|
647
680
|
# cache using the clear_cache! method on your Parse::Client instance.
|
|
681
|
+
# @option opts [Boolean] :cache_session_requests Whether this client's
|
|
682
|
+
# response cache stores and serves reads made with a session token.
|
|
683
|
+
# Overrides {Parse::Middleware::Caching.cache_session_requests} in
|
|
684
|
+
# either direction; omit it to use that class default (off). Only
|
|
685
|
+
# `true` enables it. With it on, a revoked session keeps reading its
|
|
686
|
+
# cached rows until the entry expires.
|
|
648
687
|
# @option opts [String] :cache_namespace Optional prefix applied to every
|
|
649
688
|
# cache key. Useful when two Parse apps share one Redis instance and
|
|
650
689
|
# would otherwise collide on identical paths (e.g.
|
|
@@ -704,7 +743,9 @@ module Parse
|
|
|
704
743
|
@allow_faraday_proxy = opts.fetch(:allow_faraday_proxy, false)
|
|
705
744
|
|
|
706
745
|
# Security check for HTTP usage (except localhost/127.0.0.1 for development)
|
|
707
|
-
|
|
746
|
+
# The scheme and host come from URI parsing, so `HTTP://` and leading
|
|
747
|
+
# whitespace are treated as plain http, not as a secure URL.
|
|
748
|
+
if self.class.url_scheme(@server_url) == "http" && !self.class.loopback_host?(self.class.url_host(@server_url))
|
|
708
749
|
if @require_https
|
|
709
750
|
raise ArgumentError, "[Parse::Client] HTTPS required but server URL uses HTTP: #{@server_url}. " \
|
|
710
751
|
"Set require_https: false or use an HTTPS URL."
|
|
@@ -765,7 +806,7 @@ module Parse
|
|
|
765
806
|
# scheme; without this guard a caller passing
|
|
766
807
|
# faraday: { ssl: { verify: false }, proxy: "http://attacker" }
|
|
767
808
|
# would neuter TLS verification on an HTTPS connection.
|
|
768
|
-
validate_faraday_opts!(opts[:faraday])
|
|
809
|
+
opts[:faraday] = validate_faraday_opts!(opts[:faraday])
|
|
769
810
|
opts[:faraday].merge!(:url => @server_url)
|
|
770
811
|
@conn = Faraday.new(opts[:faraday]) do |conn|
|
|
771
812
|
# Apply timeouts before any user-supplied middleware sees a request.
|
|
@@ -937,6 +978,10 @@ module Parse
|
|
|
937
978
|
# old workers still read the legacy shape, so invalidation has to
|
|
938
979
|
# hit both until every old worker is drained.
|
|
939
980
|
delete_legacy_variants: opts.fetch(:cache_delete_legacy_variants, true),
|
|
981
|
+
# Per-client override of
|
|
982
|
+
# Parse::Middleware::Caching.cache_session_requests; omitted
|
|
983
|
+
# means the class default applies.
|
|
984
|
+
**(opts.key?(:cache_session_requests) ? { cache_session_requests: opts[:cache_session_requests] } : {}),
|
|
940
985
|
}
|
|
941
986
|
|
|
942
987
|
# Inform about opt-in cache behavior
|
|
@@ -986,19 +1031,27 @@ module Parse
|
|
|
986
1031
|
# controlled MITM unless explicitly allowlisted
|
|
987
1032
|
#
|
|
988
1033
|
# @api private
|
|
1034
|
+
# @return [Hash] the options as a Hash, so a `Faraday::ConnectionOptions`
|
|
1035
|
+
# (or anything else responding to `to_hash`) is checked and used in the
|
|
1036
|
+
# same form; any other value raises.
|
|
989
1037
|
def validate_faraday_opts!(faraday_opts)
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
if ssl.is_a?(Hash)
|
|
994
|
-
verify = ssl.key?(:verify) ? ssl[:verify] : ssl["verify"]
|
|
995
|
-
if verify == false && @server_url.to_s.start_with?("https://")
|
|
1038
|
+
faraday_opts = {} if faraday_opts.nil?
|
|
1039
|
+
unless faraday_opts.is_a?(Hash)
|
|
1040
|
+
unless faraday_opts.respond_to?(:to_hash)
|
|
996
1041
|
raise ArgumentError,
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
"Fix the server certificate or downgrade the URL to http:// " \
|
|
1000
|
-
"(with require_https: false) for explicit local testing."
|
|
1042
|
+
"[Parse::Client] opts[:faraday] must be a Hash or Faraday::ConnectionOptions " \
|
|
1043
|
+
"(got #{faraday_opts.class})."
|
|
1001
1044
|
end
|
|
1045
|
+
faraday_opts = faraday_opts.to_hash
|
|
1046
|
+
end
|
|
1047
|
+
|
|
1048
|
+
ssl = faraday_opts[:ssl] || faraday_opts["ssl"]
|
|
1049
|
+
if self.class.url_scheme(@server_url) == "https" && (setting = tls_verification_disabled(ssl))
|
|
1050
|
+
raise ArgumentError,
|
|
1051
|
+
"[Parse::Client] Refusing to disable TLS certificate verification " \
|
|
1052
|
+
"(opts[:faraday][:ssl] #{setting}) on an HTTPS server URL. " \
|
|
1053
|
+
"Fix the server certificate or downgrade the URL to http:// " \
|
|
1054
|
+
"(with require_https: false) for explicit local testing."
|
|
1002
1055
|
end
|
|
1003
1056
|
|
|
1004
1057
|
proxy = faraday_opts[:proxy] || faraday_opts["proxy"]
|
|
@@ -1018,15 +1071,82 @@ module Parse
|
|
|
1018
1071
|
# `proxy: nil` is the Faraday-documented way to disable
|
|
1019
1072
|
# env-proxy autodiscovery.
|
|
1020
1073
|
faraday_opts[:proxy] = nil unless @allow_faraday_proxy
|
|
1074
|
+
faraday_opts
|
|
1021
1075
|
end
|
|
1022
1076
|
|
|
1023
1077
|
private :validate_faraday_opts!
|
|
1024
1078
|
|
|
1025
|
-
#
|
|
1026
|
-
#
|
|
1027
|
-
#
|
|
1028
|
-
#
|
|
1029
|
-
|
|
1079
|
+
# Which TLS setting in a Faraday `ssl:` option (a Hash or a
|
|
1080
|
+
# `Faraday::SSLOptions`) turns certificate or hostname verification off,
|
|
1081
|
+
# or nil when none does: `verify: false`, `verify_mode: VERIFY_NONE`, or
|
|
1082
|
+
# `verify_hostname: false`.
|
|
1083
|
+
# @api private
|
|
1084
|
+
def tls_verification_disabled(ssl)
|
|
1085
|
+
read = lambda do |key|
|
|
1086
|
+
if ssl.is_a?(Hash)
|
|
1087
|
+
ssl.key?(key) ? ssl[key] : ssl[key.to_s]
|
|
1088
|
+
elsif ssl.respond_to?(key)
|
|
1089
|
+
ssl.public_send(key)
|
|
1090
|
+
end
|
|
1091
|
+
end
|
|
1092
|
+
return nil if ssl.nil?
|
|
1093
|
+
return "verify: false" if read.call(:verify) == false
|
|
1094
|
+
if defined?(OpenSSL::SSL::VERIFY_NONE) && read.call(:verify_mode) == OpenSSL::SSL::VERIFY_NONE
|
|
1095
|
+
return "verify_mode: VERIFY_NONE"
|
|
1096
|
+
end
|
|
1097
|
+
return "verify_hostname: false" if read.call(:verify_hostname) == false
|
|
1098
|
+
nil
|
|
1099
|
+
end
|
|
1100
|
+
private :tls_verification_disabled
|
|
1101
|
+
|
|
1102
|
+
# Whether a URL host is this machine: `localhost`, any `127.0.0.0/8`
|
|
1103
|
+
# address, `::1`, or `0.0.0.0` (a connect to the unspecified address
|
|
1104
|
+
# reaches the local host on Linux and macOS). Used by the http:// server
|
|
1105
|
+
# warning and the LiveQuery cleartext guards so they agree.
|
|
1106
|
+
# @param host [String, nil]
|
|
1107
|
+
# @return [Boolean]
|
|
1108
|
+
# @api private
|
|
1109
|
+
#
|
|
1110
|
+
# Addresses are parsed with IPAddr, so a malformed one such as
|
|
1111
|
+
# `127.999.1.1` (which a resolver would look up by name) is not loopback.
|
|
1112
|
+
# `localhost.` with a trailing dot is not either: some resolvers skip
|
|
1113
|
+
# `/etc/hosts` for the fully qualified form. `0.0.0.0` stays local for
|
|
1114
|
+
# client URLs only (it is a bind address everywhere else).
|
|
1115
|
+
def self.loopback_host?(host)
|
|
1116
|
+
h = host.to_s.strip.downcase.delete_prefix("[").delete_suffix("]")
|
|
1117
|
+
return false if h.empty?
|
|
1118
|
+
return true if h == "localhost" || h == "0.0.0.0"
|
|
1119
|
+
return false unless h.match?(/\A[0-9a-f:.]+\z/)
|
|
1120
|
+
require "ipaddr"
|
|
1121
|
+
IPAddr.new(h).loopback?
|
|
1122
|
+
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
1123
|
+
false
|
|
1124
|
+
end
|
|
1125
|
+
|
|
1126
|
+
# The lowercased scheme of a URL String, or nil when it does not parse.
|
|
1127
|
+
# Leading and trailing whitespace is ignored. Scheme checks go through
|
|
1128
|
+
# this instead of a case-sensitive prefix match, which let `HTTP://` and
|
|
1129
|
+
# `WS://` past the plaintext guards.
|
|
1130
|
+
# @param url [String, nil]
|
|
1131
|
+
# @return [String, nil]
|
|
1132
|
+
# @api private
|
|
1133
|
+
def self.url_scheme(url)
|
|
1134
|
+
return nil if url.nil?
|
|
1135
|
+
URI.parse(url.to_s.strip).scheme&.downcase
|
|
1136
|
+
rescue URI::InvalidURIError
|
|
1137
|
+
nil
|
|
1138
|
+
end
|
|
1139
|
+
|
|
1140
|
+
# The lowercased host of a URL String, or nil when it does not parse.
|
|
1141
|
+
# @param url [String, nil]
|
|
1142
|
+
# @return [String, nil]
|
|
1143
|
+
# @api private
|
|
1144
|
+
def self.url_host(url)
|
|
1145
|
+
return nil if url.nil?
|
|
1146
|
+
URI.parse(url.to_s.strip).host&.downcase
|
|
1147
|
+
rescue URI::InvalidURIError
|
|
1148
|
+
nil
|
|
1149
|
+
end
|
|
1030
1150
|
|
|
1031
1151
|
# Configure LiveQuery with the given options
|
|
1032
1152
|
# @param opts [Hash] configuration options
|
|
@@ -1049,7 +1169,7 @@ module Parse
|
|
|
1049
1169
|
# `live_query: { url: "ws://prod-host" }` or
|
|
1050
1170
|
# `live_query_url: "ws://prod-host"` bypassed it — the master key
|
|
1051
1171
|
# and any session token would ride the connect frame in cleartext.
|
|
1052
|
-
validate_live_query_url!(resolved_url, allow_insecure: live_query_opts[:allow_insecure])
|
|
1172
|
+
resolved_url = validate_live_query_url!(resolved_url, allow_insecure: live_query_opts[:allow_insecure])
|
|
1053
1173
|
|
|
1054
1174
|
# Warn (don't raise) on `live_query: { ... }` keys that are not
|
|
1055
1175
|
# `Parse::LiveQuery::Configuration` setters. The block form would
|
|
@@ -1079,20 +1199,14 @@ module Parse
|
|
|
1079
1199
|
end
|
|
1080
1200
|
end
|
|
1081
1201
|
|
|
1202
|
+
# Validate an explicit LiveQuery URL at configure time with the same
|
|
1203
|
+
# rules {Parse::LiveQuery::Client} applies, and return the URL to use
|
|
1204
|
+
# (`http://` and `https://` are mapped to `ws://` and `wss://`).
|
|
1205
|
+
# @return [String, nil]
|
|
1082
1206
|
# @api private
|
|
1083
1207
|
def validate_live_query_url!(url, allow_insecure:)
|
|
1084
|
-
return unless url.is_a?(String)
|
|
1085
|
-
|
|
1086
|
-
host = URI.parse(url).host.to_s rescue ""
|
|
1087
|
-
return if LIVE_QUERY_LOOPBACK_HOSTS.include?(host)
|
|
1088
|
-
return if allow_insecure
|
|
1089
|
-
|
|
1090
|
-
raise ArgumentError,
|
|
1091
|
-
"[Parse::Client] Refusing explicit insecure LiveQuery URL #{url.inspect}. " \
|
|
1092
|
-
"The connect frame carries the master key and any session token in " \
|
|
1093
|
-
"plaintext on this socket. Use wss:// for routable hosts, or pass " \
|
|
1094
|
-
"`live_query: { allow_insecure: true }` to opt into cleartext for " \
|
|
1095
|
-
"local development on a non-loopback address."
|
|
1208
|
+
return url unless url.is_a?(String)
|
|
1209
|
+
Parse::LiveQuery::Client.normalize_url(url, allow_insecure: allow_insecure)
|
|
1096
1210
|
end
|
|
1097
1211
|
|
|
1098
1212
|
# @api private
|
|
@@ -1383,7 +1497,9 @@ module Parse
|
|
|
1383
1497
|
# `with_session(token_b)` resolved user B, and the identity cache
|
|
1384
1498
|
# then mapped token A (or a garbage token) to user B for its TTL.
|
|
1385
1499
|
if raw_token.nil?
|
|
1386
|
-
|
|
1500
|
+
# Same lookup a batch uses for each request's own credentials
|
|
1501
|
+
# (Parse::Request#explicit_authority).
|
|
1502
|
+
header_token = Parse::Request.header_value(headers, Parse::Protocol::SESSION_TOKEN)
|
|
1387
1503
|
raw_token = header_token if header_token.is_a?(String)
|
|
1388
1504
|
end
|
|
1389
1505
|
# SEC-02:an EXPLICITLY-supplied session_token that is a blank /
|
|
@@ -158,8 +158,11 @@ module Parse
|
|
|
158
158
|
use_master_key: NOT_PROVIDED, auto_connect: nil, auto_reconnect: nil)
|
|
159
159
|
cfg = config
|
|
160
160
|
|
|
161
|
-
# Use provided values or fall back to configuration/environment
|
|
162
|
-
|
|
161
|
+
# Use provided values or fall back to configuration/environment. An
|
|
162
|
+
# explicit or configured URL is validated here; a derived one is
|
|
163
|
+
# validated by derive_websocket_url.
|
|
164
|
+
explicit_url = url || cfg.url
|
|
165
|
+
@url = explicit_url ? validate_websocket_url!(explicit_url) : derive_websocket_url
|
|
163
166
|
@application_id = application_id || cfg.application_id ||
|
|
164
167
|
parse_client_value(:application_id)
|
|
165
168
|
@client_key = client_key || cfg.client_key ||
|
|
@@ -509,12 +512,70 @@ module Parse
|
|
|
509
512
|
nil
|
|
510
513
|
end
|
|
511
514
|
|
|
512
|
-
#
|
|
513
|
-
#
|
|
514
|
-
#
|
|
515
|
-
#
|
|
516
|
-
#
|
|
517
|
-
|
|
515
|
+
# Normalize and validate an explicit LiveQuery URL, returning the URL
|
|
516
|
+
# to connect to.
|
|
517
|
+
#
|
|
518
|
+
# - The scheme is compared case-insensitively and the URL is stripped.
|
|
519
|
+
# - `https://` is mapped to `wss://` and `http://` to `ws://`, with a
|
|
520
|
+
# one-time deprecation warning. The connector used to open them as a
|
|
521
|
+
# plaintext socket, so the `https` mapping is strictly safer.
|
|
522
|
+
# - Plaintext `ws://` (including a mapped `http://`) is refused on a
|
|
523
|
+
# host that is not this machine ({Parse::Client.loopback_host?})
|
|
524
|
+
# unless `allow_insecure` is set, in which case it warns.
|
|
525
|
+
# - Any other scheme raises `ArgumentError`.
|
|
526
|
+
#
|
|
527
|
+
# @param url [String]
|
|
528
|
+
# @param allow_insecure [Boolean]
|
|
529
|
+
# @return [String]
|
|
530
|
+
# @raise [ArgumentError]
|
|
531
|
+
def self.normalize_url(url, allow_insecure: false)
|
|
532
|
+
scheme = Parse::Client.url_scheme(url)
|
|
533
|
+
mapped = { "http" => "ws", "https" => "wss" }[scheme]
|
|
534
|
+
if mapped
|
|
535
|
+
warn_http_scheme_once(scheme, mapped)
|
|
536
|
+
scheme = mapped
|
|
537
|
+
end
|
|
538
|
+
unless %w[ws wss].include?(scheme)
|
|
539
|
+
raise ArgumentError,
|
|
540
|
+
"[Parse::LiveQuery] LiveQuery URL #{url.to_s.inspect} must use wss:// " \
|
|
541
|
+
"(or ws:// on a loopback host). Got scheme #{scheme.inspect}."
|
|
542
|
+
end
|
|
543
|
+
normalized = url.to_s.strip.sub(/\A[a-z][a-z0-9+.\-]*:/i, "#{scheme}:")
|
|
544
|
+
host = Parse::Client.url_host(normalized).to_s
|
|
545
|
+
return normalized if scheme == "wss" || Parse::Client.loopback_host?(host)
|
|
546
|
+
|
|
547
|
+
if allow_insecure
|
|
548
|
+
warn "[Parse::LiveQuery] Using insecure ws:// URL for #{host} " \
|
|
549
|
+
"(allow_insecure is enabled). Master key and session tokens " \
|
|
550
|
+
"will traverse a cleartext socket."
|
|
551
|
+
normalized
|
|
552
|
+
else
|
|
553
|
+
raise ArgumentError,
|
|
554
|
+
"[Parse::LiveQuery] Refusing insecure ws:// LiveQuery URL #{url.to_s.inspect}. " \
|
|
555
|
+
"The connect frame carries the master key and any session token in " \
|
|
556
|
+
"plaintext on this socket. Use wss:// for routable hosts, or set " \
|
|
557
|
+
"`Parse::LiveQuery.configure { |c| c.allow_insecure = true }` (or " \
|
|
558
|
+
"`live_query: { allow_insecure: true }`) for local development."
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# @!visibility private
|
|
563
|
+
def self.warn_http_scheme_once(scheme, mapped)
|
|
564
|
+
@warned_http_schemes ||= {}
|
|
565
|
+
return if @warned_http_schemes[scheme]
|
|
566
|
+
@warned_http_schemes[scheme] = true
|
|
567
|
+
warn "[Parse::LiveQuery] DEPRECATION: an #{scheme}:// LiveQuery URL is " \
|
|
568
|
+
"treated as #{mapped}://. Configure the #{mapped}:// URL directly; " \
|
|
569
|
+
"a later release will refuse #{scheme}://."
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# Validate an explicit or configured LiveQuery URL the same way a
|
|
573
|
+
# derived one is, and return the URL to connect to. See
|
|
574
|
+
# {Parse::LiveQuery::Client.normalize_url}.
|
|
575
|
+
# @return [String]
|
|
576
|
+
def validate_websocket_url!(url)
|
|
577
|
+
self.class.normalize_url(url, allow_insecure: config.allow_insecure)
|
|
578
|
+
end
|
|
518
579
|
|
|
519
580
|
# Derive WebSocket URL from Parse server URL. Refuses to
|
|
520
581
|
# synthesize a `ws://` URL from an `http://` server URL on any
|
|
@@ -530,7 +591,7 @@ module Parse
|
|
|
530
591
|
scheme = uri.scheme == "https" ? "wss" : "ws"
|
|
531
592
|
host = uri.host.to_s
|
|
532
593
|
|
|
533
|
-
if scheme == "ws" && !
|
|
594
|
+
if scheme == "ws" && !Parse::Client.loopback_host?(host)
|
|
534
595
|
if config.allow_insecure
|
|
535
596
|
warn "[Parse::LiveQuery] Deriving insecure ws:// URL for #{host} " \
|
|
536
597
|
"(allow_insecure is enabled). Master key and session tokens " \
|
|
@@ -251,12 +251,15 @@ module Parse
|
|
|
251
251
|
# already revoked elsewhere or one the caller cannot see; dropping
|
|
252
252
|
# cached entries is idempotent in both cases. A raised delete still
|
|
253
253
|
# drops the token it named but leaves the owner alone. A single
|
|
254
|
-
# delete cannot tell "already gone" from
|
|
255
|
-
#
|
|
254
|
+
# delete that returns false cannot tell "already gone" from
|
|
255
|
+
# "denied", so it never resets and forgets nothing for a row the
|
|
256
|
+
# caller could not read; one that returns true removed the row, so
|
|
257
|
+
# the recorded owner (or the rate-limited reset) applies.
|
|
256
258
|
if result.nil?
|
|
257
259
|
_forget_identity!(token.is_a?(String) ? token : nil, nil)
|
|
258
260
|
else
|
|
259
|
-
|
|
261
|
+
deleted = result == true
|
|
262
|
+
_forget_identity!(token, owner_id, reset_fallback: deleted, deleted: deleted)
|
|
260
263
|
end
|
|
261
264
|
end
|
|
262
265
|
end
|
|
@@ -312,16 +315,23 @@ module Parse
|
|
|
312
315
|
|
|
313
316
|
# Look up the token and owner of every session about to be deleted that
|
|
314
317
|
# does not carry them, so the delete can drop their identity entries.
|
|
315
|
-
# One `_Session` query per client.
|
|
316
|
-
# as
|
|
317
|
-
#
|
|
318
|
-
#
|
|
319
|
-
#
|
|
318
|
+
# One `_Session` query per client. When the delete runs as a user
|
|
319
|
+
# (`session_token`), the lookup runs as that user too, so it only
|
|
320
|
+
# reads sessions that user can see. Otherwise a client with a master
|
|
321
|
+
# key reads it as SDK metadata (it works inside
|
|
322
|
+
# `Parse.without_master_key`), and one without skips the lookup. The
|
|
323
|
+
# query never uses the response cache: its rows carry live session
|
|
324
|
+
# tokens.
|
|
320
325
|
#
|
|
321
326
|
# A row the lookup did not return is marked absent: there is nothing
|
|
322
|
-
# to forget for it.
|
|
323
|
-
# `:
|
|
324
|
-
#
|
|
327
|
+
# to forget for it. A row a session-scoped lookup could not see is
|
|
328
|
+
# marked `:invisible`: a denied or not-found delete of it forgets
|
|
329
|
+
# nothing and never resets, so a user deleting session ids they cannot
|
|
330
|
+
# read cannot evict other users' cached identities. A delete of it
|
|
331
|
+
# that succeeds removed a session the caller was allowed to delete, so
|
|
332
|
+
# its recorded owner (or the rate-limited reset) is used. Only
|
|
333
|
+
# a lookup that raised marks its sessions `:unknown`, which makes
|
|
334
|
+
# their delete reset the client's identity cache (rate limited).
|
|
325
335
|
# @param sessions [Array<Parse::Object>]
|
|
326
336
|
# @param session_token [String, nil] the delete's own session.
|
|
327
337
|
# @!visibility private
|
|
@@ -337,15 +347,16 @@ module Parse
|
|
|
337
347
|
next
|
|
338
348
|
end
|
|
339
349
|
ids = group.map(&:id).uniq
|
|
350
|
+
scoped = !token.nil?
|
|
340
351
|
found = begin
|
|
341
352
|
query = Parse::Session.query(:objectId.in => ids, limit: ids.size)
|
|
342
353
|
query.keys(:session_token, :user)
|
|
343
354
|
query.client = cl
|
|
344
355
|
query.cache = false
|
|
345
|
-
if
|
|
346
|
-
query.instance_variable_set(:@_metadata_master, true)
|
|
347
|
-
else
|
|
356
|
+
if scoped
|
|
348
357
|
query.session_token = token
|
|
358
|
+
else
|
|
359
|
+
query.instance_variable_set(:@_metadata_master, true)
|
|
349
360
|
end
|
|
350
361
|
query.results.to_h do |row|
|
|
351
362
|
owner = row.instance_variable_get(:@user)
|
|
@@ -362,6 +373,8 @@ module Parse
|
|
|
362
373
|
:unknown
|
|
363
374
|
elsif (row = found[o.id]) && (row[0].is_a?(String) || row[1])
|
|
364
375
|
row
|
|
376
|
+
elsif scoped
|
|
377
|
+
:invisible
|
|
365
378
|
else
|
|
366
379
|
:absent
|
|
367
380
|
end
|
|
@@ -379,10 +392,14 @@ module Parse
|
|
|
379
392
|
identity = _identity_for_destroy
|
|
380
393
|
_clear_identity_for_destroy!
|
|
381
394
|
return unless Parse::Session.send(:_destroy_applied?, response)
|
|
395
|
+
deleted = response.nil? || (response.respond_to?(:success?) && response.success?)
|
|
396
|
+
# A row the caller could not read counts only when the delete itself
|
|
397
|
+
# succeeded: "object not found" there may mean "not yours".
|
|
398
|
+
return if identity[0] == :invisible && !deleted
|
|
382
399
|
# The batch response tells success and "object not found" apart from
|
|
383
400
|
# a denial, so an absent row whose owner was never recorded may fall
|
|
384
401
|
# back to the rate-limited reset here.
|
|
385
|
-
_forget_identity!(*identity, reset_fallback: true)
|
|
402
|
+
_forget_identity!(*identity, reset_fallback: true, deleted: deleted)
|
|
386
403
|
end
|
|
387
404
|
private :_after_batch_destroy
|
|
388
405
|
|
|
@@ -451,6 +468,10 @@ module Parse
|
|
|
451
468
|
token = @session_token.is_a?(String) && !@session_token.empty? ? @session_token : nil
|
|
452
469
|
owner_id = _identity_owner_id
|
|
453
470
|
return [:unknown, owner_id] if looked_up == :unknown
|
|
471
|
+
# A session-scoped lookup could not see the row: forget what this
|
|
472
|
+
# instance carries; with nothing at all, mark it invisible so only a
|
|
473
|
+
# delete that succeeds may use the recorded owner or the reset.
|
|
474
|
+
return (token || owner_id ? [token, owner_id] : [:invisible, nil]) if looked_up == :invisible
|
|
454
475
|
if looked_up == :absent
|
|
455
476
|
# No row to read: the session is gone or not visible. Forget what
|
|
456
477
|
# this instance carries; with nothing at all, mark it absent so the
|
|
@@ -476,8 +497,15 @@ module Parse
|
|
|
476
497
|
# Drop the token and the owner's entries from this session's client's
|
|
477
498
|
# identity plane.
|
|
478
499
|
# @!visibility private
|
|
479
|
-
def _forget_identity!(token, owner_id, reset_fallback: false)
|
|
500
|
+
def _forget_identity!(token, owner_id, reset_fallback: false, deleted: false)
|
|
480
501
|
cl = client
|
|
502
|
+
if token == :invisible
|
|
503
|
+
# The caller could not read the row. Only a delete that succeeded
|
|
504
|
+
# touches other identities: the recorded owner, else the
|
|
505
|
+
# rate-limited reset. A denied or not-found delete forgets nothing.
|
|
506
|
+
return unless deleted
|
|
507
|
+
token = :absent
|
|
508
|
+
end
|
|
481
509
|
if token == :absent
|
|
482
510
|
# The row was not readable before the delete. Use the owner recorded
|
|
483
511
|
# when this session was last loaded; without one, and only when the
|
|
@@ -432,11 +432,20 @@ module Parse
|
|
|
432
432
|
# default; enable it only for writes that are safe to repeat (it
|
|
433
433
|
# works around Parse Server running a transaction's requests
|
|
434
434
|
# concurrently, which MongoDB intermittently rejects).
|
|
435
|
+
# @param session [String, #session_token, nil] run the transaction as
|
|
436
|
+
# this user (ACL and CLP enforced). Without it, the transaction runs
|
|
437
|
+
# with the credentials of its objects' class client, as a single
|
|
438
|
+
# save does.
|
|
435
439
|
# @yield [Parse::BatchOperation] the batch operation to add requests to
|
|
436
440
|
# @return [Array<Parse::Response>] the responses from the transaction
|
|
437
441
|
# @raise [Parse::Error] if the transaction fails
|
|
438
|
-
|
|
442
|
+
# @raise [Parse::BatchOperation::MixedAuthorityError] if the objects
|
|
443
|
+
# are bound to clients with different credentials. Parse Server runs
|
|
444
|
+
# a transaction under one credential, so it is refused before
|
|
445
|
+
# anything is sent.
|
|
446
|
+
def transaction(retries: 5, retry_server_errors: false, session: nil, &block)
|
|
439
447
|
raise ArgumentError, "Block required for transaction" unless block_given?
|
|
448
|
+
session_token = Parse::BatchOperation.session_token_for!(session)
|
|
440
449
|
|
|
441
450
|
previous_context = Fiber[TRANSACTION_CONTEXT_KEY]
|
|
442
451
|
transaction_context = { snapshots: {}, created_objects: {} }
|
|
@@ -490,6 +499,10 @@ module Parse
|
|
|
490
499
|
result.each { |obj| batch_wrapper.add(obj) if obj.respond_to?(:change_requests) }
|
|
491
500
|
end
|
|
492
501
|
|
|
502
|
+
if session_token
|
|
503
|
+
batch.requests.each { |r| r.opts[:session_token] = session_token if r.opts.is_a?(Hash) }
|
|
504
|
+
end
|
|
505
|
+
|
|
493
506
|
# Submit with retry logic for transaction conflicts.
|
|
494
507
|
# Parse Server reports a write conflict inside a transaction as
|
|
495
508
|
# error code 251, after rolling it back. Retry on that structured
|
|
@@ -1266,6 +1279,9 @@ module Parse
|
|
|
1266
1279
|
uri = self.uri_path
|
|
1267
1280
|
r = Request.new(:delete, uri)
|
|
1268
1281
|
r.tag = object_id
|
|
1282
|
+
# A batch sends this through the class's client (and its bound
|
|
1283
|
+
# session), as a single destroy does.
|
|
1284
|
+
r.client = client
|
|
1269
1285
|
r
|
|
1270
1286
|
end
|
|
1271
1287
|
|
|
@@ -1276,6 +1292,8 @@ module Parse
|
|
|
1276
1292
|
|
|
1277
1293
|
# Creates an array of all possible operations that need to be performed
|
|
1278
1294
|
# on this object. This includes all property and relational operation changes.
|
|
1295
|
+
# Each request carries this class's client, so a batch sends it with the
|
|
1296
|
+
# same credentials as a single {#save}.
|
|
1279
1297
|
#
|
|
1280
1298
|
# This is the path batch saves ({Array#save}) and
|
|
1281
1299
|
# {Parse::Object.transaction} use, so it applies the same save-time
|
|
@@ -1309,6 +1327,7 @@ module Parse
|
|
|
1309
1327
|
body[Parse::Model::OBJECT_ID] = @id if @id.present?
|
|
1310
1328
|
r = Request.new(:post, uri, body: body)
|
|
1311
1329
|
r.tag = object_id
|
|
1330
|
+
r.client = client
|
|
1312
1331
|
requests << r
|
|
1313
1332
|
end
|
|
1314
1333
|
return requests
|
|
@@ -1318,6 +1337,7 @@ module Parse
|
|
|
1318
1337
|
if attribute_changes? || force
|
|
1319
1338
|
r = Request.new(:put, uri, body: attribute_updates)
|
|
1320
1339
|
r.tag = object_id
|
|
1340
|
+
r.client = client
|
|
1321
1341
|
requests << r
|
|
1322
1342
|
end
|
|
1323
1343
|
|
|
@@ -1328,6 +1348,7 @@ module Parse
|
|
|
1328
1348
|
next if ops.empty?
|
|
1329
1349
|
r = Request.new(:put, uri, body: ops)
|
|
1330
1350
|
r.tag = object_id
|
|
1351
|
+
r.client = client
|
|
1331
1352
|
requests << r
|
|
1332
1353
|
end
|
|
1333
1354
|
end
|