parse-stack-next 4.5.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 (178) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.env.sample +112 -0
  4. data/.env.test +10 -0
  5. data/.github/workflows/ruby.yml +36 -0
  6. data/.gitignore +49 -0
  7. data/.ruby-version +1 -0
  8. data/.solargraph.yml +22 -0
  9. data/CHANGELOG.md +5816 -0
  10. data/Gemfile +30 -0
  11. data/Gemfile.lock +175 -0
  12. data/LICENSE.txt +23 -0
  13. data/Makefile +63 -0
  14. data/README.md +5655 -0
  15. data/Rakefile +573 -0
  16. data/bin/console +38 -0
  17. data/bin/parse-console +136 -0
  18. data/bin/server +17 -0
  19. data/bin/setup +7 -0
  20. data/config/parse-config.json +12 -0
  21. data/docs/TEST_SERVER.md +271 -0
  22. data/docs/_config.yml +1 -0
  23. data/docs/mcp_guide.md +3484 -0
  24. data/docs/mongodb_direct_guide.md +1348 -0
  25. data/docs/mongodb_index_optimization_guide.md +631 -0
  26. data/examples/transaction_example.rb +219 -0
  27. data/lib/parse/acl_scope.rb +728 -0
  28. data/lib/parse/agent/cancellation_token.rb +80 -0
  29. data/lib/parse/agent/constraint_translator.rb +480 -0
  30. data/lib/parse/agent/describe.rb +420 -0
  31. data/lib/parse/agent/errors.rb +133 -0
  32. data/lib/parse/agent/mcp_client.rb +557 -0
  33. data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
  34. data/lib/parse/agent/mcp_rack_app.rb +1143 -0
  35. data/lib/parse/agent/mcp_server.rb +376 -0
  36. data/lib/parse/agent/metadata_audit.rb +259 -0
  37. data/lib/parse/agent/metadata_dsl.rb +733 -0
  38. data/lib/parse/agent/metadata_registry.rb +794 -0
  39. data/lib/parse/agent/pipeline_validator.rb +82 -0
  40. data/lib/parse/agent/prompts.rb +351 -0
  41. data/lib/parse/agent/rate_limiter.rb +158 -0
  42. data/lib/parse/agent/relation_graph.rb +162 -0
  43. data/lib/parse/agent/result_formatter.rb +453 -0
  44. data/lib/parse/agent/tools.rb +5489 -0
  45. data/lib/parse/agent.rb +3249 -0
  46. data/lib/parse/api/aggregate.rb +79 -0
  47. data/lib/parse/api/all.rb +26 -0
  48. data/lib/parse/api/analytics.rb +18 -0
  49. data/lib/parse/api/batch.rb +33 -0
  50. data/lib/parse/api/cloud_functions.rb +58 -0
  51. data/lib/parse/api/config.rb +125 -0
  52. data/lib/parse/api/files.rb +29 -0
  53. data/lib/parse/api/hooks.rb +117 -0
  54. data/lib/parse/api/objects.rb +146 -0
  55. data/lib/parse/api/path_segment.rb +75 -0
  56. data/lib/parse/api/push.rb +20 -0
  57. data/lib/parse/api/schema.rb +49 -0
  58. data/lib/parse/api/server.rb +50 -0
  59. data/lib/parse/api/sessions.rb +24 -0
  60. data/lib/parse/api/users.rb +250 -0
  61. data/lib/parse/atlas_search/index_manager.rb +353 -0
  62. data/lib/parse/atlas_search/result.rb +204 -0
  63. data/lib/parse/atlas_search/search_builder.rb +604 -0
  64. data/lib/parse/atlas_search/session.rb +253 -0
  65. data/lib/parse/atlas_search.rb +995 -0
  66. data/lib/parse/client/authentication.rb +97 -0
  67. data/lib/parse/client/batch.rb +234 -0
  68. data/lib/parse/client/body_builder.rb +240 -0
  69. data/lib/parse/client/caching.rb +203 -0
  70. data/lib/parse/client/logging.rb +293 -0
  71. data/lib/parse/client/profiling.rb +181 -0
  72. data/lib/parse/client/protocol.rb +91 -0
  73. data/lib/parse/client/request.rb +233 -0
  74. data/lib/parse/client/response.rb +208 -0
  75. data/lib/parse/client.rb +1104 -0
  76. data/lib/parse/clp_scope.rb +361 -0
  77. data/lib/parse/live_query/circuit_breaker.rb +256 -0
  78. data/lib/parse/live_query/client.rb +1001 -0
  79. data/lib/parse/live_query/configuration.rb +224 -0
  80. data/lib/parse/live_query/event.rb +115 -0
  81. data/lib/parse/live_query/event_queue.rb +272 -0
  82. data/lib/parse/live_query/health_monitor.rb +214 -0
  83. data/lib/parse/live_query/logging.rb +149 -0
  84. data/lib/parse/live_query/subscription.rb +294 -0
  85. data/lib/parse/live_query.rb +163 -0
  86. data/lib/parse/lookup_rewriter.rb +445 -0
  87. data/lib/parse/model/acl.rb +968 -0
  88. data/lib/parse/model/associations/belongs_to.rb +275 -0
  89. data/lib/parse/model/associations/collection_proxy.rb +435 -0
  90. data/lib/parse/model/associations/has_many.rb +597 -0
  91. data/lib/parse/model/associations/has_one.rb +158 -0
  92. data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
  93. data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
  94. data/lib/parse/model/bytes.rb +62 -0
  95. data/lib/parse/model/classes/audience.rb +262 -0
  96. data/lib/parse/model/classes/installation.rb +363 -0
  97. data/lib/parse/model/classes/job_schedule.rb +153 -0
  98. data/lib/parse/model/classes/job_status.rb +264 -0
  99. data/lib/parse/model/classes/product.rb +75 -0
  100. data/lib/parse/model/classes/push_status.rb +263 -0
  101. data/lib/parse/model/classes/role.rb +751 -0
  102. data/lib/parse/model/classes/session.rb +201 -0
  103. data/lib/parse/model/classes/user.rb +943 -0
  104. data/lib/parse/model/clp.rb +544 -0
  105. data/lib/parse/model/core/actions.rb +1268 -0
  106. data/lib/parse/model/core/builder.rb +139 -0
  107. data/lib/parse/model/core/create_lock.rb +386 -0
  108. data/lib/parse/model/core/describe.rb +382 -0
  109. data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
  110. data/lib/parse/model/core/errors.rb +38 -0
  111. data/lib/parse/model/core/fetching.rb +566 -0
  112. data/lib/parse/model/core/field_guards.rb +220 -0
  113. data/lib/parse/model/core/indexing.rb +382 -0
  114. data/lib/parse/model/core/parse_reference.rb +407 -0
  115. data/lib/parse/model/core/properties.rb +809 -0
  116. data/lib/parse/model/core/querying.rb +491 -0
  117. data/lib/parse/model/core/schema.rb +202 -0
  118. data/lib/parse/model/core/search_indexing.rb +174 -0
  119. data/lib/parse/model/date.rb +88 -0
  120. data/lib/parse/model/email.rb +213 -0
  121. data/lib/parse/model/file.rb +527 -0
  122. data/lib/parse/model/geojson.rb +271 -0
  123. data/lib/parse/model/geopoint.rb +261 -0
  124. data/lib/parse/model/model.rb +260 -0
  125. data/lib/parse/model/object.rb +2068 -0
  126. data/lib/parse/model/phone.rb +520 -0
  127. data/lib/parse/model/pointer.rb +443 -0
  128. data/lib/parse/model/polygon.rb +406 -0
  129. data/lib/parse/model/push.rb +975 -0
  130. data/lib/parse/model/shortnames.rb +8 -0
  131. data/lib/parse/model/time_zone.rb +141 -0
  132. data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
  133. data/lib/parse/model/validations.rb +96 -0
  134. data/lib/parse/mongodb.rb +2300 -0
  135. data/lib/parse/pipeline_security.rb +554 -0
  136. data/lib/parse/query/constraint.rb +198 -0
  137. data/lib/parse/query/constraints.rb +3279 -0
  138. data/lib/parse/query/cursor.rb +434 -0
  139. data/lib/parse/query/n_plus_one_detector.rb +445 -0
  140. data/lib/parse/query/operation.rb +104 -0
  141. data/lib/parse/query/ordering.rb +66 -0
  142. data/lib/parse/query.rb +7028 -0
  143. data/lib/parse/schema/index_migrator.rb +291 -0
  144. data/lib/parse/schema/search_index_migrator.rb +289 -0
  145. data/lib/parse/schema.rb +494 -0
  146. data/lib/parse/stack/generators/rails.rb +40 -0
  147. data/lib/parse/stack/generators/templates/model.erb +51 -0
  148. data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
  149. data/lib/parse/stack/generators/templates/model_role.rb +4 -0
  150. data/lib/parse/stack/generators/templates/model_session.rb +4 -0
  151. data/lib/parse/stack/generators/templates/model_user.rb +11 -0
  152. data/lib/parse/stack/generators/templates/parse.rb +12 -0
  153. data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
  154. data/lib/parse/stack/railtie.rb +18 -0
  155. data/lib/parse/stack/tasks.rb +563 -0
  156. data/lib/parse/stack/version.rb +11 -0
  157. data/lib/parse/stack.rb +455 -0
  158. data/lib/parse/two_factor_auth/user_extension.rb +449 -0
  159. data/lib/parse/two_factor_auth.rb +310 -0
  160. data/lib/parse/webhooks/payload.rb +360 -0
  161. data/lib/parse/webhooks/registration.rb +199 -0
  162. data/lib/parse/webhooks/replay_protection.rb +189 -0
  163. data/lib/parse/webhooks.rb +510 -0
  164. data/lib/parse-stack-next.rb +5 -0
  165. data/lib/parse-stack.rb +5 -0
  166. data/parse-stack-next.gemspec +82 -0
  167. data/parse-stack.png +0 -0
  168. data/scripts/debug-ips.js +35 -0
  169. data/scripts/docker/Dockerfile.parse +13 -0
  170. data/scripts/docker/atlas-init.js +284 -0
  171. data/scripts/docker/docker-compose.atlas.yml +76 -0
  172. data/scripts/docker/docker-compose.test.yml +106 -0
  173. data/scripts/docker/mongo-init.js +21 -0
  174. data/scripts/eval_mcp_with_lm_studio.rb +274 -0
  175. data/scripts/start-parse.sh +90 -0
  176. data/scripts/start_mcp_server.rb +78 -0
  177. data/scripts/test_server_connection.rb +82 -0
  178. metadata +377 -0
@@ -0,0 +1,527 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "active_support"
5
+ require "active_support/core_ext/object"
6
+ require_relative "model"
7
+ require "open-uri"
8
+ require "ipaddr"
9
+ require "resolv"
10
+
11
+ module Parse
12
+ # This class represents a Parse file pointer. `Parse::File` has helper
13
+ # methods to upload Parse files directly to Parse and manage file
14
+ # associations with your classes.
15
+ # @example
16
+ # file = File.open("file_path.jpg")
17
+ # contents = file.read
18
+ # file = Parse::File.new("myimage.jpg", contents , "image/jpeg")
19
+ # file.saved? # => false
20
+ # file.save
21
+ #
22
+ # file.url # https://files.parsetfss.com/....
23
+ #
24
+ # # or create and upload a remote file (auto-detected mime type)
25
+ # file = Parse::File.create(some_url)
26
+ #
27
+ #
28
+ # @note The default MIME type for all files is _image/jpeg_. This can be default
29
+ # can be changed by setting a value to `Parse::File.default_mime_type`.
30
+ class File < Model
31
+ # Raised when a `Parse::File` is hydrated with a `url:` whose host is
32
+ # outside {Parse::File.trusted_url_hosts} and the
33
+ # {Parse::File.untrusted_url_policy} is `:raise`. The default policy
34
+ # is `:warn`, so this exception is opt-in via integrator
35
+ # configuration.
36
+ class UntrustedHostError < Parse::Error; end
37
+
38
+ # Regular expression that matches the old legacy Parse hosted file name
39
+ 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.
41
+ ATTRIBUTES = { __type: :string, name: :string, url: :string }.freeze
42
+
43
+ # @!visibility private
44
+ # Default cap on remote-fetched file size (50 MiB). Override via
45
+ # +Parse::File.max_remote_size+.
46
+ DEFAULT_MAX_REMOTE_SIZE = 50 * 1024 * 1024
47
+ # @!visibility private
48
+ # Default read/open timeout for remote fetches in seconds.
49
+ DEFAULT_REMOTE_TIMEOUT = 10
50
+ # @!visibility private
51
+ # CIDR ranges that must never be reachable from Parse::File URL fetches
52
+ # (loopback, link-local, private, multicast, broadcast, cloud-metadata,
53
+ # CGNAT, IPv6 ULA/link-local, IPv4-mapped IPv6). Refer to RFC 1918 /
54
+ # 6890 / 6598 / 4193 / 4291.
55
+ BLOCKED_CIDRS = [
56
+ "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8",
57
+ "169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.168.0.0/16",
58
+ "198.18.0.0/15", "224.0.0.0/4", "240.0.0.0/4", "255.255.255.255/32",
59
+ # Alibaba Cloud metadata service (public-IP-space but well-known
60
+ # cloud-metadata endpoint that must not be reachable from SDK fetches).
61
+ "100.100.100.200/32",
62
+ "::/128", "::1/128", "fc00::/7", "fe80::/10", "ff00::/8", "::ffff:0:0/96"
63
+ ].map { |c| IPAddr.new(c) }.freeze
64
+ # Restrictive port allowlist for Parse::File URL fetches. By default
65
+ # only the standard HTTP/HTTPS ports are permitted. Operators may
66
+ # extend +Parse::File.allowed_remote_ports+ for legitimate non-standard
67
+ # CDN ports.
68
+ DEFAULT_ALLOWED_REMOTE_PORTS = [80, 443, 8080, 8443].freeze
69
+ # @return [String] the name of the file including extension (if any)
70
+ 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.
75
+ # @param value [String, nil] the URL to assign.
76
+ def url=(value)
77
+ @url = Parse::File.sanitize_hydrated_url(value, fallback: @url, name: @name)
78
+ end
79
+
80
+ # @return [Object] the contents of the file.
81
+ attr_accessor :contents
82
+
83
+ # @return [String] the mime-type of the file whe
84
+ attr_accessor :mime_type
85
+ # @return [Model::TYPE_FILE]
86
+ def self.parse_class; TYPE_FILE; end
87
+ # @return [Model::TYPE_FILE]
88
+ def parse_class; self.class.parse_class; end
89
+
90
+ alias_method :__type, :parse_class
91
+ # @!visibility private
92
+ FIELD_NAME = "name"
93
+ # @!visibility private
94
+ FIELD_URL = "url"
95
+ class << self
96
+
97
+ # @return [String] the default mime-type
98
+ attr_writer :default_mime_type
99
+
100
+ # @return [Boolean] whether to force all urls to be https.
101
+ attr_writer :force_ssl
102
+
103
+ # @return [String] The default mime type for created instances. Default: _'image/jpeg'_
104
+ def default_mime_type
105
+ @default_mime_type ||= "image/jpeg"
106
+ end
107
+
108
+ # @return [Boolean] When set to true, it will make all calls to File#url
109
+ def force_ssl
110
+ @force_ssl ||= false
111
+ end
112
+
113
+ # @return [Integer] Maximum byte size for a remote URL fetch via
114
+ # +Parse::File.create+ / +Parse::File.new(url)+.
115
+ attr_writer :max_remote_size
116
+ def max_remote_size
117
+ @max_remote_size ||= DEFAULT_MAX_REMOTE_SIZE
118
+ end
119
+
120
+ # @return [Integer] Read/open timeout (seconds) for remote URL fetches.
121
+ attr_writer :remote_timeout
122
+ def remote_timeout
123
+ @remote_timeout ||= DEFAULT_REMOTE_TIMEOUT
124
+ end
125
+
126
+ # @return [Array<String>] Optional host allowlist. When non-empty, only
127
+ # hostnames whose DNS resolution matches an entry are permitted as
128
+ # sources for remote URL fetches. Wildcards via leading "." (e.g.
129
+ # ".example.com" matches "files.example.com"). Default: empty (any
130
+ # public host is allowed; private hosts are always denied).
131
+ attr_writer :allowed_remote_hosts
132
+ def allowed_remote_hosts
133
+ @allowed_remote_hosts ||= []
134
+ end
135
+
136
+ # @return [Array<String>] Allowlist of HOSTS permitted in a `Parse::File`
137
+ # `url` field at hydration time. When set, any attempt to assign a
138
+ # `url` whose host is not on the list raises `Parse::File::UntrustedHostError`
139
+ # (or warns and clears when `untrusted_url_policy = :warn`). Defaults
140
+ # to:
141
+ #
142
+ # - `files.parsetfss.com` (legacy Parse hosted files)
143
+ # - Anything in `Parse::File.trusted_url_hosts`
144
+ # - Anything matching `parse_hosted_file?` (the `tfss-` filename
145
+ # prefix, which can ride on any host)
146
+ #
147
+ # Integrators with a CDN in front of Parse files add their CDN host:
148
+ # `Parse::File.trusted_url_hosts << "cdn.example.com"`. Wildcard
149
+ # entries via leading "." (e.g. `".cdn.example.com"`) match any
150
+ # subdomain.
151
+ attr_writer :trusted_url_hosts
152
+ def trusted_url_hosts
153
+ @trusted_url_hosts ||= ["files.parsetfss.com"]
154
+ end
155
+
156
+ # @return [Symbol] policy when a `Parse::File` is hydrated with a URL
157
+ # whose host is not in {trusted_url_hosts}. One of:
158
+ #
159
+ # - `:warn` (default) — emit a single warning per host and accept
160
+ # the URL anyway (preserves prior behavior; useful while
161
+ # populating the allowlist).
162
+ # - `:strip` — keep the file metadata but blank the `@url` so
163
+ # downstream renderers don't emit `<img src="…">` pointing at
164
+ # an attacker-controlled host.
165
+ # - `:raise` — refuse hydration with `UntrustedHostError`.
166
+ #
167
+ # The default is intentionally non-breaking; integrators ready to
168
+ # enforce flip the policy explicitly.
169
+ attr_writer :untrusted_url_policy
170
+ def untrusted_url_policy
171
+ @untrusted_url_policy ||= :warn
172
+ end
173
+
174
+ # @return [Array<Integer>] Allowed remote ports for URL fetches.
175
+ attr_writer :allowed_remote_ports
176
+ def allowed_remote_ports
177
+ @allowed_remote_ports ||= DEFAULT_ALLOWED_REMOTE_PORTS.dup
178
+ end
179
+
180
+ # @!visibility private
181
+ # Fetches a remote URL with strict SSRF defenses. Refuses non-HTTP
182
+ # schemes, RFC1918 / loopback / cloud-metadata addresses, oversized
183
+ # bodies, and slow upstreams. Returns the open-uri Tempfile/StringIO
184
+ # the caller can read from.
185
+ #
186
+ # DNS rebinding mitigation: the host is resolved twice — once before
187
+ # the fetch and once via +URI.open+'s underlying resolver. The
188
+ # second-pass addresses are re-validated against +BLOCKED_CIDRS+;
189
+ # any new private/internal IP causes an +ArgumentError+ at progress
190
+ # time so the body cannot be streamed back. (Caveat: this is a
191
+ # best-effort defense — the TCP +connect()+ uses a third resolution
192
+ # that we cannot intercept without a custom socket factory. Operators
193
+ # who need strict guarantees should also enforce egress allowlists
194
+ # at the network layer.)
195
+ # @raise [ArgumentError] on any disallowed input or unsafe target.
196
+ def safe_open_url(url_string)
197
+ uri = begin
198
+ URI.parse(url_string)
199
+ rescue URI::InvalidURIError => e
200
+ raise ArgumentError, "Invalid URL: #{e.message}"
201
+ end
202
+ unless %w[http https].include?(uri.scheme)
203
+ raise ArgumentError, "Parse::File only supports http(s) URLs (got #{uri.scheme.inspect})"
204
+ end
205
+ host = uri.host
206
+ raise ArgumentError, "URL missing host" if host.nil? || host.empty?
207
+ # Reject credentials embedded in the authority component — userinfo
208
+ # has no legitimate purpose for an SDK file fetch and confuses
209
+ # downstream code that inspects file.base_uri later.
210
+ if uri.userinfo
211
+ raise ArgumentError, "Parse::File URL must not include userinfo credentials"
212
+ end
213
+ # Port allowlist — refuses internal-port probing via DNS rebinding +
214
+ # SSH/Redis/Memcached banner exfiltration even if the host clears
215
+ # the CIDR check.
216
+ port = uri.port || (uri.scheme == "https" ? 443 : 80)
217
+ unless allowed_remote_ports.include?(port)
218
+ raise ArgumentError, "Port #{port} not in Parse::File.allowed_remote_ports"
219
+ end
220
+ resolved = assert_host_allowed!(host)
221
+
222
+ size_cap = max_remote_size
223
+ timeout = remote_timeout
224
+ URI.open(uri,
225
+ read_timeout: timeout,
226
+ open_timeout: timeout,
227
+ redirect: false,
228
+ content_length_proc: ->(len) {
229
+ if len && len > size_cap
230
+ raise ArgumentError, "Remote file exceeds Parse::File.max_remote_size (#{size_cap} bytes)"
231
+ end
232
+ # DNS-rebinding re-check: by the time content_length_proc
233
+ # fires, the connection has been established. Re-resolve
234
+ # and refuse if the host now points anywhere private.
235
+ assert_host_not_rebound!(host, resolved)
236
+ },
237
+ progress_proc: ->(transferred) {
238
+ if transferred > size_cap
239
+ raise ArgumentError, "Remote file exceeds Parse::File.max_remote_size (#{size_cap} bytes)"
240
+ end
241
+ })
242
+ end
243
+
244
+ # @!visibility private
245
+ # Validates that +host+ resolves only to public, non-blocked addresses.
246
+ # When +Parse::File.allowed_remote_hosts+ is non-empty, host must also
247
+ # match an allowlist entry.
248
+ # @return [Array<IPAddr>] the addresses that passed validation.
249
+ def assert_host_allowed!(host)
250
+ addrs = resolve_addresses(host)
251
+ if addrs.empty?
252
+ raise ArgumentError, "Could not resolve host #{host}"
253
+ end
254
+ addrs.each do |ip|
255
+ if BLOCKED_CIDRS.any? { |cidr| cidr.include?(ip) }
256
+ raise ArgumentError, "Refusing Parse::File fetch to private/internal address #{ip} for host #{host}"
257
+ end
258
+ end
259
+ unless allowed_remote_hosts.empty?
260
+ permitted = allowed_remote_hosts.any? do |allowed|
261
+ if allowed.start_with?(".")
262
+ host.downcase.end_with?(allowed.downcase) ||
263
+ host.casecmp(allowed[1..]).zero?
264
+ else
265
+ host.casecmp(allowed).zero?
266
+ end
267
+ end
268
+ unless permitted
269
+ raise ArgumentError, "Host #{host} not in Parse::File.allowed_remote_hosts"
270
+ end
271
+ end
272
+ addrs
273
+ end
274
+
275
+ # @!visibility private
276
+ # DNS rebinding re-check. Resolves +host+ again and refuses if any
277
+ # currently-resolved address is private or differs from the first
278
+ # resolution. Best-effort: kernel resolver caches and a third
279
+ # resolution at connect-time are out of scope.
280
+ def assert_host_not_rebound!(host, prior_addrs)
281
+ return if prior_addrs.nil? || prior_addrs.empty?
282
+ current = resolve_addresses(host)
283
+ current.each do |ip|
284
+ if BLOCKED_CIDRS.any? { |cidr| cidr.include?(ip) }
285
+ raise ArgumentError, "DNS rebinding detected — host #{host} now resolves to private address #{ip}"
286
+ end
287
+ end
288
+ end
289
+
290
+ # @!visibility private
291
+ def resolve_addresses(host)
292
+ # Already an IP literal?
293
+ IPAddr.new(host)
294
+ [IPAddr.new(host)]
295
+ rescue IPAddr::InvalidAddressError
296
+ begin
297
+ Resolv.getaddresses(host).map { |a|
298
+ begin
299
+ IPAddr.new(a)
300
+ rescue StandardError
301
+ nil
302
+ end
303
+ }.compact
304
+ rescue Resolv::ResolvError, Resolv::ResolvTimeout
305
+ []
306
+ end
307
+ end
308
+ end
309
+ # The initializer to create a new file supports different inputs.
310
+ # If the first paramter is a string which starts with 'http', we then download
311
+ # the content of the file (and use the detected mime-type) to set the content and mime_type fields.
312
+ # If the first parameter is a hash, we assume it might be the Parse File hash format which contains url and name fields only.
313
+ # If the first paramter is a Parse::File, then we copy fields over
314
+ # Otherwise, creating a new file requires a name, the actual contents (usually from a File.open("local.jpg").read ) and the mime-type
315
+ # @param name [String]
316
+ # @param contents [Object]
317
+ # @param mime_type [String] Default see default_mime_type
318
+ def initialize(name, contents = nil, mime_type = nil)
319
+ mime_type ||= Parse::File.default_mime_type
320
+
321
+ if name.is_a?(String) && name.start_with?("http") #could be url string
322
+ file = Parse::File.safe_open_url(name)
323
+ @contents = file.read
324
+ @name = File.basename file.base_uri.to_s
325
+ @mime_type = file.content_type
326
+ elsif name.is_a?(Hash)
327
+ self.attributes = name
328
+ elsif name.is_a?(::File)
329
+ @contents = contents || name.read
330
+ @name = File.basename name.to_path
331
+ elsif name.is_a?(Parse::File)
332
+ @name = name.name
333
+ @url = name.url
334
+ else
335
+ @name = name
336
+ @contents = contents
337
+ end
338
+ if @name.blank?
339
+ raise ArgumentError, "Invalid Parse::File initialization with name '#{@name}'"
340
+ end
341
+
342
+ @mime_type ||= mime_type
343
+ end
344
+
345
+ # This creates a new Parse File Object with from a URL, saves it and returns it
346
+ # @param url [String] A url which will be used to create the file and automatically save it.
347
+ # @return [Parse::File] A newly saved file based on contents of _url_
348
+ def self.create(url)
349
+ url = url.url if url.is_a?(Parse::File)
350
+ file = self.new(url)
351
+ file.save
352
+ file
353
+ end
354
+
355
+ # A File object is considered saved if the basename of the URL and the name parameters are equal
356
+ # @return [Boolean] true if this file has already been saved.
357
+ def saved?
358
+ @url.present? && @name.present? && @name == File.basename(@url)
359
+ end
360
+
361
+ # Returns the url string for this Parse::File pointer. If the *force_ssl* option is
362
+ # set to true, it will make sure it returns a secure url.
363
+ # @return [String] the url string for the file.
364
+ def url
365
+ if @url.present? && Parse::File.force_ssl && @url.starts_with?("http://")
366
+ return @url.sub("http://", "https://")
367
+ end
368
+ @url
369
+ end
370
+
371
+ # @return [Hash]
372
+ def attributes
373
+ ATTRIBUTES
374
+ end
375
+
376
+ # @return [Boolean] Two files are equal if they have the same url
377
+ def ==(u)
378
+ return false unless u.is_a?(self.class)
379
+ @url == u.url
380
+ end
381
+
382
+ # Allows mass assignment from a Parse JSON hash.
383
+ def attributes=(h)
384
+ raw_url = nil
385
+ if h.is_a?(String)
386
+ raw_url = h
387
+ @name = File.basename(h)
388
+ elsif h.is_a?(Hash)
389
+ raw_url = h[FIELD_URL] || h[:url]
390
+ @name = h[FIELD_NAME] || h[:name] || @name
391
+ end
392
+ @url = Parse::File.sanitize_hydrated_url(raw_url, fallback: @url, name: @name)
393
+ end
394
+
395
+ # @!visibility private
396
+ # Apply {trusted_url_hosts} / {untrusted_url_policy} to a URL coming
397
+ # in from a Parse JSON hydration. Returns the URL to assign to
398
+ # `@url`, which may be:
399
+ #
400
+ # - `raw` itself when the host is trusted (or `parse_hosted_file?`
401
+ # matches via the `tfss-` filename prefix, which can ride on any
402
+ # host),
403
+ # - `fallback` when policy is `:strip`,
404
+ # - raises {UntrustedHostError} when policy is `:raise`.
405
+ #
406
+ # On `:warn`, the URL is accepted but a single warning per host is
407
+ # emitted (deduplicated process-wide). Empty / non-string / non-http
408
+ # values pass through unchanged so callers can clear the field.
409
+ def self.sanitize_hydrated_url(raw, fallback: nil, name: nil)
410
+ return raw if raw.nil?
411
+ return raw unless raw.is_a?(String) && !raw.empty?
412
+ return raw unless raw.start_with?("http://") || raw.start_with?("https://")
413
+
414
+ uri = begin
415
+ URI.parse(raw)
416
+ rescue URI::InvalidURIError
417
+ return raw # malformed URL — leave it alone; downstream code already handles
418
+ end
419
+ host = uri.host.to_s.downcase
420
+ return raw if host.empty?
421
+
422
+ # tfss-prefixed filenames can be served from arbitrary hosts (the
423
+ # legacy hosted-files contract). Accept those regardless of host.
424
+ basename = name || File.basename(raw)
425
+ return raw if basename.to_s.start_with?("tfss-")
426
+
427
+ return raw if trusted_url_host?(host)
428
+
429
+ case untrusted_url_policy
430
+ when :raise
431
+ raise UntrustedHostError,
432
+ "Parse::File URL host #{host.inspect} is not in Parse::File.trusted_url_hosts. " \
433
+ "Add the host to the allowlist or change the policy to :warn/:strip."
434
+ when :strip
435
+ warn_untrusted_url_host_once(host, action: "stripped")
436
+ fallback
437
+ else # :warn (default)
438
+ warn_untrusted_url_host_once(host, action: "accepted")
439
+ raw
440
+ end
441
+ end
442
+
443
+ # @!visibility private
444
+ def self.trusted_url_host?(host)
445
+ trusted_url_hosts.any? do |entry|
446
+ e = entry.to_s.downcase
447
+ next false if e.empty?
448
+ if e.start_with?(".")
449
+ host == e[1..] || host.end_with?(e)
450
+ else
451
+ host == e
452
+ end
453
+ end
454
+ end
455
+
456
+ # @!visibility private
457
+ def self.warn_untrusted_url_host_once(host, action:)
458
+ @warned_untrusted_hosts ||= {}
459
+ return if @warned_untrusted_hosts[host]
460
+ @warned_untrusted_hosts[host] = true
461
+ warn "[Parse::File:SECURITY] Untrusted URL host #{host.inspect} " \
462
+ "(#{action}). Add the host to Parse::File.trusted_url_hosts " \
463
+ "to silence this warning. Untrusted hosts in file URL fields " \
464
+ "enable stored phishing, SVG XSS, and open-redirect via " \
465
+ "<img src='…'> rendering."
466
+ end
467
+
468
+ # A proxy method for ::File.basename
469
+ # @param file_name [String]
470
+ # @param suffix [String]
471
+ # @return [String] File.basename(file_name)
472
+ # @see ::File.basename
473
+ def self.basename(file_name, suffix = nil)
474
+ if suffix.nil?
475
+ ::File.basename(file_name)
476
+ else
477
+ ::File.basename(file_name, suffix)
478
+ end
479
+ end
480
+
481
+ # Save the file by uploading it to Parse and creating a file pointer.
482
+ # @return [Boolean] true if successfully uploaded and saved.
483
+ def save
484
+ unless saved? || @contents.nil? || @name.nil?
485
+ response = client.create_file(@name, @contents, @mime_type)
486
+ unless response.error?
487
+ result = response.result
488
+ @name = result[FIELD_NAME] || File.basename(result[FIELD_URL])
489
+ @url = result[FIELD_URL]
490
+ end
491
+ end
492
+ saved?
493
+ end
494
+
495
+ # @return [Boolean] true if this file is hosted by Parse's servers.
496
+ def parse_hosted_file?
497
+ return false if @url.blank?
498
+ ::File.basename(@url).starts_with?("tfss-") || @url.starts_with?("http://files.parsetfss.com")
499
+ end
500
+
501
+ # @!visibility private
502
+ def inspect
503
+ "<Parse::File @name='#{@name}' @mime_type='#{@mime_type}' @contents=#{@contents.nil?} @url='#{@url}'>"
504
+ end
505
+
506
+ # @return [String] the url
507
+ # @see #url
508
+ def to_s
509
+ @url
510
+ end
511
+ end
512
+ end
513
+
514
+ # Adds extensions to Hash class.
515
+ class Hash
516
+ # Determines if the hash contains Parse File json metadata fields. This is determined whether
517
+ # the key `__type` exists and is of type `__File` and whether the `name` field matches the File.basename
518
+ # of the `url` field.
519
+ #
520
+ # @return [Boolean] True if this hash contains Parse file metadata.
521
+ def parse_file?
522
+ url = self[Parse::File::FIELD_URL]
523
+ name = self[Parse::File::FIELD_NAME]
524
+ (count == 2 || self["__type"] == Parse::File.parse_class) &&
525
+ url.present? && name.present? && name == ::File.basename(url)
526
+ end
527
+ end