linkio 1.0.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 +7 -0
- data/CHANGELOG.md +47 -0
- data/LICENSE +21 -0
- data/README.md +295 -0
- data/app/controllers/linkio/application_controller.rb +50 -0
- data/app/controllers/linkio/deep_links_controller.rb +11 -0
- data/app/controllers/linkio/pending_links_controller.rb +33 -0
- data/app/controllers/linkio/referrals_controller.rb +23 -0
- data/app/controllers/linkio/well_known_controller.rb +16 -0
- data/config/routes.rb +18 -0
- data/lib/generators/linkio/install/install_generator.rb +29 -0
- data/lib/generators/linkio/install/templates/linkio.rb +63 -0
- data/lib/linkio/app_target.rb +79 -0
- data/lib/linkio/client.rb +213 -0
- data/lib/linkio/configuration.rb +152 -0
- data/lib/linkio/deep_link_data.rb +30 -0
- data/lib/linkio/engine.rb +24 -0
- data/lib/linkio/errors.rb +12 -0
- data/lib/linkio/pending_link_data.rb +57 -0
- data/lib/linkio/platform.rb +31 -0
- data/lib/linkio/rack/middleware.rb +105 -0
- data/lib/linkio/referral_data.rb +48 -0
- data/lib/linkio/request.rb +76 -0
- data/lib/linkio/result.rb +74 -0
- data/lib/linkio/smart_redirect.rb +135 -0
- data/lib/linkio/storage/base.rb +69 -0
- data/lib/linkio/storage/in_memory_storage.rb +79 -0
- data/lib/linkio/storage/redis_storage.rb +110 -0
- data/lib/linkio/utils.rb +125 -0
- data/lib/linkio/version.rb +5 -0
- data/lib/linkio.rb +71 -0
- metadata +136 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LinkIO
|
|
6
|
+
module Rack
|
|
7
|
+
# Rack middleware that serves the LinkIO endpoints without any framework.
|
|
8
|
+
# Mount it in a config.ru or any Rack app:
|
|
9
|
+
#
|
|
10
|
+
# use LinkIO::Rack::Middleware, client: my_client
|
|
11
|
+
# run MyApp
|
|
12
|
+
#
|
|
13
|
+
# Requests it does not recognise are passed through to the wrapped app.
|
|
14
|
+
# Handled routes:
|
|
15
|
+
# GET /.well-known/apple-app-site-association
|
|
16
|
+
# GET /.well-known/assetlinks.json
|
|
17
|
+
# GET /link
|
|
18
|
+
# GET /pending-link
|
|
19
|
+
# GET /pending-link/:device_id
|
|
20
|
+
# POST /track-referral
|
|
21
|
+
# GET /referrals/:referrer_id
|
|
22
|
+
class Middleware
|
|
23
|
+
# @param app [#call] the downstream Rack app
|
|
24
|
+
# @param client [LinkIO::Client] defaults to the global {LinkIO.client}
|
|
25
|
+
def initialize(app, client: nil)
|
|
26
|
+
@app = app
|
|
27
|
+
@client = client
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(env)
|
|
31
|
+
request = ::Rack::Request.new(env) if defined?(::Rack::Request)
|
|
32
|
+
method = env["REQUEST_METHOD"]
|
|
33
|
+
path = env["PATH_INFO"].to_s
|
|
34
|
+
|
|
35
|
+
result = route(method, path, env, request)
|
|
36
|
+
result ? result.to_rack : @app.call(env)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def client
|
|
42
|
+
@client || LinkIO.client
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def route(method, path, env, request)
|
|
46
|
+
return client.well_known(path) if path.start_with?("/.well-known/")
|
|
47
|
+
|
|
48
|
+
case method
|
|
49
|
+
when "GET"
|
|
50
|
+
get_routes(path, env)
|
|
51
|
+
when "POST"
|
|
52
|
+
post_routes(path, request, env)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def get_routes(path, env)
|
|
57
|
+
if path == "/link"
|
|
58
|
+
client.handle_deep_link(Request.from_rack(env))
|
|
59
|
+
elsif path == "/pending-link"
|
|
60
|
+
fingerprint_pending_link(env)
|
|
61
|
+
elsif (device_id = capture(path, %r{\A/pending-link/(.+)\z}))
|
|
62
|
+
not_found_or(client.pending_link(device_id))
|
|
63
|
+
elsif (referrer_id = capture(path, %r{\A/referrals/(.+)\z}))
|
|
64
|
+
Result.json({ "referrals" => client.referrals(referrer_id).map(&:to_h) })
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def post_routes(path, request, _env)
|
|
69
|
+
return unless path == "/track-referral"
|
|
70
|
+
|
|
71
|
+
body = parse_body(request)
|
|
72
|
+
client.track_referral(body["referralCode"], body["userId"], body["metadata"])
|
|
73
|
+
Result.json({ "success" => true })
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def fingerprint_pending_link(env)
|
|
77
|
+
ip = Utils.client_ip(
|
|
78
|
+
forwarded_for: env["HTTP_X_FORWARDED_FOR"],
|
|
79
|
+
remote_address: env["REMOTE_ADDR"]
|
|
80
|
+
)
|
|
81
|
+
not_found_or(client.pending_link_by_fingerprint(ip, env["HTTP_USER_AGENT"]))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def not_found_or(deep_link)
|
|
85
|
+
return Result.json({ "error" => "No pending link found" }, status: 404) if deep_link.nil?
|
|
86
|
+
|
|
87
|
+
Result.json(deep_link.to_h)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def capture(path, regex)
|
|
91
|
+
match = regex.match(path)
|
|
92
|
+
match && match[1]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def parse_body(request)
|
|
96
|
+
raw = request&.body&.read.to_s
|
|
97
|
+
return {} if raw.empty?
|
|
98
|
+
|
|
99
|
+
JSON.parse(raw)
|
|
100
|
+
rescue JSON::ParserError
|
|
101
|
+
{}
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinkIO
|
|
4
|
+
# A referral relationship between a referrer and a referee.
|
|
5
|
+
# `timestamp` is epoch milliseconds, matching the Node.js backend.
|
|
6
|
+
class ReferralData
|
|
7
|
+
attr_reader :referrer_id, :referee_id, :referral_code, :timestamp, :metadata
|
|
8
|
+
|
|
9
|
+
# @param referrer_id [String]
|
|
10
|
+
# @param referee_id [String]
|
|
11
|
+
# @param referral_code [String]
|
|
12
|
+
# @param timestamp [Integer] epoch milliseconds
|
|
13
|
+
# @param metadata [Hash, nil]
|
|
14
|
+
def initialize(referrer_id:, referee_id:, referral_code:, timestamp:, metadata: nil)
|
|
15
|
+
@referrer_id = referrer_id
|
|
16
|
+
@referee_id = referee_id
|
|
17
|
+
@referral_code = referral_code
|
|
18
|
+
@timestamp = timestamp
|
|
19
|
+
@metadata = metadata
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Hash]
|
|
23
|
+
def to_h
|
|
24
|
+
{
|
|
25
|
+
"referrerId" => referrer_id,
|
|
26
|
+
"refereeId" => referee_id,
|
|
27
|
+
"referralCode" => referral_code,
|
|
28
|
+
"timestamp" => timestamp,
|
|
29
|
+
"metadata" => metadata
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Build from a hash using either camelCase or snake_case keys.
|
|
34
|
+
#
|
|
35
|
+
# @param hash [Hash]
|
|
36
|
+
# @return [ReferralData]
|
|
37
|
+
def self.from_h(hash)
|
|
38
|
+
h = hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
39
|
+
new(
|
|
40
|
+
referrer_id: h["referrerId"] || h["referrer_id"],
|
|
41
|
+
referee_id: h["refereeId"] || h["referee_id"],
|
|
42
|
+
referral_code: h["referralCode"] || h["referral_code"],
|
|
43
|
+
timestamp: h["timestamp"],
|
|
44
|
+
metadata: h["metadata"]
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinkIO
|
|
4
|
+
# A framework-agnostic view of an incoming HTTP request, containing only what
|
|
5
|
+
# {LinkIO::Client#handle_deep_link} needs. Build one directly, from a Rack
|
|
6
|
+
# env with {from_rack}, or let the Rails engine construct it for you.
|
|
7
|
+
class Request
|
|
8
|
+
attr_reader :user_agent, :full_url, :query_params, :path_params, :client_ip, :device_id
|
|
9
|
+
|
|
10
|
+
# @param user_agent [String, nil]
|
|
11
|
+
# @param full_url [String] absolute request URL including query string
|
|
12
|
+
# @param query_params [Hash] parsed query parameters
|
|
13
|
+
# @param path_params [Hash] route/path parameters to merge into params
|
|
14
|
+
# @param client_ip [String] resolved client IP (proxy-aware)
|
|
15
|
+
# @param device_id [String, nil] explicit device id (query "deviceId" or X-Device-Id)
|
|
16
|
+
def initialize(full_url:, client_ip:, user_agent: nil, query_params: {}, path_params: {}, device_id: nil)
|
|
17
|
+
@user_agent = user_agent.to_s
|
|
18
|
+
@full_url = full_url
|
|
19
|
+
@query_params = stringify(query_params)
|
|
20
|
+
@path_params = stringify(path_params)
|
|
21
|
+
@client_ip = client_ip
|
|
22
|
+
@device_id = device_id
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Merged query + path params (path params win), matching the Node backend's
|
|
26
|
+
# behaviour of overlaying `req.params` onto the parsed query params.
|
|
27
|
+
#
|
|
28
|
+
# @return [Hash{String => Object}]
|
|
29
|
+
def params
|
|
30
|
+
merged = query_params.dup
|
|
31
|
+
path_params.each { |k, v| merged[k] = v unless v.nil? || v.to_s.empty? }
|
|
32
|
+
merged
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Build a Request from a Rack environment hash.
|
|
36
|
+
#
|
|
37
|
+
# @param env [Hash] a Rack env
|
|
38
|
+
# @param path_params [Hash] optional route params (e.g. from a router)
|
|
39
|
+
# @return [LinkIO::Request]
|
|
40
|
+
def self.from_rack(env, path_params: {})
|
|
41
|
+
user_agent = env["HTTP_USER_AGENT"]
|
|
42
|
+
full_url = rack_full_url(env)
|
|
43
|
+
query = Utils.parse_query_params(full_url)
|
|
44
|
+
ip = Utils.client_ip(
|
|
45
|
+
forwarded_for: env["HTTP_X_FORWARDED_FOR"],
|
|
46
|
+
remote_address: env["REMOTE_ADDR"]
|
|
47
|
+
)
|
|
48
|
+
device_id = query["deviceId"] || env["HTTP_X_DEVICE_ID"]
|
|
49
|
+
|
|
50
|
+
new(
|
|
51
|
+
user_agent: user_agent,
|
|
52
|
+
full_url: full_url,
|
|
53
|
+
query_params: query,
|
|
54
|
+
path_params: path_params,
|
|
55
|
+
client_ip: ip,
|
|
56
|
+
device_id: device_id
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.rack_full_url(env)
|
|
61
|
+
scheme = env["rack.url_scheme"] || "http"
|
|
62
|
+
host = env["HTTP_HOST"] || env["SERVER_NAME"] || "localhost"
|
|
63
|
+
path = env["PATH_INFO"].to_s
|
|
64
|
+
query = env["QUERY_STRING"].to_s
|
|
65
|
+
url = "#{scheme}://#{host}#{path}"
|
|
66
|
+
query.empty? ? url : "#{url}?#{query}"
|
|
67
|
+
end
|
|
68
|
+
private_class_method :rack_full_url
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def stringify(hash)
|
|
73
|
+
(hash || {}).each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LinkIO
|
|
6
|
+
# A framework-agnostic description of the response the caller should send.
|
|
7
|
+
# {LinkIO::Client} returns these so that the same logic can be wired into
|
|
8
|
+
# Rack, Rails, Sinatra, or anything else.
|
|
9
|
+
#
|
|
10
|
+
# @!attribute [r] type
|
|
11
|
+
# @return [Symbol] one of :redirect, :html, :json
|
|
12
|
+
class Result
|
|
13
|
+
# Header names are lowercase to satisfy Rack 3's strict lint (Rack 2 and
|
|
14
|
+
# Rails accept them too).
|
|
15
|
+
HTML_HEADERS = { "content-type" => "text/html; charset=utf-8" }.freeze
|
|
16
|
+
JSON_HEADERS = { "content-type" => "application/json; charset=utf-8" }.freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :type, :status, :body, :headers
|
|
19
|
+
|
|
20
|
+
def initialize(type:, status:, body:, headers: {})
|
|
21
|
+
@type = type
|
|
22
|
+
@status = status
|
|
23
|
+
@body = body
|
|
24
|
+
@headers = headers
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Result] a 302 redirect to +url+
|
|
28
|
+
def self.redirect(url, status: 302)
|
|
29
|
+
new(type: :redirect, status: status, body: url, headers: { "location" => url })
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Result] an HTML response
|
|
33
|
+
def self.html(body, status: 200)
|
|
34
|
+
new(type: :html, status: status, body: body, headers: HTML_HEADERS.dup)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param object [Object] serialized to JSON for the body
|
|
38
|
+
# @return [Result] a JSON response (body kept as the raw object too)
|
|
39
|
+
def self.json(object, status: 200)
|
|
40
|
+
new(type: :json, status: status, body: object, headers: JSON_HEADERS.dup)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def redirect?
|
|
44
|
+
type == :redirect
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def html?
|
|
48
|
+
type == :html
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def json?
|
|
52
|
+
type == :json
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The location for redirect results.
|
|
56
|
+
# @return [String, nil]
|
|
57
|
+
def location
|
|
58
|
+
headers["location"]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The response body as a string, JSON-encoding json results.
|
|
62
|
+
# @return [String]
|
|
63
|
+
def body_string
|
|
64
|
+
json? ? JSON.generate(body) : body.to_s
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Convert to a standard Rack response triple.
|
|
68
|
+
#
|
|
69
|
+
# @return [Array(Integer, Hash, Array<String>)]
|
|
70
|
+
def to_rack
|
|
71
|
+
[status, headers.dup, [body_string]]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinkIO
|
|
4
|
+
# Generates the HTML page that attempts to open the native app via its custom
|
|
5
|
+
# scheme and falls back to the app store after a timeout. This is a faithful
|
|
6
|
+
# port of the Node.js backend's `generateSmartRedirectPage`.
|
|
7
|
+
module SmartRedirect
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# @param app_scheme [String] e.g. "rokart"
|
|
11
|
+
# @param params [Hash] parameters forwarded to the app link
|
|
12
|
+
# @param store_url [String] fallback store URL
|
|
13
|
+
# @param platform [String] {LinkIO::Platform} value
|
|
14
|
+
# @param package_name [String] Android package name (for the intent:// fallback)
|
|
15
|
+
# @param timeout [Integer] ms before falling back to the store
|
|
16
|
+
# @return [String] a complete HTML document
|
|
17
|
+
def page(app_scheme:, params:, store_url:, platform:, package_name:, timeout:)
|
|
18
|
+
query_string = Utils.encode_query(params)
|
|
19
|
+
app_uri = "#{app_scheme}://link#{"?#{query_string}" unless query_string.empty?}"
|
|
20
|
+
|
|
21
|
+
android_intent =
|
|
22
|
+
if platform == Platform::ANDROID
|
|
23
|
+
"intent://link#{"?#{query_string}" unless query_string.empty?}" \
|
|
24
|
+
"#Intent;scheme=#{app_scheme};package=#{package_name};end"
|
|
25
|
+
else
|
|
26
|
+
""
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
is_android = platform == Platform::ANDROID
|
|
30
|
+
|
|
31
|
+
<<~HTML
|
|
32
|
+
<!DOCTYPE html>
|
|
33
|
+
<html>
|
|
34
|
+
<head>
|
|
35
|
+
<meta charset="utf-8">
|
|
36
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
37
|
+
<title>Opening App...</title>
|
|
38
|
+
<style>
|
|
39
|
+
body {
|
|
40
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
41
|
+
display: flex;
|
|
42
|
+
justify-content: center;
|
|
43
|
+
align-items: center;
|
|
44
|
+
min-height: 100vh;
|
|
45
|
+
margin: 0;
|
|
46
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
47
|
+
color: white;
|
|
48
|
+
text-align: center;
|
|
49
|
+
}
|
|
50
|
+
.container { padding: 20px; }
|
|
51
|
+
.spinner {
|
|
52
|
+
width: 50px;
|
|
53
|
+
height: 50px;
|
|
54
|
+
border: 3px solid rgba(255,255,255,0.3);
|
|
55
|
+
border-radius: 50%;
|
|
56
|
+
border-top-color: white;
|
|
57
|
+
animation: spin 1s linear infinite;
|
|
58
|
+
margin: 0 auto 20px;
|
|
59
|
+
}
|
|
60
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
61
|
+
h1 { font-size: 24px; margin-bottom: 10px; }
|
|
62
|
+
p { opacity: 0.9; margin-bottom: 20px; }
|
|
63
|
+
.btn {
|
|
64
|
+
display: inline-block;
|
|
65
|
+
padding: 12px 24px;
|
|
66
|
+
background: white;
|
|
67
|
+
color: #667eea;
|
|
68
|
+
text-decoration: none;
|
|
69
|
+
border-radius: 8px;
|
|
70
|
+
font-weight: 600;
|
|
71
|
+
margin: 5px;
|
|
72
|
+
}
|
|
73
|
+
</style>
|
|
74
|
+
</head>
|
|
75
|
+
<body>
|
|
76
|
+
<div class="container">
|
|
77
|
+
<div class="spinner"></div>
|
|
78
|
+
<h1>Opening App...</h1>
|
|
79
|
+
<p>If the app doesn't open, tap below to download</p>
|
|
80
|
+
<a href="#{store_url}" class="btn">Download App</a>
|
|
81
|
+
</div>
|
|
82
|
+
<script>
|
|
83
|
+
(function() {
|
|
84
|
+
var appUri = "#{app_uri}";
|
|
85
|
+
var storeUrl = "#{store_url}";
|
|
86
|
+
var timeout = #{timeout};
|
|
87
|
+
var androidIntent = "#{android_intent}";
|
|
88
|
+
var isAndroid = #{is_android};
|
|
89
|
+
|
|
90
|
+
var startTime = Date.now();
|
|
91
|
+
var hasFocus = true;
|
|
92
|
+
|
|
93
|
+
// Track if user leaves the page (app opened)
|
|
94
|
+
window.addEventListener('blur', function() { hasFocus = false; });
|
|
95
|
+
window.addEventListener('pagehide', function() { hasFocus = false; });
|
|
96
|
+
document.addEventListener('visibilitychange', function() {
|
|
97
|
+
if (document.hidden) hasFocus = false;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Try to open app
|
|
101
|
+
var iframe = document.createElement('iframe');
|
|
102
|
+
iframe.style.display = 'none';
|
|
103
|
+
iframe.src = appUri;
|
|
104
|
+
document.body.appendChild(iframe);
|
|
105
|
+
|
|
106
|
+
// Also try direct location for some browsers
|
|
107
|
+
setTimeout(function() {
|
|
108
|
+
if (hasFocus) {
|
|
109
|
+
window.location.href = appUri;
|
|
110
|
+
}
|
|
111
|
+
}, 100);
|
|
112
|
+
|
|
113
|
+
// Android: try intent scheme as fallback
|
|
114
|
+
if (isAndroid && androidIntent) {
|
|
115
|
+
setTimeout(function() {
|
|
116
|
+
if (hasFocus && Date.now() - startTime < timeout) {
|
|
117
|
+
window.location.href = androidIntent;
|
|
118
|
+
}
|
|
119
|
+
}, 500);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Fallback to store after timeout
|
|
123
|
+
setTimeout(function() {
|
|
124
|
+
if (hasFocus && Date.now() - startTime >= timeout - 100) {
|
|
125
|
+
window.location.href = storeUrl;
|
|
126
|
+
}
|
|
127
|
+
}, timeout);
|
|
128
|
+
})();
|
|
129
|
+
</script>
|
|
130
|
+
</body>
|
|
131
|
+
</html>
|
|
132
|
+
HTML
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinkIO
|
|
4
|
+
module Storage
|
|
5
|
+
# The storage interface every adapter must implement. Mirrors the
|
|
6
|
+
# `LinkIOStorage` interface from the Node.js backend. Subclass this (or
|
|
7
|
+
# duck-type it) to plug in your own backing store.
|
|
8
|
+
#
|
|
9
|
+
# All pending-link methods deal in {LinkIO::PendingLinkData}; referral
|
|
10
|
+
# methods deal in {LinkIO::ReferralData}.
|
|
11
|
+
class Base
|
|
12
|
+
# @param device_id [String]
|
|
13
|
+
# @param data [PendingLinkData]
|
|
14
|
+
# @return [void]
|
|
15
|
+
def save_pending_link(device_id, data)
|
|
16
|
+
raise NotImplementedError, "#{self.class}#save_pending_link is not implemented"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param device_id [String]
|
|
20
|
+
# @return [PendingLinkData, nil]
|
|
21
|
+
def get_pending_link(device_id)
|
|
22
|
+
raise NotImplementedError, "#{self.class}#get_pending_link is not implemented"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param device_id [String]
|
|
26
|
+
# @return [void]
|
|
27
|
+
def delete_pending_link(device_id)
|
|
28
|
+
raise NotImplementedError, "#{self.class}#delete_pending_link is not implemented"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param fingerprint [String]
|
|
32
|
+
# @param data [PendingLinkData]
|
|
33
|
+
# @return [void]
|
|
34
|
+
def save_pending_link_by_fingerprint(fingerprint, data)
|
|
35
|
+
raise NotImplementedError, "#{self.class}#save_pending_link_by_fingerprint is not implemented"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param fingerprint [String]
|
|
39
|
+
# @return [PendingLinkData, nil]
|
|
40
|
+
def get_pending_link_by_fingerprint(fingerprint)
|
|
41
|
+
raise NotImplementedError, "#{self.class}#get_pending_link_by_fingerprint is not implemented"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param fingerprint [String]
|
|
45
|
+
# @return [void]
|
|
46
|
+
def delete_pending_link_by_fingerprint(fingerprint)
|
|
47
|
+
raise NotImplementedError, "#{self.class}#delete_pending_link_by_fingerprint is not implemented"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param referral [ReferralData]
|
|
51
|
+
# @return [void]
|
|
52
|
+
def save_referral(referral)
|
|
53
|
+
raise NotImplementedError, "#{self.class}#save_referral is not implemented"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param referrer_id [String]
|
|
57
|
+
# @return [Array<ReferralData>]
|
|
58
|
+
def get_referrals_by_referrer(referrer_id)
|
|
59
|
+
raise NotImplementedError, "#{self.class}#get_referrals_by_referrer is not implemented"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param referee_id [String]
|
|
63
|
+
# @return [ReferralData, nil]
|
|
64
|
+
def get_referral_by_referee(referee_id)
|
|
65
|
+
raise NotImplementedError, "#{self.class}#get_referral_by_referee is not implemented"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinkIO
|
|
4
|
+
module Storage
|
|
5
|
+
# A process-local, thread-safe in-memory store. Great for development and
|
|
6
|
+
# tests. Data is lost when the process exits and is not shared between
|
|
7
|
+
# processes, so do not use it in production with multiple workers.
|
|
8
|
+
#
|
|
9
|
+
# Expiry is enforced lazily on read (matching the Node.js backend).
|
|
10
|
+
class InMemoryStorage < Base
|
|
11
|
+
def initialize
|
|
12
|
+
super
|
|
13
|
+
@pending_links = {}
|
|
14
|
+
@fingerprint_links = {}
|
|
15
|
+
@referrals = []
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def save_pending_link(device_id, data)
|
|
20
|
+
@mutex.synchronize { @pending_links[device_id] = data }
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def get_pending_link(device_id)
|
|
25
|
+
@mutex.synchronize { fetch_unexpired(@pending_links, device_id) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def delete_pending_link(device_id)
|
|
29
|
+
@mutex.synchronize { @pending_links.delete(device_id) }
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def save_pending_link_by_fingerprint(fingerprint, data)
|
|
34
|
+
@mutex.synchronize { @fingerprint_links[fingerprint] = data }
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def get_pending_link_by_fingerprint(fingerprint)
|
|
39
|
+
@mutex.synchronize { fetch_unexpired(@fingerprint_links, fingerprint) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def delete_pending_link_by_fingerprint(fingerprint)
|
|
43
|
+
@mutex.synchronize { @fingerprint_links.delete(fingerprint) }
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def save_referral(referral)
|
|
48
|
+
@mutex.synchronize do
|
|
49
|
+
exists = @referrals.any? { |r| r.referee_id == referral.referee_id }
|
|
50
|
+
@referrals << referral unless exists
|
|
51
|
+
end
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def get_referrals_by_referrer(referrer_id)
|
|
56
|
+
@mutex.synchronize { @referrals.select { |r| r.referrer_id == referrer_id } }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def get_referral_by_referee(referee_id)
|
|
60
|
+
@mutex.synchronize { @referrals.find { |r| r.referee_id == referee_id } }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Caller must hold the mutex.
|
|
66
|
+
def fetch_unexpired(store, key)
|
|
67
|
+
data = store[key]
|
|
68
|
+
return nil if data.nil?
|
|
69
|
+
|
|
70
|
+
if data.expired?
|
|
71
|
+
store.delete(key)
|
|
72
|
+
return nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
data
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LinkIO
|
|
6
|
+
module Storage
|
|
7
|
+
# A Redis-backed store suitable for production. Pass any client that
|
|
8
|
+
# responds to the standard `redis` gem methods (`get`, `set`, `del`).
|
|
9
|
+
# Pending links are written with a TTL so they expire automatically.
|
|
10
|
+
#
|
|
11
|
+
# require "redis"
|
|
12
|
+
# storage = LinkIO::Storage::RedisStorage.new(Redis.new)
|
|
13
|
+
#
|
|
14
|
+
# Keys are namespaced identically to the Node.js backend
|
|
15
|
+
# (`linkio:pending:*`, `linkio:fingerprint:*`, `linkio:referral:*`,
|
|
16
|
+
# `linkio:referrer:*`) so the two implementations can share a Redis.
|
|
17
|
+
class RedisStorage < Base
|
|
18
|
+
PENDING_PREFIX = "linkio:pending:"
|
|
19
|
+
FINGERPRINT_PREFIX = "linkio:fingerprint:"
|
|
20
|
+
REFERRAL_PREFIX = "linkio:referral:"
|
|
21
|
+
REFERRER_PREFIX = "linkio:referrer:"
|
|
22
|
+
|
|
23
|
+
# @param redis [Object] a redis client (e.g. `Redis.new`)
|
|
24
|
+
def initialize(redis)
|
|
25
|
+
super()
|
|
26
|
+
@redis = redis
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def save_pending_link(device_id, data)
|
|
30
|
+
write_pending("#{PENDING_PREFIX}#{device_id}", data)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def get_pending_link(device_id)
|
|
34
|
+
read_pending("#{PENDING_PREFIX}#{device_id}")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def delete_pending_link(device_id)
|
|
38
|
+
@redis.del("#{PENDING_PREFIX}#{device_id}")
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def save_pending_link_by_fingerprint(fingerprint, data)
|
|
43
|
+
write_pending("#{FINGERPRINT_PREFIX}#{fingerprint}", data)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def get_pending_link_by_fingerprint(fingerprint)
|
|
47
|
+
read_pending("#{FINGERPRINT_PREFIX}#{fingerprint}")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def delete_pending_link_by_fingerprint(fingerprint)
|
|
51
|
+
@redis.del("#{FINGERPRINT_PREFIX}#{fingerprint}")
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def save_referral(referral)
|
|
56
|
+
return if get_referral_by_referee(referral.referee_id)
|
|
57
|
+
|
|
58
|
+
@redis.set("#{REFERRAL_PREFIX}#{referral.referee_id}", JSON.generate(referral.to_h))
|
|
59
|
+
|
|
60
|
+
referrer_key = "#{REFERRER_PREFIX}#{referral.referrer_id}"
|
|
61
|
+
list = read_json(referrer_key) || []
|
|
62
|
+
list << referral.referee_id
|
|
63
|
+
@redis.set(referrer_key, JSON.generate(list))
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def get_referrals_by_referrer(referrer_id)
|
|
68
|
+
referee_ids = read_json("#{REFERRER_PREFIX}#{referrer_id}") || []
|
|
69
|
+
referee_ids.map do |referee_id|
|
|
70
|
+
raw = @redis.get("#{REFERRAL_PREFIX}#{referee_id}")
|
|
71
|
+
raw && ReferralData.from_h(JSON.parse(raw))
|
|
72
|
+
end.compact
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def get_referral_by_referee(referee_id)
|
|
76
|
+
raw = @redis.get("#{REFERRAL_PREFIX}#{referee_id}")
|
|
77
|
+
raw ? ReferralData.from_h(JSON.parse(raw)) : nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def write_pending(key, data)
|
|
83
|
+
ttl = ((data.expires_at - (Time.now.to_f * 1000)) / 1000).floor
|
|
84
|
+
if ttl.positive?
|
|
85
|
+
@redis.set(key, JSON.generate(data.to_h), ex: ttl)
|
|
86
|
+
else
|
|
87
|
+
@redis.set(key, JSON.generate(data.to_h))
|
|
88
|
+
end
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def read_pending(key)
|
|
93
|
+
raw = @redis.get(key)
|
|
94
|
+
return nil if raw.nil?
|
|
95
|
+
|
|
96
|
+
data = PendingLinkData.from_h(JSON.parse(raw))
|
|
97
|
+
if data.expired?
|
|
98
|
+
@redis.del(key)
|
|
99
|
+
return nil
|
|
100
|
+
end
|
|
101
|
+
data
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def read_json(key)
|
|
105
|
+
raw = @redis.get(key)
|
|
106
|
+
raw ? JSON.parse(raw) : nil
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|