parse-stack-next 5.0.1 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/ISSUE_TEMPLATE/bug_report.yml +105 -0
- data/.github/ISSUE_TEMPLATE/feature_request.yml +67 -0
- data/.github/dependabot.yml +13 -0
- data/.github/workflows/codeql.yml +1 -1
- data/.github/workflows/docs.yml +3 -3
- data/.github/workflows/release.yml +14 -3
- data/.github/workflows/ruby.yml +1 -1
- data/.gitignore +1 -0
- data/.yardopts +19 -0
- data/CHANGELOG.md +792 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +8 -5
- data/README.md +15 -0
- data/Rakefile +5 -1
- data/docs/acl_clp_guide.md +553 -0
- data/docs/atlas_vector_search_guide.md +123 -22
- data/docs/client_sdk_guide.md +201 -5
- data/docs/usage_guide.md +21 -0
- data/docs/yard-template/default/fulldoc/html/css/common.css +1222 -0
- data/docs/yard-template/default/fulldoc/html/css/full_list.css +387 -0
- data/lib/parse/agent/tools.rb +153 -1
- data/lib/parse/cache/redis.rb +53 -0
- data/lib/parse/client/caching.rb +18 -1
- data/lib/parse/client.rb +79 -12
- data/lib/parse/embeddings/cohere.rb +143 -6
- data/lib/parse/embeddings/provider.rb +20 -2
- data/lib/parse/embeddings/voyage.rb +102 -0
- data/lib/parse/embeddings.rb +332 -1
- data/lib/parse/live_query/client.rb +167 -4
- data/lib/parse/live_query/configuration.rb +12 -0
- data/lib/parse/live_query/subscription.rb +55 -2
- data/lib/parse/live_query.rb +123 -1
- data/lib/parse/lock.rb +342 -0
- data/lib/parse/lock_backend.rb +308 -0
- data/lib/parse/model/classes/audience.rb +5 -0
- data/lib/parse/model/classes/installation.rb +122 -0
- data/lib/parse/model/classes/job_schedule.rb +3 -1
- data/lib/parse/model/classes/job_status.rb +4 -1
- data/lib/parse/model/classes/push_status.rb +4 -1
- data/lib/parse/model/classes/session.rb +7 -0
- data/lib/parse/model/classes/user.rb +204 -0
- data/lib/parse/model/core/create_lock.rb +28 -146
- data/lib/parse/model/core/embed_managed.rb +162 -13
- data/lib/parse/model/core/parse_reference.rb +17 -1
- data/lib/parse/model/core/querying.rb +26 -2
- data/lib/parse/model/file.rb +523 -18
- data/lib/parse/query.rb +31 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +98 -1
- data/parse-stack-next.gemspec +2 -2
- metadata +17 -7
data/lib/parse/model/file.rb
CHANGED
|
@@ -35,10 +35,37 @@ module Parse
|
|
|
35
35
|
# configuration.
|
|
36
36
|
class UntrustedHostError < Parse::Error; end
|
|
37
37
|
|
|
38
|
+
# Raised when caller code attempts to assign a presigned / signed URL
|
|
39
|
+
# to {#url}. The `@url` field is reserved for stable canonical URLs;
|
|
40
|
+
# short-TTL signed URLs must come from {#download_url} (added in a
|
|
41
|
+
# later phase) and never be cached on the instance. Fail-loud so the
|
|
42
|
+
# leak vector is caught at the point of error rather than discovered
|
|
43
|
+
# in logs or a CDN access trail.
|
|
44
|
+
class SignedUrlError < Parse::Error; end
|
|
45
|
+
|
|
46
|
+
|
|
38
47
|
# Regular expression that matches the old legacy Parse hosted file name
|
|
39
48
|
LEGACY_FILE_RX = /^tfss-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}-/
|
|
40
|
-
# The default attributes in a Parse File hash.
|
|
49
|
+
# The default attributes in a Parse File hash. Matches the Parse
|
|
50
|
+
# Server file-pointer wire format `{__type, name, url}`. The `key`
|
|
51
|
+
# field on `Parse::File` is in-memory only (not persisted to Parse
|
|
52
|
+
# Server because the server normalizes embedded file pointers and
|
|
53
|
+
# strips unknown fields); see {#key}.
|
|
41
54
|
ATTRIBUTES = { __type: :string, name: :string, url: :string }.freeze
|
|
55
|
+
# Query-string parameter names that mark a URL as a presigned /
|
|
56
|
+
# signed URL — i.e. one whose POSSESSION grants temporary
|
|
57
|
+
# capability. Used by {.url_signature_param?} (the detection
|
|
58
|
+
# predicate behind the URL normalization point) and exposed
|
|
59
|
+
# publicly so downstream apps wiring custom strict-mode checks
|
|
60
|
+
# can iterate the same list the SDK does. Detection is
|
|
61
|
+
# case-insensitive.
|
|
62
|
+
SIGNATURE_QUERY_PARAMS = %w[
|
|
63
|
+
X-Amz-Signature
|
|
64
|
+
X-Amz-Credential
|
|
65
|
+
X-Amz-Security-Token
|
|
66
|
+
AWSAccessKeyId
|
|
67
|
+
Key-Pair-Id
|
|
68
|
+
].freeze
|
|
42
69
|
|
|
43
70
|
# @!visibility private
|
|
44
71
|
# Default cap on remote-fetched file size (50 MiB). Override via
|
|
@@ -68,13 +95,38 @@ module Parse
|
|
|
68
95
|
DEFAULT_ALLOWED_REMOTE_PORTS = [80, 443, 8080, 8443].freeze
|
|
69
96
|
# @return [String] the name of the file including extension (if any)
|
|
70
97
|
attr_accessor :name
|
|
71
|
-
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
98
|
+
|
|
99
|
+
# Assign the file's URL.
|
|
100
|
+
#
|
|
101
|
+
# Routes through the single normalization point
|
|
102
|
+
# {#normalize_and_store_url}, which is also called by
|
|
103
|
+
# {#attributes=} on hydration. The rule (see s3_adapter_plan.md
|
|
104
|
+
# rev 3, D1/D2) applies uniformly to every writer:
|
|
105
|
+
#
|
|
106
|
+
# - Signed URLs (query string carries `X-Amz-Signature` /
|
|
107
|
+
# `X-Amz-Credential` / `X-Amz-Security-Token` / `AWSAccessKeyId` /
|
|
108
|
+
# `Key-Pair-Id`) are silently normalized: the query string is
|
|
109
|
+
# stripped, the bare canonical URL is stored in `@url`, the
|
|
110
|
+
# original signed URL is stashed in `@presigned_url` with its
|
|
111
|
+
# data-driven expiry parsed from the query params themselves
|
|
112
|
+
# (`X-Amz-Date + X-Amz-Expires` for SigV4, `Expires` for legacy /
|
|
113
|
+
# CloudFront).
|
|
114
|
+
# - Trusted-host check via {.sanitize_hydrated_url} still applies.
|
|
115
|
+
# - The `@key` cache is invalidated — URL reassignment may point at
|
|
116
|
+
# a different storage location.
|
|
117
|
+
#
|
|
118
|
+
# No raise on signed URLs. The Wave A {SignedUrlError} class is
|
|
119
|
+
# still defined for downstream apps that want stricter
|
|
120
|
+
# enforcement (e.g. operators who can guarantee Parse Server is
|
|
121
|
+
# NOT configured with `S3FilesAdapter` and want presigned URLs to
|
|
122
|
+
# raise instead of normalize), but the built-in SDK writers do
|
|
123
|
+
# not raise it. Asymmetric behavior between writers (raise here,
|
|
124
|
+
# accept there) was an explicit anti-goal in rev 3 — it grows
|
|
125
|
+
# footguns through `assign_attributes` / serializer round-trips.
|
|
126
|
+
#
|
|
75
127
|
# @param value [String, nil] the URL to assign.
|
|
76
128
|
def url=(value)
|
|
77
|
-
|
|
129
|
+
normalize_and_store_url(value)
|
|
78
130
|
end
|
|
79
131
|
|
|
80
132
|
# @return [Object] the contents of the file.
|
|
@@ -82,6 +134,7 @@ module Parse
|
|
|
82
134
|
|
|
83
135
|
# @return [String] the mime-type of the file whe
|
|
84
136
|
attr_accessor :mime_type
|
|
137
|
+
|
|
85
138
|
# @return [Model::TYPE_FILE]
|
|
86
139
|
def self.parse_class; TYPE_FILE; end
|
|
87
140
|
# @return [Model::TYPE_FILE]
|
|
@@ -153,6 +206,30 @@ module Parse
|
|
|
153
206
|
@trusted_url_hosts ||= ["files.parsetfss.com"]
|
|
154
207
|
end
|
|
155
208
|
|
|
209
|
+
# @return [Symbol] policy applied when an incoming URL carries a
|
|
210
|
+
# signed-URL signature query parameter (see
|
|
211
|
+
# {SIGNATURE_QUERY_PARAMS}). One of:
|
|
212
|
+
#
|
|
213
|
+
# - `:strip` (default) — strip the signature, store the bare
|
|
214
|
+
# canonical URL in `@url`, stash the original signed URL in
|
|
215
|
+
# `@presigned_url` with its parsed expiry. The pragmatic
|
|
216
|
+
# default — operators whose Parse Server is configured with
|
|
217
|
+
# `S3FilesAdapter(presignedUrl: true)` get a freshly-signed
|
|
218
|
+
# URL on every read, and the SDK has to accept that.
|
|
219
|
+
# - `:raise` — refuse the assignment with
|
|
220
|
+
# {SignedUrlError}. Strict mode for apps that can guarantee
|
|
221
|
+
# Parse Server is NOT configured with a presigned-URL file
|
|
222
|
+
# adapter and want any signed URL in `@url` to fail loudly
|
|
223
|
+
# instead of being silently normalized.
|
|
224
|
+
#
|
|
225
|
+
# The choice applies uniformly to both caller-side `url=` and
|
|
226
|
+
# hydration `attributes=` — asymmetric writer behavior was an
|
|
227
|
+
# explicit anti-goal of the design.
|
|
228
|
+
attr_writer :signed_url_policy
|
|
229
|
+
def signed_url_policy
|
|
230
|
+
@signed_url_policy ||= :strip
|
|
231
|
+
end
|
|
232
|
+
|
|
156
233
|
# @return [Symbol] policy when a `Parse::File` is hydrated with a URL
|
|
157
234
|
# whose host is not in {trusted_url_hosts}. One of:
|
|
158
235
|
#
|
|
@@ -177,6 +254,271 @@ module Parse
|
|
|
177
254
|
@allowed_remote_ports ||= DEFAULT_ALLOWED_REMOTE_PORTS.dup
|
|
178
255
|
end
|
|
179
256
|
|
|
257
|
+
# Regex that matches any HTTP(S) URL carrying an unambiguously
|
|
258
|
+
# AWS-style signed-URL parameter — SigV4 (`X-Amz-*`), legacy
|
|
259
|
+
# SigV2 (`AWSAccessKeyId`), or CloudFront (`Key-Pair-Id`).
|
|
260
|
+
# Designed to be plugged into log scrubbers / `lograge` /
|
|
261
|
+
# `semantic_logger` filters so accidental
|
|
262
|
+
# `Rails.logger.info(file_url)` calls do not leak short-TTL
|
|
263
|
+
# download credentials into log aggregators.
|
|
264
|
+
#
|
|
265
|
+
# Bare `Signature=` and `Policy=` are NOT matched on their own —
|
|
266
|
+
# they collide with too many unrelated app conventions (webhook
|
|
267
|
+
# signatures, privacy_policy fields). CloudFront URLs always
|
|
268
|
+
# carry `Key-Pair-Id` alongside `Signature` / `Policy`, so the
|
|
269
|
+
# `Key-Pair-Id` match catches the whole URL substring.
|
|
270
|
+
#
|
|
271
|
+
# This pattern matches **plain-text** URLs (`&` as the literal
|
|
272
|
+
# query separator). For JSON-encoded log payloads — where `&`
|
|
273
|
+
# is serialized as `\u0026`, common in Sentry / Honeybadger /
|
|
274
|
+
# Rollbar event bodies — use {.log_filter_strict} which accepts
|
|
275
|
+
# both forms.
|
|
276
|
+
#
|
|
277
|
+
# **Out of scope:** CloudFront signed *cookies*
|
|
278
|
+
# (`CloudFront-Policy`, `CloudFront-Signature`,
|
|
279
|
+
# `CloudFront-Key-Pair-Id` set as HTTP cookies rather than
|
|
280
|
+
# query parameters) are a separate auth mechanism and the SDK
|
|
281
|
+
# does not provide leak detection for them. Apps using
|
|
282
|
+
# CloudFront signed cookies must scrub their own cookie
|
|
283
|
+
# logging.
|
|
284
|
+
#
|
|
285
|
+
# Log lines wrapped at fixed widths that split the URL
|
|
286
|
+
# mid-querystring will silently bypass either regex; scrub
|
|
287
|
+
# before line-wrapping.
|
|
288
|
+
#
|
|
289
|
+
# @example Rails — scrub presigned URLs out of all log lines
|
|
290
|
+
# config.lograge.custom_payload do |controller|
|
|
291
|
+
# payload = { ... }
|
|
292
|
+
# payload.transform_values do |v|
|
|
293
|
+
# v.is_a?(String) ? v.gsub(Parse::File.log_filter, "[FILTERED_PRESIGNED_URL]") : v
|
|
294
|
+
# end
|
|
295
|
+
# end
|
|
296
|
+
#
|
|
297
|
+
# @example Rails — `filter_parameters` for params with these names
|
|
298
|
+
# Rails.application.config.filter_parameters += Parse::File.filter_parameter_names
|
|
299
|
+
#
|
|
300
|
+
# @return [Regexp]
|
|
301
|
+
def log_filter
|
|
302
|
+
@log_filter ||= %r{
|
|
303
|
+
https?://[^\s'"<>]+ # URL prefix
|
|
304
|
+
[?&] # query separator
|
|
305
|
+
(?:
|
|
306
|
+
X-Amz-Signature |
|
|
307
|
+
X-Amz-Credential |
|
|
308
|
+
X-Amz-Security-Token |
|
|
309
|
+
X-Amz-Algorithm |
|
|
310
|
+
X-Amz-Date |
|
|
311
|
+
X-Amz-Expires |
|
|
312
|
+
X-Amz-SignedHeaders |
|
|
313
|
+
AWSAccessKeyId |
|
|
314
|
+
Key-Pair-Id
|
|
315
|
+
)
|
|
316
|
+
=[^&\s'"<>]+ # signature value
|
|
317
|
+
(?:&[^\s'"<>]*)? # trailing params
|
|
318
|
+
}xi.freeze
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Stricter variant of {.log_filter} that ALSO matches the
|
|
322
|
+
# JSON-encoded query separator (`\u0026` for `&`). Use this
|
|
323
|
+
# when scrubbing error-reporter event bodies (Sentry,
|
|
324
|
+
# Honeybadger, Rollbar, Bugsnag) where the URL string has been
|
|
325
|
+
# JSON-encoded once and the literal `&` appears as `\u0026`.
|
|
326
|
+
#
|
|
327
|
+
# @example Sentry beforeSend hook — scrub both shapes
|
|
328
|
+
# Sentry.init do |config|
|
|
329
|
+
# config.before_send = ->(event, _hint) {
|
|
330
|
+
# json = JSON.dump(event.to_hash)
|
|
331
|
+
# scrubbed = json.gsub(Parse::File.log_filter_strict, "[FILTERED_PRESIGNED_URL]")
|
|
332
|
+
# JSON.parse(scrubbed)
|
|
333
|
+
# }
|
|
334
|
+
# end
|
|
335
|
+
#
|
|
336
|
+
# @return [Regexp]
|
|
337
|
+
def log_filter_strict
|
|
338
|
+
# URL prefix excludes the backslash so it doesn't greedily
|
|
339
|
+
# consume the `\u0026` sequence in JSON-encoded payloads.
|
|
340
|
+
# Separator and trailing-params clauses both accept either
|
|
341
|
+
# form. The literal `\\u0026` in source produces the Regexp
|
|
342
|
+
# source `\\u0026` which matches the 6 characters `\u0026`
|
|
343
|
+
# (not the Unicode escape for `&` — which is what
|
|
344
|
+
# `\u0026` in source would mean).
|
|
345
|
+
@log_filter_strict ||= %r{
|
|
346
|
+
https?://[^\s'"<>\\]+ # URL prefix (excludes \)
|
|
347
|
+
(?:[?&]|\\u0026) # separator: & or \u0026
|
|
348
|
+
(?:
|
|
349
|
+
X-Amz-Signature |
|
|
350
|
+
X-Amz-Credential |
|
|
351
|
+
X-Amz-Security-Token |
|
|
352
|
+
X-Amz-Algorithm |
|
|
353
|
+
X-Amz-Date |
|
|
354
|
+
X-Amz-Expires |
|
|
355
|
+
X-Amz-SignedHeaders |
|
|
356
|
+
AWSAccessKeyId |
|
|
357
|
+
Key-Pair-Id
|
|
358
|
+
)
|
|
359
|
+
=[^&\s'"<>\\]+ # signature value (excludes \)
|
|
360
|
+
(?:(?:&|\\u0026)[^\s'"<>\\]*)? # trailing params
|
|
361
|
+
}xi.freeze
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Parameter names operators should add to
|
|
365
|
+
# `Rails.application.config.filter_parameters` so presigned-URL
|
|
366
|
+
# query params are scrubbed from request logs by Rails itself.
|
|
367
|
+
#
|
|
368
|
+
# Defaults are AWS-prefixed only (`X-Amz-*`, `AWSAccessKeyId`,
|
|
369
|
+
# `Key-Pair-Id`) so the list never over-redacts a Rails app's
|
|
370
|
+
# `privacy_policy` / e-signature / `policy_id` form fields. For
|
|
371
|
+
# CloudFront-heavy deployments that need bare `Signature` /
|
|
372
|
+
# `Policy` / `Expires` matched as well, append
|
|
373
|
+
# {.cloudfront_signed_param_names}.
|
|
374
|
+
#
|
|
375
|
+
# @return [Array<Regexp>]
|
|
376
|
+
def filter_parameter_names
|
|
377
|
+
@filter_parameter_names ||= [
|
|
378
|
+
/\AX-Amz-/i,
|
|
379
|
+
/\AAWSAccessKeyId\z/i,
|
|
380
|
+
/\AKey-Pair-Id\z/i,
|
|
381
|
+
].freeze
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# CloudFront-signed-URL parameter names (`Signature`, `Policy`,
|
|
385
|
+
# `Expires`). Opt-in extension to {.filter_parameter_names} for
|
|
386
|
+
# apps that proxy CloudFront-signed URLs through Rails params.
|
|
387
|
+
#
|
|
388
|
+
# **Out of scope:** CloudFront signed *cookies*
|
|
389
|
+
# (`CloudFront-Policy`, `CloudFront-Signature`,
|
|
390
|
+
# `CloudFront-Key-Pair-Id` set as HTTP cookies rather than
|
|
391
|
+
# query parameters) are a separate auth mechanism — Rails
|
|
392
|
+
# parameter filtering does not see cookies, and the SDK
|
|
393
|
+
# does not provide a separate cookie-filter list. Apps using
|
|
394
|
+
# CloudFront signed cookies must wire their own protection
|
|
395
|
+
# via `ActionDispatch::Cookies::Middleware` filters.
|
|
396
|
+
#
|
|
397
|
+
# WARNING: these names collide with legitimate app params —
|
|
398
|
+
# `policy` (privacy_policy, policy_id), `signature` (DocuSign /
|
|
399
|
+
# webhook signatures), `expires` (any cache-control style field).
|
|
400
|
+
# Append only when the operator has confirmed no such collision
|
|
401
|
+
# exists in the app's request surface.
|
|
402
|
+
#
|
|
403
|
+
# @return [Array<Regexp>]
|
|
404
|
+
def cloudfront_signed_param_names
|
|
405
|
+
@cloudfront_signed_param_names ||= [
|
|
406
|
+
/\ASignature\z/i,
|
|
407
|
+
/\APolicy\z/i,
|
|
408
|
+
/\AExpires\z/i,
|
|
409
|
+
].freeze
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# True when the URL's query string carries any known signed-URL
|
|
413
|
+
# parameter from {SIGNATURE_QUERY_PARAMS}. Used by the URL
|
|
414
|
+
# normalization point ({Parse::File#normalize_and_store_url}).
|
|
415
|
+
# Uses `String#include?` for cheap substring detection rather
|
|
416
|
+
# than building a Regexp on every assignment.
|
|
417
|
+
#
|
|
418
|
+
# Case-folds the comparison so misbehaving CDNs / reverse
|
|
419
|
+
# proxies that lowercase query-parameter names (rare but real)
|
|
420
|
+
# do not bypass detection. AWS's canonical capitalization is
|
|
421
|
+
# what `SIGNATURE_QUERY_PARAMS` is written in; the case-fold
|
|
422
|
+
# is purely defensive.
|
|
423
|
+
#
|
|
424
|
+
# Known limitations (documented for callers wiring custom
|
|
425
|
+
# strict-mode checks via this predicate):
|
|
426
|
+
#
|
|
427
|
+
# - URL-encoded query separators (`?` written as `%3F`) bypass
|
|
428
|
+
# the literal `?<param>=` substring match. Decode percent
|
|
429
|
+
# encoding before passing in if the URL came from a context
|
|
430
|
+
# that double-encodes.
|
|
431
|
+
# - URL fragments (`#`) before a `?` placeholder do not get
|
|
432
|
+
# stripped here — `normalize_and_store_url` handles
|
|
433
|
+
# fragment-aware stripping during the actual URL store.
|
|
434
|
+
#
|
|
435
|
+
# @param url_string [String, nil]
|
|
436
|
+
# @return [Boolean]
|
|
437
|
+
def url_signature_param?(url_string)
|
|
438
|
+
return false unless url_string.is_a?(String)
|
|
439
|
+
return false unless url_string.include?("?") || url_string.include?("&")
|
|
440
|
+
haystack = url_string.downcase
|
|
441
|
+
SIGNATURE_QUERY_PARAMS.any? do |param|
|
|
442
|
+
needle = param.downcase
|
|
443
|
+
haystack.include?("?#{needle}=") || haystack.include?("&#{needle}=")
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Parse the expiry time (UTC) of a presigned URL directly from
|
|
448
|
+
# its query parameters — the TTL is whatever the issuer chose,
|
|
449
|
+
# NEVER hardcoded SDK-side.
|
|
450
|
+
#
|
|
451
|
+
# Supports:
|
|
452
|
+
# - **SigV4** (`X-Amz-Date=YYYYMMDDTHHMMSSZ` +
|
|
453
|
+
# `X-Amz-Expires=<seconds>`): expiry = date + expires_seconds.
|
|
454
|
+
# - **SigV2 / CloudFront** (`Expires=<unix-seconds>`): expiry =
|
|
455
|
+
# the raw timestamp.
|
|
456
|
+
#
|
|
457
|
+
# Returns nil on malformed input — including a regex-valid
|
|
458
|
+
# date string whose component values are out of range
|
|
459
|
+
# (`20260231T120000Z`, leap-second seconds field `60`, day 32,
|
|
460
|
+
# month 13). Hydration of a corrupt row should not abort
|
|
461
|
+
# `attributes=` with an upstream `ArgumentError`; the caller
|
|
462
|
+
# sees the file with `presigned_url_expires_at == nil` and can
|
|
463
|
+
# decide what to do.
|
|
464
|
+
#
|
|
465
|
+
# @param url [String]
|
|
466
|
+
# @return [Time, nil] expiry in UTC, or nil if the URL doesn't
|
|
467
|
+
# carry parseable presigned-URL expiry data.
|
|
468
|
+
def parse_presigned_expiry(url)
|
|
469
|
+
return nil unless url.is_a?(String)
|
|
470
|
+
query = url.split("?", 2)[1]
|
|
471
|
+
return nil unless query
|
|
472
|
+
params = {}
|
|
473
|
+
query.split("&").each do |pair|
|
|
474
|
+
k, v = pair.split("=", 2)
|
|
475
|
+
params[k] = v if k && v
|
|
476
|
+
end
|
|
477
|
+
if params["X-Amz-Date"] && params["X-Amz-Expires"]
|
|
478
|
+
ts = params["X-Amz-Date"]
|
|
479
|
+
secs = params["X-Amz-Expires"].to_i
|
|
480
|
+
return nil unless secs > 0
|
|
481
|
+
# X-Amz-Date is ISO 8601 basic — YYYYMMDDTHHMMSSZ, always
|
|
482
|
+
# UTC. Manual slice is safer than `Time.strptime` which
|
|
483
|
+
# treats `Z` as a literal and interprets the result in
|
|
484
|
+
# local time.
|
|
485
|
+
m = ts.match(/\A(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z\z/)
|
|
486
|
+
return nil unless m
|
|
487
|
+
begin
|
|
488
|
+
Time.utc(m[1].to_i, m[2].to_i, m[3].to_i,
|
|
489
|
+
m[4].to_i, m[5].to_i, m[6].to_i) + secs
|
|
490
|
+
rescue ArgumentError
|
|
491
|
+
# Regex-valid but date-component-invalid (day 32, month
|
|
492
|
+
# 13, seconds 60). Return nil rather than propagating up
|
|
493
|
+
# through hydration.
|
|
494
|
+
nil
|
|
495
|
+
end
|
|
496
|
+
elsif params["Expires"]
|
|
497
|
+
unix = params["Expires"].to_i
|
|
498
|
+
return nil if unix <= 0
|
|
499
|
+
Time.at(unix).utc
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Strip the query string (everything from the first `?`) from a URL.
|
|
504
|
+
#
|
|
505
|
+
# Used to drop short-TTL presigned-URL signature parameters before a
|
|
506
|
+
# `File.basename` comparison. Implemented with `String#index` rather
|
|
507
|
+
# than a `sub(/\?.*\z/, "")` regex: the regex form is O(n^2) on
|
|
508
|
+
# adversarial input (a long run of `?`), and these URLs are
|
|
509
|
+
# externally influenced (S3/CloudFront presign on every read), so the
|
|
510
|
+
# linear form removes the polynomial-regex slow path.
|
|
511
|
+
#
|
|
512
|
+
# @param url [String, nil]
|
|
513
|
+
# @return [String, nil] the URL up to (but excluding) the first `?`,
|
|
514
|
+
# or the input unchanged when there is no query string. `nil` and
|
|
515
|
+
# non-strings pass through untouched.
|
|
516
|
+
def strip_query(url)
|
|
517
|
+
return url unless url.is_a?(String)
|
|
518
|
+
i = url.index("?")
|
|
519
|
+
i ? url[0, i] : url
|
|
520
|
+
end
|
|
521
|
+
|
|
180
522
|
# @!visibility private
|
|
181
523
|
# Fetches a remote URL with strict SSRF defenses. Refuses non-HTTP
|
|
182
524
|
# schemes, RFC1918 / loopback / cloud-metadata addresses, oversized
|
|
@@ -329,7 +671,15 @@ module Parse
|
|
|
329
671
|
@name = File.basename name.to_path
|
|
330
672
|
elsif name.is_a?(Parse::File)
|
|
331
673
|
@name = name.name
|
|
332
|
-
|
|
674
|
+
# Route through the single URL normalization point so the copy
|
|
675
|
+
# gets the same strip + stash treatment as a caller-side
|
|
676
|
+
# `url=`. Preserve the source's presigned-URL stash
|
|
677
|
+
# post-normalization (normalize resets them; carrying the
|
|
678
|
+
# source's values across keeps the copy semantically
|
|
679
|
+
# equivalent for view-render use cases).
|
|
680
|
+
normalize_and_store_url(name.url)
|
|
681
|
+
@presigned_url = name.presigned_url if name.presigned_url
|
|
682
|
+
@presigned_url_expires_at = name.presigned_url_expires_at if name.presigned_url_expires_at
|
|
333
683
|
else
|
|
334
684
|
@name = name
|
|
335
685
|
@contents = contents
|
|
@@ -351,10 +701,22 @@ module Parse
|
|
|
351
701
|
file
|
|
352
702
|
end
|
|
353
703
|
|
|
354
|
-
# A File object is considered saved
|
|
704
|
+
# A File object is considered saved when `@url` and `@name` are
|
|
705
|
+
# both present and `@name` matches the basename of `@url`'s path
|
|
706
|
+
# component.
|
|
707
|
+
#
|
|
708
|
+
# The URL's query string is stripped before the basename
|
|
709
|
+
# computation so short-TTL presigned URLs that Parse Server's
|
|
710
|
+
# S3FilesAdapter returns on every read
|
|
711
|
+
# (`https://bucket.s3.../doc.pdf?X-Amz-Signature=...`) don't
|
|
712
|
+
# confuse `File.basename` into including the signature bytes in
|
|
713
|
+
# the comparison.
|
|
714
|
+
#
|
|
355
715
|
# @return [Boolean] true if this file has already been saved.
|
|
356
716
|
def saved?
|
|
357
|
-
@url.present? && @name.present?
|
|
717
|
+
return false unless @url.present? && @name.present?
|
|
718
|
+
path_only = Parse::File.strip_query(@url)
|
|
719
|
+
@name == File.basename(path_only)
|
|
358
720
|
end
|
|
359
721
|
|
|
360
722
|
# Returns the url string for this Parse::File pointer. If the *force_ssl* option is
|
|
@@ -379,6 +741,13 @@ module Parse
|
|
|
379
741
|
end
|
|
380
742
|
|
|
381
743
|
# Allows mass assignment from a Parse JSON hash.
|
|
744
|
+
#
|
|
745
|
+
# Routes through the single normalization point
|
|
746
|
+
# {#normalize_and_store_url}, identical to {#url=}. Signed URLs
|
|
747
|
+
# (the common Parse-Server-S3 case) are silently stripped and
|
|
748
|
+
# stashed in `@presigned_url`. See rev 3 D2 in
|
|
749
|
+
# s3_adapter_plan.md — asymmetric writer behavior is an explicit
|
|
750
|
+
# anti-goal.
|
|
382
751
|
def attributes=(h)
|
|
383
752
|
raw_url = nil
|
|
384
753
|
if h.is_a?(String)
|
|
@@ -388,8 +757,107 @@ module Parse
|
|
|
388
757
|
raw_url = h[FIELD_URL] || h[:url]
|
|
389
758
|
@name = h[FIELD_NAME] || h[:name] || @name
|
|
390
759
|
end
|
|
391
|
-
|
|
760
|
+
normalize_and_store_url(raw_url)
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
# @return [String, nil] the last signed URL the SDK saw for this
|
|
764
|
+
# file's location. Populated by the URL normalization point
|
|
765
|
+
# ({#normalize_and_store_url}) whenever an incoming URL carries a
|
|
766
|
+
# recognized signed-URL query parameter. Distinct from `@url`
|
|
767
|
+
# (which is always the bare canonical URL — see rev 3 D1). The
|
|
768
|
+
# expiry of this URL is in {#presigned_url_expires_at}; callers
|
|
769
|
+
# should consult that before handing the URL to a client.
|
|
770
|
+
attr_reader :presigned_url
|
|
771
|
+
|
|
772
|
+
# @return [Time, nil] the expiry time (UTC) parsed from the most
|
|
773
|
+
# recent presigned URL the SDK saw, computed from the URL's own
|
|
774
|
+
# query parameters (`X-Amz-Date` + `X-Amz-Expires` for SigV4,
|
|
775
|
+
# `Expires` for SigV2 / CloudFront). The TTL is **never**
|
|
776
|
+
# hardcoded; whatever Parse Server's S3FilesAdapter (or whoever
|
|
777
|
+
# issued the URL) chose is what the SDK uses.
|
|
778
|
+
attr_reader :presigned_url_expires_at
|
|
779
|
+
|
|
780
|
+
# True when {#presigned_url} is set and not yet expired (with an
|
|
781
|
+
# optional safety buffer so callers can refetch before the URL
|
|
782
|
+
# actually expires server-side).
|
|
783
|
+
#
|
|
784
|
+
# @example Render a presigned URL in a Rails view, refetching when near expiry
|
|
785
|
+
# if file.presigned_url_valid?
|
|
786
|
+
# # render directly — buffer absorbs network RTT + retries
|
|
787
|
+
# else
|
|
788
|
+
# post.reload
|
|
789
|
+
# # render post.attachment.presigned_url
|
|
790
|
+
# end
|
|
791
|
+
#
|
|
792
|
+
# @param buffer [Integer, Float] seconds before
|
|
793
|
+
# `presigned_url_expires_at` to start treating as expired.
|
|
794
|
+
# Default 60 seconds — a margin that absorbs network RTT,
|
|
795
|
+
# client clock skew, and one retry. Tighten via `buffer: 30`
|
|
796
|
+
# in latency-sensitive paths; loosen via `buffer: 120` for
|
|
797
|
+
# apps that proxy URLs through additional hops before render.
|
|
798
|
+
# @return [Boolean]
|
|
799
|
+
def presigned_url_valid?(buffer: 60)
|
|
800
|
+
return false if @presigned_url.nil?
|
|
801
|
+
return false if @presigned_url_expires_at.nil?
|
|
802
|
+
(@presigned_url_expires_at - buffer.to_f) > Time.now.utc
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
# @!visibility private
|
|
806
|
+
# The single normalization point for any URL assignment. Routes
|
|
807
|
+
# both {#url=} (caller-side) and {#attributes=} (hydration) through
|
|
808
|
+
# the same logic — see rev 3 D2 in s3_adapter_plan.md for the
|
|
809
|
+
# rationale on uniform behavior.
|
|
810
|
+
def normalize_and_store_url(value)
|
|
811
|
+
# Unconditionally invalidate the presigned-URL stash on every
|
|
812
|
+
# URL assignment. Reassignment may point at a different storage
|
|
813
|
+
# location; a stale stashed signed URL would silently lie to
|
|
814
|
+
# downstream callers (e.g. an ERB view that renders
|
|
815
|
+
# `file.presigned_url`). If the new value is itself a signed
|
|
816
|
+
# URL, the stash is repopulated below.
|
|
817
|
+
@presigned_url = nil
|
|
818
|
+
@presigned_url_expires_at = nil
|
|
819
|
+
|
|
820
|
+
# Strict-mode hook: operators who can guarantee Parse Server is
|
|
821
|
+
# NOT configured with a presigned-URL file adapter (i.e. signed
|
|
822
|
+
# URLs in `@url` would always indicate a bug) flip the policy
|
|
823
|
+
# via `Parse::File.signed_url_policy = :raise` and get a loud
|
|
824
|
+
# SignedUrlError instead of silent normalization.
|
|
825
|
+
if value.is_a?(String) && Parse::File.url_signature_param?(value)
|
|
826
|
+
if Parse::File.signed_url_policy == :raise
|
|
827
|
+
raise SignedUrlError,
|
|
828
|
+
"Parse::File received a signed URL while " \
|
|
829
|
+
"`signed_url_policy` is `:raise`. The query string " \
|
|
830
|
+
"carries a presigned-URL signature parameter and the " \
|
|
831
|
+
"configured policy refuses to normalize silently. " \
|
|
832
|
+
"Mint signed GET URLs via the storage adapter (server " \
|
|
833
|
+
"mode) or read `file.presigned_url` (client mode) " \
|
|
834
|
+
"instead of assigning them to `@url`."
|
|
835
|
+
end
|
|
836
|
+
# Stash the original signed URL with its data-driven expiry,
|
|
837
|
+
# then strip the query string and store the bare canonical
|
|
838
|
+
# URL in @url. Subsequent reads of `file.url` return the
|
|
839
|
+
# canonical URL; presigned access goes through
|
|
840
|
+
# `presigned_url` (or, in a later release, `download_url`).
|
|
841
|
+
@presigned_url = value
|
|
842
|
+
@presigned_url_expires_at = Parse::File.parse_presigned_expiry(value)
|
|
843
|
+
bare = Parse::File.strip_query(value)
|
|
844
|
+
normalized = Parse::File.sanitize_hydrated_url(bare, fallback: @url, name: @name)
|
|
845
|
+
# If the host check stripped or rejected the URL (`:strip`
|
|
846
|
+
# policy or `:raise` after the signature strip), clear the
|
|
847
|
+
# stash too — leaving an attacker-controlled signed URL in
|
|
848
|
+
# `@presigned_url` while `@url` was refused is a silent leak
|
|
849
|
+
# surface that the host-policy author explicitly chose to
|
|
850
|
+
# avoid.
|
|
851
|
+
if normalized.nil? || normalized != bare
|
|
852
|
+
@presigned_url = nil
|
|
853
|
+
@presigned_url_expires_at = nil
|
|
854
|
+
end
|
|
855
|
+
@url = normalized
|
|
856
|
+
else
|
|
857
|
+
@url = Parse::File.sanitize_hydrated_url(value, fallback: @url, name: @name)
|
|
858
|
+
end
|
|
392
859
|
end
|
|
860
|
+
private :normalize_and_store_url
|
|
393
861
|
|
|
394
862
|
# @!visibility private
|
|
395
863
|
# Apply {trusted_url_hosts} / {untrusted_url_policy} to a URL coming
|
|
@@ -497,8 +965,27 @@ module Parse
|
|
|
497
965
|
response = client.create_file(@name, @contents, @mime_type, **opts)
|
|
498
966
|
unless response.error?
|
|
499
967
|
result = response.result
|
|
500
|
-
|
|
501
|
-
|
|
968
|
+
# Route the create-response URL through the SAME normalization
|
|
969
|
+
# point as `url=` / `attributes=`. Parse Server's S3FilesAdapter
|
|
970
|
+
# can return a freshly-signed URL in the file-create response
|
|
971
|
+
# (not only on read), and a direct `@url = result[url]` would
|
|
972
|
+
# leave that signed URL verbatim in `@url` — and bake the
|
|
973
|
+
# signature query string into `@name` via `File.basename` when
|
|
974
|
+
# the response omits `name`. Normalizing here keeps the `@url`
|
|
975
|
+
# invariant (canonical, never a short-TTL signed URL) on the
|
|
976
|
+
# save writer too, stashes any signature in `@presigned_url`,
|
|
977
|
+
# and honors `signed_url_policy = :raise`.
|
|
978
|
+
#
|
|
979
|
+
# Set `@name` to the server's authoritative name (or nil)
|
|
980
|
+
# BEFORE normalizing so `sanitize_hydrated_url`'s `tfss-` host
|
|
981
|
+
# check reads the response URL's own basename rather than a
|
|
982
|
+
# stale pre-upload name. As before, `@name` is always taken
|
|
983
|
+
# from the response — but the fallback now derives from the
|
|
984
|
+
# CANONICAL `@url`, never the signed URL.
|
|
985
|
+
result_name = result[FIELD_NAME]
|
|
986
|
+
@name = result_name
|
|
987
|
+
normalize_and_store_url(result[FIELD_URL])
|
|
988
|
+
@name = result_name || (@url.present? ? File.basename(@url) : nil)
|
|
502
989
|
end
|
|
503
990
|
end
|
|
504
991
|
saved?
|
|
@@ -511,8 +998,17 @@ module Parse
|
|
|
511
998
|
end
|
|
512
999
|
|
|
513
1000
|
# @!visibility private
|
|
1001
|
+
# Inspect output deliberately omits `@url` to keep short-TTL
|
|
1002
|
+
# adapter-issued URLs (e.g. S3/CloudFront presigned download URLs)
|
|
1003
|
+
# out of exception messages, Rails error reports, and log captures.
|
|
1004
|
+
# The invariant for the public {url} accessor is that `@url` is
|
|
1005
|
+
# always a stable canonical URL — never a signed URL — but `inspect`
|
|
1006
|
+
# is conservative on principle: callers who explicitly want the URL
|
|
1007
|
+
# ask for `file.url`.
|
|
514
1008
|
def inspect
|
|
515
|
-
|
|
1009
|
+
url_state = @url.present? ? "set" : "blank"
|
|
1010
|
+
"<Parse::File @name=#{@name.inspect} @mime_type=#{@mime_type.inspect} " \
|
|
1011
|
+
"@contents=#{@contents.nil?} @url=#{url_state}>"
|
|
516
1012
|
end
|
|
517
1013
|
|
|
518
1014
|
# @return [String] the url
|
|
@@ -525,15 +1021,24 @@ end
|
|
|
525
1021
|
|
|
526
1022
|
# Adds extensions to Hash class.
|
|
527
1023
|
class Hash
|
|
528
|
-
# Determines
|
|
529
|
-
#
|
|
530
|
-
#
|
|
1024
|
+
# Determines whether the hash contains Parse File JSON metadata fields.
|
|
1025
|
+
#
|
|
1026
|
+
# Accepts the canonical Parse Server file-pointer shapes:
|
|
1027
|
+
# - `{name, url}` (count == 2) with `name == File.basename(url path)`
|
|
1028
|
+
# - `{__type: "File", name, url}` (any count) with the same basename
|
|
1029
|
+
# equality
|
|
1030
|
+
#
|
|
1031
|
+
# The URL's query string is stripped before computing basename so
|
|
1032
|
+
# short-TTL presigned URLs that Parse Server's S3FilesAdapter returns
|
|
1033
|
+
# on every read don't include the signature bytes in the comparison.
|
|
531
1034
|
#
|
|
532
1035
|
# @return [Boolean] True if this hash contains Parse file metadata.
|
|
533
1036
|
def parse_file?
|
|
534
1037
|
url = self[Parse::File::FIELD_URL]
|
|
535
1038
|
name = self[Parse::File::FIELD_NAME]
|
|
536
|
-
|
|
537
|
-
|
|
1039
|
+
return false unless url.present? && name.present?
|
|
1040
|
+
return false unless count == 2 || self["__type"] == Parse::File.parse_class
|
|
1041
|
+
path_only = Parse::File.strip_query(url)
|
|
1042
|
+
name == ::File.basename(path_only)
|
|
538
1043
|
end
|
|
539
1044
|
end
|