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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/.github/ISSUE_TEMPLATE/bug_report.yml +105 -0
  3. data/.github/ISSUE_TEMPLATE/feature_request.yml +67 -0
  4. data/.github/dependabot.yml +13 -0
  5. data/.github/workflows/codeql.yml +1 -1
  6. data/.github/workflows/docs.yml +3 -3
  7. data/.github/workflows/release.yml +14 -3
  8. data/.github/workflows/ruby.yml +1 -1
  9. data/.gitignore +1 -0
  10. data/.yardopts +19 -0
  11. data/CHANGELOG.md +792 -0
  12. data/Gemfile +3 -0
  13. data/Gemfile.lock +8 -5
  14. data/README.md +15 -0
  15. data/Rakefile +5 -1
  16. data/docs/acl_clp_guide.md +553 -0
  17. data/docs/atlas_vector_search_guide.md +123 -22
  18. data/docs/client_sdk_guide.md +201 -5
  19. data/docs/usage_guide.md +21 -0
  20. data/docs/yard-template/default/fulldoc/html/css/common.css +1222 -0
  21. data/docs/yard-template/default/fulldoc/html/css/full_list.css +387 -0
  22. data/lib/parse/agent/tools.rb +153 -1
  23. data/lib/parse/cache/redis.rb +53 -0
  24. data/lib/parse/client/caching.rb +18 -1
  25. data/lib/parse/client.rb +79 -12
  26. data/lib/parse/embeddings/cohere.rb +143 -6
  27. data/lib/parse/embeddings/provider.rb +20 -2
  28. data/lib/parse/embeddings/voyage.rb +102 -0
  29. data/lib/parse/embeddings.rb +332 -1
  30. data/lib/parse/live_query/client.rb +167 -4
  31. data/lib/parse/live_query/configuration.rb +12 -0
  32. data/lib/parse/live_query/subscription.rb +55 -2
  33. data/lib/parse/live_query.rb +123 -1
  34. data/lib/parse/lock.rb +342 -0
  35. data/lib/parse/lock_backend.rb +308 -0
  36. data/lib/parse/model/classes/audience.rb +5 -0
  37. data/lib/parse/model/classes/installation.rb +122 -0
  38. data/lib/parse/model/classes/job_schedule.rb +3 -1
  39. data/lib/parse/model/classes/job_status.rb +4 -1
  40. data/lib/parse/model/classes/push_status.rb +4 -1
  41. data/lib/parse/model/classes/session.rb +7 -0
  42. data/lib/parse/model/classes/user.rb +204 -0
  43. data/lib/parse/model/core/create_lock.rb +28 -146
  44. data/lib/parse/model/core/embed_managed.rb +162 -13
  45. data/lib/parse/model/core/parse_reference.rb +17 -1
  46. data/lib/parse/model/core/querying.rb +26 -2
  47. data/lib/parse/model/file.rb +523 -18
  48. data/lib/parse/query.rb +31 -1
  49. data/lib/parse/stack/version.rb +1 -1
  50. data/lib/parse/stack.rb +98 -1
  51. data/parse-stack-next.gemspec +2 -2
  52. metadata +17 -7
@@ -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
- # Assign the file's URL. Routes through the same
72
- # {Parse::File.sanitize_hydrated_url} validator that hydration uses,
73
- # so caller-supplied URLs (e.g. `parse_file.url = params[:url]`) get
74
- # the same trusted-host check as JSON-hydrated rows.
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
- @url = Parse::File.sanitize_hydrated_url(value, fallback: @url, name: @name)
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
- @url = name.url
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 if the basename of the URL and the name parameters are equal
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? && @name == File.basename(@url)
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
- @url = Parse::File.sanitize_hydrated_url(raw_url, fallback: @url, name: @name)
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
- @name = result[FIELD_NAME] || File.basename(result[FIELD_URL])
501
- @url = result[FIELD_URL]
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
- "<Parse::File @name='#{@name}' @mime_type='#{@mime_type}' @contents=#{@contents.nil?} @url='#{@url}'>"
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 if the hash contains Parse File json metadata fields. This is determined whether
529
- # the key `__type` exists and is of type `__File` and whether the `name` field matches the File.basename
530
- # of the `url` field.
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
- (count == 2 || self["__type"] == Parse::File.parse_class) &&
537
- url.present? && name.present? && name == ::File.basename(url)
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