ruby_native 0.11.2 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/app/controllers/ruby_native/auth/start_controller.rb +7 -1
- data/app/controllers/ruby_native/iap/restores_controller.rb +70 -21
- data/lib/generators/ruby_native/iap_generator.rb +17 -2
- data/lib/generators/ruby_native/install_generator.rb +1 -0
- data/lib/generators/ruby_native/templates/add_restored_transaction_id_to_ruby_native_purchase_intents.rb +5 -0
- data/lib/ruby_native/cli/deploy.rb +8 -3
- data/lib/ruby_native/cli/login.rb +10 -5
- data/lib/ruby_native/cli/preview.rb +28 -19
- data/lib/ruby_native/helper.rb +58 -3
- data/lib/ruby_native/oauth_middleware.rb +24 -2
- data/lib/ruby_native/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b7e336fc49236887320d23ecfc7718229370029d7a2b7981df2e888fd427e853
|
|
4
|
+
data.tar.gz: 16ca5ee3894370636c36193591a135c3829c3a9bb1449065994e04fc09be6b47
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8039052ae6fe99f9a429564c534b9b78b1df59c5cfa9a4b0157dd3b606244d609666b360242ff25b2a9931511262bb933f0e1b668c6570ade89413dd4c058009
|
|
7
|
+
data.tar.gz: 5b3b966610654c7f0625b2ffdb4aa42f4cb0ecccf773f06a2795faea962b6e54db0324d246959a3436083c907e1cc6b8f208275718fb598ceef74f4d42e04afd
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ruby_native
|
|
2
2
|
|
|
3
|
-
[Ruby Native](https://rubynative.com) turns a Rails app into
|
|
3
|
+
[Ruby Native](https://rubynative.com) turns a Rails app into iOS and Android apps. This gem is the Rails-side integration: view helpers, a YAML config file, endpoints auto-mounted at `/native`, and a CLI for previewing and deploying builds. Full docs, including the complete helper reference and config schema, live at [rubynative.com/docs](https://rubynative.com/docs).
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -9,9 +9,15 @@ module RubyNative
|
|
|
9
9
|
return
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
+
@callback_scheme = params[:callback_scheme].to_s
|
|
13
|
+
|
|
14
|
+
unless @callback_scheme.match?(OAuthMiddleware::CALLBACK_SCHEME)
|
|
15
|
+
head :bad_request
|
|
16
|
+
return
|
|
17
|
+
end
|
|
18
|
+
|
|
12
19
|
oauth_paths = RubyNative.config&.dig(:auth, :oauth_paths) || []
|
|
13
20
|
@oauth_path = oauth_paths.find { |p| p.end_with?(@provider) } || "/auth/#{@provider}"
|
|
14
|
-
@callback_scheme = params[:callback_scheme]
|
|
15
21
|
end
|
|
16
22
|
end
|
|
17
23
|
end
|
|
@@ -7,37 +7,86 @@ module RubyNative
|
|
|
7
7
|
skip_forgery_protection
|
|
8
8
|
|
|
9
9
|
def create
|
|
10
|
-
|
|
11
|
-
return head :bad_request if customer_id.blank?
|
|
10
|
+
return head :bad_request if params[:customer_id].blank?
|
|
12
11
|
|
|
13
12
|
transactions = Array(params[:signed_transactions])
|
|
14
13
|
return head :bad_request if transactions.empty?
|
|
15
14
|
|
|
16
|
-
transactions.each
|
|
17
|
-
transaction = decode_and_verify_jws(signed_transaction)
|
|
18
|
-
|
|
19
|
-
event = Event.new(
|
|
20
|
-
type: "subscription.created",
|
|
21
|
-
status: "active",
|
|
22
|
-
owner_token: customer_id,
|
|
23
|
-
product_id: transaction["productId"],
|
|
24
|
-
original_transaction_id: transaction["originalTransactionId"],
|
|
25
|
-
transaction_id: transaction["transactionId"],
|
|
26
|
-
purchase_date: parse_timestamp(transaction["purchaseDate"]),
|
|
27
|
-
expires_date: parse_timestamp(transaction["expiresDate"]),
|
|
28
|
-
environment: transaction["environment"]&.downcase,
|
|
29
|
-
notification_uuid: SecureRandom.uuid,
|
|
30
|
-
success_path: params[:success_path]
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
RubyNative.fire_subscription_callbacks(event)
|
|
34
|
-
end
|
|
15
|
+
transactions.each { |signed_transaction| restore(decode_and_verify_jws(signed_transaction)) }
|
|
35
16
|
|
|
36
17
|
head :ok
|
|
37
18
|
rescue VerificationError, JWT::DecodeError => e
|
|
38
19
|
Rails.logger.warn "[RubyNative] Restore verification failed: #{e.message}"
|
|
39
20
|
head :unprocessable_entity
|
|
40
21
|
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# The account being restored to comes from the purchase intent the
|
|
26
|
+
# transaction was made against, never from `customer_id` in the request. A
|
|
27
|
+
# signed transaction is only proof that Apple sold it, not proof of who is
|
|
28
|
+
# asking, so honoring a caller-supplied id let one purchased subscription
|
|
29
|
+
# be replayed to grant entitlement to any number of accounts.
|
|
30
|
+
def restore(transaction)
|
|
31
|
+
transaction_id = transaction["transactionId"]
|
|
32
|
+
intent = PurchaseIntent.find_by(uuid: transaction["appAccountToken"])
|
|
33
|
+
|
|
34
|
+
unless intent
|
|
35
|
+
Rails.logger.warn "[RubyNative] Ignoring restored transaction #{transaction_id.inspect}: " \
|
|
36
|
+
"no purchase intent matches its appAccountToken"
|
|
37
|
+
return
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
if params[:customer_id] != intent.customer_id
|
|
41
|
+
Rails.logger.warn "[RubyNative] Restoring transaction #{transaction_id.inspect} to " \
|
|
42
|
+
"#{intent.customer_id.inspect} from its purchase intent, not the requested " \
|
|
43
|
+
"#{params[:customer_id].inspect}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Apple reissues a transaction id on every renewal, so a restore of the
|
|
47
|
+
# same one is a repeat and must not fire the callback again.
|
|
48
|
+
return if dedup_available? && intent.restored_transaction_id == transaction_id
|
|
49
|
+
|
|
50
|
+
intent.update!(intent_attributes(transaction))
|
|
51
|
+
|
|
52
|
+
event = Event.new(
|
|
53
|
+
type: "subscription.created",
|
|
54
|
+
status: "active",
|
|
55
|
+
owner_token: intent.customer_id,
|
|
56
|
+
product_id: transaction["productId"],
|
|
57
|
+
original_transaction_id: transaction["originalTransactionId"],
|
|
58
|
+
transaction_id: transaction_id,
|
|
59
|
+
purchase_date: parse_timestamp(transaction["purchaseDate"]),
|
|
60
|
+
expires_date: parse_timestamp(transaction["expiresDate"]),
|
|
61
|
+
environment: transaction["environment"]&.downcase,
|
|
62
|
+
notification_uuid: SecureRandom.uuid,
|
|
63
|
+
success_path: params[:success_path]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
RubyNative.fire_subscription_callbacks(event)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A restore is the recovery path for a purchase whose completion POST never
|
|
70
|
+
# landed, so it settles the intent too. An unrecognized environment is
|
|
71
|
+
# dropped rather than raising on the enum.
|
|
72
|
+
def intent_attributes(transaction)
|
|
73
|
+
attributes = {status: :completed}
|
|
74
|
+
environment = transaction["environment"]&.downcase
|
|
75
|
+
attributes[:environment] = environment if PurchaseIntent.environments.key?(environment)
|
|
76
|
+
attributes[:restored_transaction_id] = transaction["transactionId"] if dedup_available?
|
|
77
|
+
attributes
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Deduping needs a column that arrives in a migration, and an app can be on
|
|
81
|
+
# this version of the gem before it has been run. Restoring to the right
|
|
82
|
+
# account is the part that matters and needs no column, so a missing one
|
|
83
|
+
# costs the repeat-callback guard and nothing else -- which is how restore
|
|
84
|
+
# behaved before the column existed anyway. Writing to it regardless would
|
|
85
|
+
# raise, and the app reports a restore as successful without reading the
|
|
86
|
+
# response, so the customer would be told "Restored!" over a 500.
|
|
87
|
+
def dedup_available?
|
|
88
|
+
PurchaseIntent.column_names.include?("restored_transaction_id")
|
|
89
|
+
end
|
|
41
90
|
end
|
|
42
91
|
end
|
|
43
92
|
end
|
|
@@ -8,13 +8,28 @@ module RubyNative
|
|
|
8
8
|
|
|
9
9
|
source_root File.expand_path("templates", __dir__)
|
|
10
10
|
|
|
11
|
+
# Must increment within a single run, or the second migration below collides
|
|
12
|
+
# with the first on a bare `Time.now` timestamp.
|
|
11
13
|
def self.next_migration_number(dirname)
|
|
12
|
-
|
|
14
|
+
ActiveRecord::Migration.next_migration_number(current_migration_number(dirname) + 1)
|
|
13
15
|
end
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
# Re-running this generator on an app that already has the first migration
|
|
18
|
+
# reports it identical and creates only what is missing, so it doubles as
|
|
19
|
+
# the upgrade path.
|
|
20
|
+
#
|
|
21
|
+
# That rests on `create_ruby_native_purchase_intents.rb` never changing.
|
|
22
|
+
# Rails compares the template against the copy the app already has, and
|
|
23
|
+
# only calls it identical when they match byte for byte; edit one character
|
|
24
|
+
# and it reports a conflict instead, which raises and aborts the run before
|
|
25
|
+
# the migrations added after it are copied. So an app upgrading would get
|
|
26
|
+
# none of them. Add a new template alongside it rather than editing it --
|
|
27
|
+
# `iap_generator_test.rb` fails if that file is touched.
|
|
28
|
+
def copy_migrations
|
|
16
29
|
migration_template "create_ruby_native_purchase_intents.rb",
|
|
17
30
|
"db/migrate/create_ruby_native_purchase_intents.rb"
|
|
31
|
+
migration_template "add_restored_transaction_id_to_ruby_native_purchase_intents.rb",
|
|
32
|
+
"db/migrate/add_restored_transaction_id_to_ruby_native_purchase_intents.rb"
|
|
18
33
|
end
|
|
19
34
|
|
|
20
35
|
def print_next_steps
|
|
@@ -40,6 +40,7 @@ module RubyNative
|
|
|
40
40
|
say " <meta name=\"viewport\" content=\"width=device-width,initial-scale=1,viewport-fit=cover\">"
|
|
41
41
|
say " 4. Add to your layout <body>:"
|
|
42
42
|
say " <%= native_tabs_tag %>"
|
|
43
|
+
say " <%= native_identity_tag(current_user&.id) %>"
|
|
43
44
|
say " 5. Preview on your device:"
|
|
44
45
|
say " bundle exec ruby_native preview"
|
|
45
46
|
say ""
|
|
@@ -79,17 +79,22 @@ module RubyNative
|
|
|
79
79
|
end
|
|
80
80
|
|
|
81
81
|
def fetch_latest_build(app_id)
|
|
82
|
-
|
|
82
|
+
# Platform-scoped, or --android --if-needed compares against the
|
|
83
|
+
# latest iOS build and skips a build Android never got.
|
|
84
|
+
uri = URI("#{HOST}/api/v1/apps/#{app_id}/builds/latest?platform=#{@platform}")
|
|
83
85
|
req = Net::HTTP::Get.new(uri)
|
|
84
86
|
req["Authorization"] = "Token #{Credentials.token}"
|
|
85
87
|
|
|
86
88
|
response = make_request(uri, req)
|
|
87
89
|
|
|
88
90
|
case response
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
# Before HTTPSuccess, its superclass: a 204 has no body to parse, and
|
|
92
|
+
# matching Success first crashed --if-needed before an app's first
|
|
93
|
+
# successful build.
|
|
91
94
|
when Net::HTTPNoContent
|
|
92
95
|
nil
|
|
96
|
+
when Net::HTTPSuccess
|
|
97
|
+
JSON.parse(response.body)
|
|
93
98
|
when Net::HTTPUnauthorized
|
|
94
99
|
raise TokenExpiredError
|
|
95
100
|
else
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require "digest"
|
|
1
2
|
require "securerandom"
|
|
2
3
|
require "net/http"
|
|
3
4
|
require "uri"
|
|
@@ -13,14 +14,18 @@ module RubyNative
|
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def run
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
# Only the hash goes through the browser. Claiming the token needs this
|
|
18
|
+
# verifier, which never leaves the machine, so a copy of the authorize
|
|
19
|
+
# URL is not enough for anyone else to collect the session.
|
|
20
|
+
verifier = SecureRandom.hex(32)
|
|
21
|
+
challenge = Digest::SHA256.hexdigest(verifier)
|
|
22
|
+
url = "#{HOST}/cli/session/new?challenge=#{challenge}"
|
|
18
23
|
|
|
19
24
|
puts "Opening browser to authorize..."
|
|
20
25
|
open_browser(url)
|
|
21
26
|
puts "Waiting for authorization..."
|
|
22
27
|
|
|
23
|
-
token = poll_for_token(
|
|
28
|
+
token = poll_for_token(verifier)
|
|
24
29
|
|
|
25
30
|
if token
|
|
26
31
|
Credentials.save(token)
|
|
@@ -44,8 +49,8 @@ module RubyNative
|
|
|
44
49
|
end
|
|
45
50
|
end
|
|
46
51
|
|
|
47
|
-
def poll_for_token(
|
|
48
|
-
uri = URI("#{HOST}/cli/session/poll?
|
|
52
|
+
def poll_for_token(verifier)
|
|
53
|
+
uri = URI("#{HOST}/cli/session/poll?verifier=#{verifier}")
|
|
49
54
|
attempts = 0
|
|
50
55
|
max_attempts = 60
|
|
51
56
|
|
|
@@ -34,8 +34,22 @@ module RubyNative
|
|
|
34
34
|
|
|
35
35
|
puts "Rails server is reachable at #{@upstream}, but #{CONFIG_PATH} returned #{response.code}."
|
|
36
36
|
puts ""
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
|
|
38
|
+
# 404 is what an unmounted engine returns; any other code means the app
|
|
39
|
+
# answered, so the mount is not the thing to go re-verify.
|
|
40
|
+
case response
|
|
41
|
+
when Net::HTTPNotFound
|
|
42
|
+
puts "Make sure the ruby_native gem is installed and mounted:"
|
|
43
|
+
puts " https://rubynative.com/docs/setup"
|
|
44
|
+
when Net::HTTPRedirection
|
|
45
|
+
puts "Something redirected the request to #{response["location"]}."
|
|
46
|
+
puts "Look for a force_ssl setting or a before_action that catches this path."
|
|
47
|
+
else
|
|
48
|
+
puts "Your Rails app returned an error. Open #{@upstream} in a browser and fix"
|
|
49
|
+
puts "what it reports, then try again. Pending migrations are a common cause:"
|
|
50
|
+
puts ""
|
|
51
|
+
puts " bin/rails db:migrate"
|
|
52
|
+
end
|
|
39
53
|
exit 1
|
|
40
54
|
rescue Errno::ECONNREFUSED
|
|
41
55
|
if @url
|
|
@@ -165,25 +179,20 @@ module RubyNative
|
|
|
165
179
|
require "rqrcode"
|
|
166
180
|
|
|
167
181
|
qr = RQRCode::QRCode.new(url, level: :l)
|
|
168
|
-
modules = qr.modules
|
|
169
|
-
size = modules.length
|
|
170
|
-
quiet_h = 4
|
|
171
|
-
quiet_v = 2
|
|
172
|
-
|
|
173
|
-
dark = "██"
|
|
174
|
-
light = " "
|
|
175
182
|
|
|
183
|
+
# Painted as background colors, not block glyphs: a glyph takes the terminal's
|
|
184
|
+
# foreground color, which prints the code inverted on a dark theme, and Android
|
|
185
|
+
# (unlike iOS) will not decode an inverted code. Truecolor rather than any
|
|
186
|
+
# palette index, because terminals remap both the 16 basic colors and the 256
|
|
187
|
+
# cube, which is how "white" came out as a mid-gray in one terminal and as
|
|
188
|
+
# orange in another. 24-bit RGB is a literal value with no lookup, so the
|
|
189
|
+
# contrast and the polarity are the same everywhere.
|
|
176
190
|
puts ""
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
inside = mr >= 0 && mr < size && mc >= 0 && mc < size
|
|
183
|
-
line << (inside && modules[mr][mc] ? dark : light)
|
|
184
|
-
end
|
|
185
|
-
puts line
|
|
186
|
-
end
|
|
191
|
+
print qr.as_ansi(
|
|
192
|
+
light: "\e[48;2;255;255;255m",
|
|
193
|
+
dark: "\e[48;2;0;0;0m",
|
|
194
|
+
quiet_zone_size: 4
|
|
195
|
+
)
|
|
187
196
|
puts ""
|
|
188
197
|
puts url
|
|
189
198
|
puts ""
|
data/lib/ruby_native/helper.rb
CHANGED
|
@@ -26,6 +26,18 @@ module RubyNative
|
|
|
26
26
|
tag.div(data: { native_push: true }, hidden: true)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
def native_identity_tag(value)
|
|
30
|
+
tag.div(data: { native_identity: native_identity_token(value) }, hidden: true)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# HMAC, not a bare digest: a digest of a small integer id space is enumerable,
|
|
34
|
+
# so the DOM would effectively still carry the id it was meant to hide.
|
|
35
|
+
def native_identity_token(value)
|
|
36
|
+
parts = Array(value).compact
|
|
37
|
+
return "" if parts.empty?
|
|
38
|
+
OpenSSL::HMAC.hexdigest("SHA256", Rails.application.secret_key_base, parts.join(":"))[0, 16]
|
|
39
|
+
end
|
|
40
|
+
|
|
29
41
|
def native_back_button_tag(text = nil, **options)
|
|
30
42
|
options[:class] = [options[:class], "native-back-button"].compact.join(" ")
|
|
31
43
|
default_content = tag.svg(
|
|
@@ -209,6 +221,48 @@ module RubyNative
|
|
|
209
221
|
tag.div(data: { native_review: true }, hidden: true)
|
|
210
222
|
end
|
|
211
223
|
|
|
224
|
+
TOAST_APPEARANCES = %w[inverted system].freeze
|
|
225
|
+
|
|
226
|
+
# Shows a transient native toast above all app chrome, including the nav
|
|
227
|
+
# bar. The signal is consumed the moment it fires, so a cached page
|
|
228
|
+
# restored by back navigation cannot re-toast. Renders nothing visible on
|
|
229
|
+
# the web; keep your web-styled flash for browsers.
|
|
230
|
+
#
|
|
231
|
+
# <%= native_toast_tag flash[:notice] %>
|
|
232
|
+
#
|
|
233
|
+
# A blank message renders nothing, so the flash one-liner needs no guard.
|
|
234
|
+
# Icons follow the same `icon:`/`icons:` rules as the navbar; the default
|
|
235
|
+
# is a per-platform checkmark and `icon: false` hides it. `appearance:` is
|
|
236
|
+
# :inverted (flips the theme so the toast always pops) or :system (matches
|
|
237
|
+
# it).
|
|
238
|
+
def native_toast_tag(message, icon: nil, icons: nil, duration: 4, appearance: :inverted)
|
|
239
|
+
return "".html_safe if message.blank?
|
|
240
|
+
|
|
241
|
+
appearance = appearance.to_s
|
|
242
|
+
unless TOAST_APPEARANCES.include?(appearance)
|
|
243
|
+
raise ArgumentError,
|
|
244
|
+
"appearance must be #{TOAST_APPEARANCES.map { |a| ":#{a}" }.join(" or ")}, " \
|
|
245
|
+
"got #{appearance.inspect}"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# An absent icon attribute means "use the default" to the shell (the JS
|
|
249
|
+
# API omits it), so `icon: false` emits an empty value to mean "none".
|
|
250
|
+
platform = try(:native_platform)
|
|
251
|
+
resolved = if icon == false
|
|
252
|
+
""
|
|
253
|
+
else
|
|
254
|
+
RubyNative::Helper.resolve_icon(icon: icon, icons: icons, platform: platform) ||
|
|
255
|
+
(platform == "android" ? "check_circle" : "checkmark.circle.fill")
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
data = { native_toast: "", native_toast_message: message }
|
|
259
|
+
data[:native_toast_icon] = resolved
|
|
260
|
+
data[:native_toast_duration] = duration
|
|
261
|
+
data[:native_toast_appearance] = appearance
|
|
262
|
+
|
|
263
|
+
tag.div(data: data, hidden: true)
|
|
264
|
+
end
|
|
265
|
+
|
|
212
266
|
# Picks the right icon name for the current native platform. Accepts the
|
|
213
267
|
# single `icon:` form (applied to every platform) and/or the `icons:` hash
|
|
214
268
|
# form (`{ ios: "...", android: "..." }`). When both are given, a matching
|
|
@@ -295,8 +349,9 @@ module RubyNative
|
|
|
295
349
|
# web side) so it works even where embedded web views don't support
|
|
296
350
|
# `navigator.share`. Icons follow the same rules as the other navbar
|
|
297
351
|
# buttons: `icon:` applies to every platform and `icons:` ({ ios:,
|
|
298
|
-
# android: }) overrides per platform.
|
|
299
|
-
|
|
352
|
+
# android: }) overrides per platform. Android defaults to the Material
|
|
353
|
+
# `share` glyph; the SF Symbol default renders as missing there.
|
|
354
|
+
def share_button(url: nil, title: "Share", icon: "square.and.arrow.up", icons: { android: "share" }, position: :trailing)
|
|
300
355
|
resolved = RubyNative::Helper.resolve_icon(icon: icon, icons: icons, platform: @context.try(:native_platform))
|
|
301
356
|
data = { native_button: "", native_share: "" }
|
|
302
357
|
data[:native_title] = title if title
|
|
@@ -352,7 +407,7 @@ module RubyNative
|
|
|
352
407
|
# opens the platform share sheet for `url:` (defaults to the current
|
|
353
408
|
# page on the web side). Icons follow the same `icon:`/`icons:` rules
|
|
354
409
|
# as the other menu items.
|
|
355
|
-
def share_item(title = "Share", url: nil, icon: "square.and.arrow.up", icons:
|
|
410
|
+
def share_item(title = "Share", url: nil, icon: "square.and.arrow.up", icons: { android: "share" }, selected: false)
|
|
356
411
|
resolved = RubyNative::Helper.resolve_icon(icon: icon, icons: icons, platform: @context.try(:native_platform))
|
|
357
412
|
data = { native_menu_item: "", native_title: title, native_share: "" }
|
|
358
413
|
data[:native_share_url] = url if url
|
|
@@ -2,6 +2,14 @@ module RubyNative
|
|
|
2
2
|
class OAuthMiddleware
|
|
3
3
|
COOKIE_NAME = "_ruby_native_oauth"
|
|
4
4
|
|
|
5
|
+
# The scheme the native app registers is always "rubynative-" plus its bundle
|
|
6
|
+
# identifier with dots as dashes, built by OAuthManager.callbackScheme on both
|
|
7
|
+
# platforms. The scheme arrives as a request param and ends up as the target of
|
|
8
|
+
# the redirect carrying the session token, so anything that could name a
|
|
9
|
+
# different origin -- a colon, slash, dot, "@", or percent escape -- is
|
|
10
|
+
# rejected outright rather than sanitized.
|
|
11
|
+
CALLBACK_SCHEME = /\Arubynative-[a-z0-9][a-z0-9_-]{0,127}\z/i
|
|
12
|
+
|
|
5
13
|
def initialize(app)
|
|
6
14
|
@app = app
|
|
7
15
|
end
|
|
@@ -10,7 +18,7 @@ module RubyNative
|
|
|
10
18
|
request = ActionDispatch::Request.new(env)
|
|
11
19
|
on_oauth_path = oauth_path?(request)
|
|
12
20
|
started_native_oauth = on_oauth_path && request.params["ruby_native"] == "1"
|
|
13
|
-
callback_scheme = request.params["callback_scheme"] if started_native_oauth
|
|
21
|
+
callback_scheme = permitted_scheme(request.params["callback_scheme"]) if started_native_oauth
|
|
14
22
|
|
|
15
23
|
status, headers, body = @app.call(env)
|
|
16
24
|
|
|
@@ -23,7 +31,7 @@ module RubyNative
|
|
|
23
31
|
set_cookie(headers, callback_scheme)
|
|
24
32
|
end
|
|
25
33
|
|
|
26
|
-
stored_scheme = read_cookie(request)
|
|
34
|
+
stored_scheme = permitted_scheme(read_cookie(request))
|
|
27
35
|
|
|
28
36
|
if stored_scheme && redirect?(status)
|
|
29
37
|
location = headers["location"] || headers["Location"]
|
|
@@ -70,6 +78,20 @@ module RubyNative
|
|
|
70
78
|
|
|
71
79
|
private
|
|
72
80
|
|
|
81
|
+
# Returns the scheme only when it matches CALLBACK_SCHEME. A rejected scheme
|
|
82
|
+
# reads as "no native app asked for this", so the flow falls through as an
|
|
83
|
+
# ordinary web sign-in and no token is ever minted.
|
|
84
|
+
def permitted_scheme(value)
|
|
85
|
+
return nil if value.blank?
|
|
86
|
+
return value if value.match?(CALLBACK_SCHEME)
|
|
87
|
+
|
|
88
|
+
Rails.logger.warn do
|
|
89
|
+
"[RubyNative] Rejected OAuth callback_scheme #{value.to_s.truncate(64).inspect}: " \
|
|
90
|
+
"expected the app's own rubynative-<bundle-id> scheme"
|
|
91
|
+
end
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
73
95
|
def oauth_path?(request)
|
|
74
96
|
oauth_paths.any? { |p| request.path == p }
|
|
75
97
|
end
|
data/lib/ruby_native/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_native
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.12.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Joe Masilotti
|
|
@@ -86,6 +86,7 @@ files:
|
|
|
86
86
|
- exe/ruby_native
|
|
87
87
|
- lib/generators/ruby_native/iap_generator.rb
|
|
88
88
|
- lib/generators/ruby_native/install_generator.rb
|
|
89
|
+
- lib/generators/ruby_native/templates/add_restored_transaction_id_to_ruby_native_purchase_intents.rb
|
|
89
90
|
- lib/generators/ruby_native/templates/create_ruby_native_purchase_intents.rb
|
|
90
91
|
- lib/generators/ruby_native/templates/ruby_native.yml
|
|
91
92
|
- lib/ruby_native.rb
|