affiliate_tracker 0.3.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 +7 -0
- data/.rubocop.yml +37 -0
- data/.ruby-version +1 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +30 -0
- data/LICENSE.txt +21 -0
- data/README.md +240 -0
- data/Rakefile +12 -0
- data/TEST_SUITE_TASKS.md +238 -0
- data/app/controllers/affiliate_tracker/application_controller.rb +7 -0
- data/app/controllers/affiliate_tracker/clicks_controller.rb +110 -0
- data/app/controllers/affiliate_tracker/dashboard_controller.rb +30 -0
- data/app/models/affiliate_tracker/click.rb +22 -0
- data/app/views/affiliate_tracker/dashboard/index.html.erb +91 -0
- data/config/routes.rb +6 -0
- data/lib/affiliate_tracker/configuration.rb +100 -0
- data/lib/affiliate_tracker/engine.rb +19 -0
- data/lib/affiliate_tracker/url_generator.rb +72 -0
- data/lib/affiliate_tracker/version.rb +5 -0
- data/lib/affiliate_tracker/view_helpers.rb +67 -0
- data/lib/affiliate_tracker.rb +34 -0
- data/lib/generators/affiliate_tracker/install/install_generator.rb +40 -0
- data/lib/generators/affiliate_tracker/install/templates/README +19 -0
- data/lib/generators/affiliate_tracker/install/templates/create_affiliate_tracker_clicks.rb.tt +20 -0
- data/lib/generators/affiliate_tracker/install/templates/initializer.rb +26 -0
- metadata +131 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module AffiliateTracker
|
|
6
|
+
class ClicksController < ApplicationController
|
|
7
|
+
skip_before_action :verify_authenticity_token, only: [:redirect]
|
|
8
|
+
|
|
9
|
+
def redirect
|
|
10
|
+
payload = params[:payload]
|
|
11
|
+
signature = params[:s]
|
|
12
|
+
|
|
13
|
+
begin
|
|
14
|
+
data = UrlGenerator.decode(payload, signature)
|
|
15
|
+
destination_url = data[:destination_url]
|
|
16
|
+
metadata = data[:metadata]
|
|
17
|
+
|
|
18
|
+
# Record the click
|
|
19
|
+
record_click(destination_url, metadata)
|
|
20
|
+
|
|
21
|
+
# Normalize URL protocol
|
|
22
|
+
destination_url = "https://#{destination_url}" unless destination_url.match?(%r{\A[a-zA-Z][a-zA-Z0-9+\-.]*://})
|
|
23
|
+
|
|
24
|
+
# Build final URL with UTM parameters
|
|
25
|
+
final_url = append_utm_params(destination_url, metadata)
|
|
26
|
+
|
|
27
|
+
# Redirect to destination (302 so browsers don't cache and we can re-track clicks)
|
|
28
|
+
redirect_to final_url, allow_other_host: true, status: :found
|
|
29
|
+
rescue AffiliateTracker::Error => e
|
|
30
|
+
Rails.logger.warn "[AffiliateTracker] Invalid tracking URL: #{e.message} from #{request.remote_ip}"
|
|
31
|
+
redirect_to AffiliateTracker.configuration.resolve_fallback_url(payload), allow_other_host: true
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def record_click(destination_url, metadata)
|
|
38
|
+
dedup_key = "affiliate_tracker:#{request.remote_ip}:#{destination_url}"
|
|
39
|
+
|
|
40
|
+
# Deduplication using Rails.cache (5 seconds window)
|
|
41
|
+
return if Rails.cache.exist?(dedup_key)
|
|
42
|
+
|
|
43
|
+
Rails.cache.write(dedup_key, true, expires_in: 5.seconds)
|
|
44
|
+
|
|
45
|
+
click = Click.create!(
|
|
46
|
+
destination_url: destination_url,
|
|
47
|
+
ip_address: anonymize_ip(request.remote_ip),
|
|
48
|
+
user_agent: request.user_agent&.truncate(500),
|
|
49
|
+
referer: request.referer&.truncate(500),
|
|
50
|
+
metadata: metadata,
|
|
51
|
+
clicked_at: Time.current
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Call custom handler if configured
|
|
55
|
+
if (handler = AffiliateTracker.configuration.after_click)
|
|
56
|
+
handler.call(click)
|
|
57
|
+
end
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
Rails.logger.error "[AffiliateTracker] Failed to record click: #{e.message}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def append_utm_params(url, metadata)
|
|
63
|
+
uri = URI.parse(url)
|
|
64
|
+
params = URI.decode_www_form(uri.query || '')
|
|
65
|
+
|
|
66
|
+
# Add ref and UTM params (metadata overrides defaults)
|
|
67
|
+
config = AffiliateTracker.configuration
|
|
68
|
+
tracking_params = {
|
|
69
|
+
'ref' => config.ref_param,
|
|
70
|
+
'utm_source' => metadata['utm_source'] || config.utm_source,
|
|
71
|
+
'utm_medium' => metadata['utm_medium'] || config.utm_medium,
|
|
72
|
+
'utm_campaign' => metadata['campaign'],
|
|
73
|
+
'utm_content' => metadata['shop']
|
|
74
|
+
}.compact
|
|
75
|
+
|
|
76
|
+
# Merge with existing params (don't overwrite if already present)
|
|
77
|
+
existing_keys = params.map(&:first)
|
|
78
|
+
tracking_params.each do |key, value|
|
|
79
|
+
params << [key, value] unless existing_keys.include?(key)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
uri.query = URI.encode_www_form(params) if params.any?
|
|
83
|
+
uri.to_s
|
|
84
|
+
rescue URI::InvalidURIError
|
|
85
|
+
url
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def anonymize_ip(ip)
|
|
89
|
+
return nil if ip.blank?
|
|
90
|
+
|
|
91
|
+
if ip.include?(':')
|
|
92
|
+
# IPv6: zero the last 80 bits (last 5 groups)
|
|
93
|
+
groups = ip.split(':')
|
|
94
|
+
if groups.size >= 8
|
|
95
|
+
(3..7).each { |i| groups[i] = '0' }
|
|
96
|
+
groups.join(':')
|
|
97
|
+
else
|
|
98
|
+
ip
|
|
99
|
+
end
|
|
100
|
+
else
|
|
101
|
+
# IPv4: zero the last octet
|
|
102
|
+
parts = ip.split('.')
|
|
103
|
+
return ip unless parts.size == 4
|
|
104
|
+
|
|
105
|
+
parts[3] = '0'
|
|
106
|
+
parts.join('.')
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AffiliateTracker
|
|
4
|
+
class DashboardController < ApplicationController
|
|
5
|
+
before_action :authenticate!
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@stats = {
|
|
9
|
+
total_clicks: Click.count,
|
|
10
|
+
today_clicks: Click.where("clicked_at >= ?", Time.current.beginning_of_day).count,
|
|
11
|
+
week_clicks: Click.where("clicked_at >= ?", 1.week.ago).count,
|
|
12
|
+
unique_destinations: Click.distinct.count(:destination_url)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@recent_clicks = Click.order(clicked_at: :desc).limit(20)
|
|
16
|
+
@top_destinations = Click.group(:destination_url)
|
|
17
|
+
.order("count_all DESC")
|
|
18
|
+
.limit(10)
|
|
19
|
+
.count
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def authenticate!
|
|
25
|
+
if (auth = AffiliateTracker.configuration.authenticate_dashboard)
|
|
26
|
+
instance_exec(&auth)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AffiliateTracker
|
|
4
|
+
class Click < ActiveRecord::Base
|
|
5
|
+
self.table_name = "affiliate_tracker_clicks"
|
|
6
|
+
|
|
7
|
+
validates :destination_url, presence: true
|
|
8
|
+
validates :clicked_at, presence: true
|
|
9
|
+
|
|
10
|
+
# metadata is native JSON column - no serialize needed in Rails 8
|
|
11
|
+
|
|
12
|
+
scope :today, -> { where("clicked_at >= ?", Time.current.beginning_of_day) }
|
|
13
|
+
scope :this_week, -> { where("clicked_at >= ?", 1.week.ago) }
|
|
14
|
+
scope :this_month, -> { where("clicked_at >= ?", 1.month.ago) }
|
|
15
|
+
|
|
16
|
+
def domain
|
|
17
|
+
URI.parse(destination_url).host
|
|
18
|
+
rescue URI::InvalidURIError
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Affiliate Tracker Dashboard</title>
|
|
5
|
+
<style>
|
|
6
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
7
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
|
8
|
+
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
|
9
|
+
h1 { font-size: 24px; margin-bottom: 20px; color: #111; }
|
|
10
|
+
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
|
11
|
+
.stat { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
12
|
+
.stat-value { font-size: 32px; font-weight: 700; color: #111; }
|
|
13
|
+
.stat-label { font-size: 14px; color: #666; margin-top: 4px; }
|
|
14
|
+
.section { background: white; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); padding: 20px; margin-bottom: 24px; }
|
|
15
|
+
.section h2 { font-size: 18px; margin-bottom: 16px; color: #111; }
|
|
16
|
+
table { width: 100%; border-collapse: collapse; }
|
|
17
|
+
th, td { text-align: left; padding: 12px; border-bottom: 1px solid #eee; }
|
|
18
|
+
th { font-weight: 600; color: #666; font-size: 12px; text-transform: uppercase; }
|
|
19
|
+
td { font-size: 14px; }
|
|
20
|
+
.url { max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
21
|
+
.time { color: #666; }
|
|
22
|
+
.badge { display: inline-block; padding: 2px 8px; background: #e0f2e9; color: #0d6939; border-radius: 4px; font-size: 12px; }
|
|
23
|
+
</style>
|
|
24
|
+
</head>
|
|
25
|
+
<body>
|
|
26
|
+
<div class="container">
|
|
27
|
+
<h1>Affiliate Tracker</h1>
|
|
28
|
+
|
|
29
|
+
<div class="stats">
|
|
30
|
+
<div class="stat">
|
|
31
|
+
<div class="stat-value"><%= @stats[:total_clicks] %></div>
|
|
32
|
+
<div class="stat-label">Total Clicks</div>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="stat">
|
|
35
|
+
<div class="stat-value"><%= @stats[:today_clicks] %></div>
|
|
36
|
+
<div class="stat-label">Today</div>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="stat">
|
|
39
|
+
<div class="stat-value"><%= @stats[:week_clicks] %></div>
|
|
40
|
+
<div class="stat-label">This Week</div>
|
|
41
|
+
</div>
|
|
42
|
+
<div class="stat">
|
|
43
|
+
<div class="stat-value"><%= @stats[:unique_destinations] %></div>
|
|
44
|
+
<div class="stat-label">Unique Destinations</div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div class="section">
|
|
49
|
+
<h2>Top Destinations</h2>
|
|
50
|
+
<table>
|
|
51
|
+
<thead>
|
|
52
|
+
<tr>
|
|
53
|
+
<th>URL</th>
|
|
54
|
+
<th>Clicks</th>
|
|
55
|
+
</tr>
|
|
56
|
+
</thead>
|
|
57
|
+
<tbody>
|
|
58
|
+
<% @top_destinations.each do |url, count| %>
|
|
59
|
+
<tr>
|
|
60
|
+
<td class="url"><%= url %></td>
|
|
61
|
+
<td><span class="badge"><%= count %></span></td>
|
|
62
|
+
</tr>
|
|
63
|
+
<% end %>
|
|
64
|
+
</tbody>
|
|
65
|
+
</table>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div class="section">
|
|
69
|
+
<h2>Recent Clicks</h2>
|
|
70
|
+
<table>
|
|
71
|
+
<thead>
|
|
72
|
+
<tr>
|
|
73
|
+
<th>Time</th>
|
|
74
|
+
<th>Destination</th>
|
|
75
|
+
<th>Metadata</th>
|
|
76
|
+
</tr>
|
|
77
|
+
</thead>
|
|
78
|
+
<tbody>
|
|
79
|
+
<% @recent_clicks.each do |click| %>
|
|
80
|
+
<tr>
|
|
81
|
+
<td class="time"><%= click.clicked_at.strftime("%Y-%m-%d %H:%M") %></td>
|
|
82
|
+
<td class="url"><%= click.destination_url %></td>
|
|
83
|
+
<td><code><%= click.metadata.to_json if click.metadata.present? %></code></td>
|
|
84
|
+
</tr>
|
|
85
|
+
<% end %>
|
|
86
|
+
</tbody>
|
|
87
|
+
</table>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</body>
|
|
91
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AffiliateTracker
|
|
4
|
+
class Configuration
|
|
5
|
+
# Dashboard authentication proc (optional)
|
|
6
|
+
attr_accessor :authenticate_dashboard
|
|
7
|
+
|
|
8
|
+
# Custom click handler (optional)
|
|
9
|
+
attr_accessor :after_click
|
|
10
|
+
|
|
11
|
+
# Default UTM source (your brand name)
|
|
12
|
+
attr_accessor :utm_source
|
|
13
|
+
|
|
14
|
+
# Default UTM medium
|
|
15
|
+
attr_accessor :utm_medium
|
|
16
|
+
|
|
17
|
+
# Referral parameter (e.g., "partnerJan" adds ?ref=partnerJan)
|
|
18
|
+
attr_accessor :ref_param
|
|
19
|
+
|
|
20
|
+
# Default metadata proc - called when generating URLs
|
|
21
|
+
# Example: -> { { user_id: Current.user&.id } }
|
|
22
|
+
attr_accessor :default_metadata
|
|
23
|
+
|
|
24
|
+
# Fallback URL when signature is missing or invalid.
|
|
25
|
+
# Can be a String (static URL) or a Proc that receives the decoded payload Hash.
|
|
26
|
+
# The payload is decoded WITHOUT signature verification, so treat it as untrusted.
|
|
27
|
+
# Default: "/" (homepage)
|
|
28
|
+
#
|
|
29
|
+
# Examples:
|
|
30
|
+
# config.fallback_url = "/oops"
|
|
31
|
+
# config.fallback_url = ->(payload) { payload&.dig("shop") ? "/#{payload["shop"]}" : "/" }
|
|
32
|
+
attr_accessor :fallback_url
|
|
33
|
+
|
|
34
|
+
def initialize
|
|
35
|
+
@authenticate_dashboard = nil
|
|
36
|
+
@after_click = nil
|
|
37
|
+
@utm_source = 'affiliate'
|
|
38
|
+
@utm_medium = 'referral'
|
|
39
|
+
@ref_param = nil
|
|
40
|
+
@default_metadata = nil
|
|
41
|
+
@fallback_url = '/'
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Resolve fallback URL from config. Safely decodes payload (unverified) and
|
|
45
|
+
# passes it to the proc. Returns "/" if anything goes wrong.
|
|
46
|
+
def resolve_fallback_url(raw_payload)
|
|
47
|
+
payload_data = decode_payload_unsafe(raw_payload)
|
|
48
|
+
|
|
49
|
+
if @fallback_url.respond_to?(:call)
|
|
50
|
+
result = @fallback_url.call(payload_data)
|
|
51
|
+
result.presence || '/'
|
|
52
|
+
else
|
|
53
|
+
@fallback_url.to_s.presence || '/'
|
|
54
|
+
end
|
|
55
|
+
rescue StandardError
|
|
56
|
+
'/'
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def resolve_default_metadata
|
|
60
|
+
return {} unless @default_metadata.respond_to?(:call)
|
|
61
|
+
|
|
62
|
+
result = @default_metadata.call
|
|
63
|
+
result.is_a?(Hash) ? result : {}
|
|
64
|
+
rescue StandardError
|
|
65
|
+
{}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def base_url
|
|
69
|
+
# Try routes first, then ActionMailer::Base as fallback (Rails 8 way)
|
|
70
|
+
options = Rails.application.routes.default_url_options.presence ||
|
|
71
|
+
ActionMailer::Base.default_url_options.presence ||
|
|
72
|
+
{}
|
|
73
|
+
|
|
74
|
+
host = options[:host]
|
|
75
|
+
unless host
|
|
76
|
+
raise Error,
|
|
77
|
+
"Set config.action_mailer.default_url_options = { host: 'example.com' } in config/environments/*.rb"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
protocol = options[:protocol] || 'https'
|
|
81
|
+
"#{protocol}://#{host}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def secret_key
|
|
85
|
+
Rails.application.key_generator.generate_key('affiliate_tracker', 32)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# Decode Base64 payload without verifying signature.
|
|
91
|
+
# Used only for building fallback URLs — treat result as untrusted.
|
|
92
|
+
def decode_payload_unsafe(raw_payload)
|
|
93
|
+
return nil if raw_payload.blank?
|
|
94
|
+
|
|
95
|
+
JSON.parse(Base64.urlsafe_decode64(raw_payload))
|
|
96
|
+
rescue StandardError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AffiliateTracker
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace AffiliateTracker
|
|
6
|
+
|
|
7
|
+
initializer "affiliate_tracker.helpers" do
|
|
8
|
+
ActiveSupport.on_load(:action_view) do
|
|
9
|
+
include AffiliateTracker::ViewHelpers
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
ActiveSupport.on_load(:action_mailer) do
|
|
13
|
+
include AffiliateTracker::ViewHelpers
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
require_relative "view_helpers"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'active_support/security_utils'
|
|
7
|
+
|
|
8
|
+
module AffiliateTracker
|
|
9
|
+
class UrlGenerator
|
|
10
|
+
attr_reader :destination_url, :metadata
|
|
11
|
+
|
|
12
|
+
def initialize(destination_url, metadata = {})
|
|
13
|
+
@destination_url = normalize_url(destination_url)
|
|
14
|
+
@metadata = metadata.transform_keys(&:to_s)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def generate
|
|
18
|
+
payload = encode_payload
|
|
19
|
+
signature = sign(payload)
|
|
20
|
+
"#{base_url}#{route_path}/#{payload}?s=#{signature}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def normalize_url(url)
|
|
26
|
+
return url if url.blank? || url.match?(%r{\A[a-zA-Z][a-zA-Z0-9+\-.]*://})
|
|
27
|
+
"https://#{url}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def encode_payload
|
|
31
|
+
data = { u: destination_url }.merge(metadata)
|
|
32
|
+
Base64.urlsafe_encode64(data.to_json, padding: false)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def sign(payload)
|
|
36
|
+
OpenSSL::HMAC.hexdigest('SHA256', secret_key, payload).first(32)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def base_url
|
|
40
|
+
AffiliateTracker.configuration.base_url or raise Error, 'base_url not configured'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def route_path
|
|
44
|
+
'/a'
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def secret_key
|
|
48
|
+
AffiliateTracker.configuration.secret_key or raise Error, 'secret_key not configured'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class << self
|
|
52
|
+
def decode(payload, signature)
|
|
53
|
+
raise Error, 'Missing payload' if payload.nil? || payload.empty?
|
|
54
|
+
raise Error, 'Missing signature' if signature.nil? || signature.empty?
|
|
55
|
+
|
|
56
|
+
expected_sig = OpenSSL::HMAC.hexdigest(
|
|
57
|
+
'SHA256',
|
|
58
|
+
AffiliateTracker.configuration.secret_key,
|
|
59
|
+
payload
|
|
60
|
+
).first(32)
|
|
61
|
+
|
|
62
|
+
raise Error, 'Invalid signature' unless ActiveSupport::SecurityUtils.secure_compare(expected_sig, signature)
|
|
63
|
+
|
|
64
|
+
data = JSON.parse(Base64.urlsafe_decode64(payload))
|
|
65
|
+
{
|
|
66
|
+
destination_url: data.delete('u'),
|
|
67
|
+
metadata: data
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AffiliateTracker
|
|
4
|
+
module ViewHelpers
|
|
5
|
+
# Generate a trackable affiliate link
|
|
6
|
+
#
|
|
7
|
+
# @param url [String] Destination URL
|
|
8
|
+
# @param text [String] Link text
|
|
9
|
+
# @param options [Hash] Tracking metadata (stored in click record)
|
|
10
|
+
#
|
|
11
|
+
# Common tracking options:
|
|
12
|
+
# user_id: - ID of user who will click (for attribution)
|
|
13
|
+
# shop_id: - Shop/store identifier
|
|
14
|
+
# promotion_id: - Specific promotion being clicked
|
|
15
|
+
# campaign: - Campaign name (e.g., "daily_digest", "weekly_email")
|
|
16
|
+
#
|
|
17
|
+
# HTML options (applied to <a> tag, not stored):
|
|
18
|
+
# class:, id:, style:, target:, rel:, data:, aria:
|
|
19
|
+
#
|
|
20
|
+
# Examples:
|
|
21
|
+
# # Basic link
|
|
22
|
+
# affiliate_link("https://shop.com", "Shop Now")
|
|
23
|
+
#
|
|
24
|
+
# # With user tracking (recommended for emails)
|
|
25
|
+
# affiliate_link("https://shop.com", "Shop Now", user_id: @user.id, campaign: "email")
|
|
26
|
+
#
|
|
27
|
+
# # Full tracking
|
|
28
|
+
# affiliate_link("https://shop.com", "View Deal",
|
|
29
|
+
# user_id: @user.id,
|
|
30
|
+
# shop_id: @shop.id,
|
|
31
|
+
# promotion_id: @promotion.id,
|
|
32
|
+
# campaign: "daily_digest")
|
|
33
|
+
#
|
|
34
|
+
# # With block
|
|
35
|
+
# affiliate_link("https://shop.com", user_id: @user.id) { "Shop Now" }
|
|
36
|
+
#
|
|
37
|
+
def affiliate_link(url, text_or_options = nil, options = {}, html_options = {}, &block)
|
|
38
|
+
if block_given?
|
|
39
|
+
options = text_or_options || {}
|
|
40
|
+
text = capture(&block)
|
|
41
|
+
else
|
|
42
|
+
text = text_or_options
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
html_keys = [:class, :id, :style, :target, :rel, :data, :aria]
|
|
46
|
+
tracking_url = AffiliateTracker.url(url, **options.except(*html_keys))
|
|
47
|
+
html_opts = { href: tracking_url, target: "_blank", rel: "noopener" }
|
|
48
|
+
html_opts.merge!(html_options)
|
|
49
|
+
html_opts.merge!(options.slice(*html_keys))
|
|
50
|
+
|
|
51
|
+
content_tag(:a, text, html_opts)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Generate just the tracking URL (useful for emails or manual links)
|
|
55
|
+
#
|
|
56
|
+
# @param url [String] Destination URL
|
|
57
|
+
# @param metadata [Hash] Tracking data (see affiliate_link for common options)
|
|
58
|
+
#
|
|
59
|
+
# Examples:
|
|
60
|
+
# affiliate_url("https://shop.com")
|
|
61
|
+
# affiliate_url("https://shop.com", user_id: @user.id, campaign: "email_weekly")
|
|
62
|
+
#
|
|
63
|
+
def affiliate_url(url, **metadata)
|
|
64
|
+
AffiliateTracker.url(url, **metadata)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "affiliate_tracker/version"
|
|
4
|
+
require_relative "affiliate_tracker/configuration"
|
|
5
|
+
|
|
6
|
+
module AffiliateTracker
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
attr_writer :configuration
|
|
11
|
+
|
|
12
|
+
def configuration
|
|
13
|
+
@configuration ||= Configuration.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def configure
|
|
17
|
+
yield(configuration)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Generate a trackable affiliate URL
|
|
21
|
+
def track_url(destination_url, metadata = {})
|
|
22
|
+
merged_metadata = configuration.resolve_default_metadata.merge(metadata)
|
|
23
|
+
UrlGenerator.new(destination_url, merged_metadata).generate
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Shorthand for track_url
|
|
27
|
+
def url(destination_url, **metadata)
|
|
28
|
+
track_url(destination_url, metadata)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
require_relative "affiliate_tracker/engine" if defined?(Rails)
|
|
34
|
+
require_relative "affiliate_tracker/url_generator"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module AffiliateTracker
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
include Rails::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
def self.next_migration_number(dirname)
|
|
14
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def copy_migration
|
|
18
|
+
migration_template "create_affiliate_tracker_clicks.rb.tt",
|
|
19
|
+
"db/migrate/create_affiliate_tracker_clicks.rb"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create_initializer
|
|
23
|
+
template "initializer.rb", "config/initializers/affiliate_tracker.rb"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def mount_engine
|
|
27
|
+
routes_path = "config/routes.rb"
|
|
28
|
+
mount_line = 'mount AffiliateTracker::Engine, at: "/a"'
|
|
29
|
+
|
|
30
|
+
return if File.exist?(routes_path) && File.read(routes_path).include?(mount_line)
|
|
31
|
+
|
|
32
|
+
route mount_line
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def show_readme
|
|
36
|
+
readme "README" if behavior == :invoke
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
|
|
2
|
+
===============================================================================
|
|
3
|
+
|
|
4
|
+
AffiliateTracker has been installed!
|
|
5
|
+
|
|
6
|
+
Next steps:
|
|
7
|
+
1. Run migrations:
|
|
8
|
+
$ rails db:migrate
|
|
9
|
+
|
|
10
|
+
2. Configure in config/initializers/affiliate_tracker.rb
|
|
11
|
+
|
|
12
|
+
3. Use in your views/mailers:
|
|
13
|
+
<%= affiliate_link "https://shop.com", "Shop Now", shop_id: 1 %>
|
|
14
|
+
<%= affiliate_url "https://shop.com", campaign: "email" %>
|
|
15
|
+
|
|
16
|
+
4. Access dashboard at:
|
|
17
|
+
/a/dashboard
|
|
18
|
+
|
|
19
|
+
===============================================================================
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateAffiliateTrackerClicks < ActiveRecord::Migration[8.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :affiliate_tracker_clicks do |t|
|
|
6
|
+
t.string :destination_url, null: false
|
|
7
|
+
t.string :ip_address
|
|
8
|
+
t.string :user_agent
|
|
9
|
+
t.string :referer
|
|
10
|
+
t.json :metadata
|
|
11
|
+
t.datetime :clicked_at, null: false
|
|
12
|
+
|
|
13
|
+
t.timestamps
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
add_index :affiliate_tracker_clicks, :destination_url
|
|
17
|
+
add_index :affiliate_tracker_clicks, :clicked_at
|
|
18
|
+
add_index :affiliate_tracker_clicks, :created_at
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
AffiliateTracker.configure do |config|
|
|
4
|
+
# Your brand name (appears in utm_source)
|
|
5
|
+
# config.utm_source = "mybrand"
|
|
6
|
+
|
|
7
|
+
# Default medium (appears in utm_medium)
|
|
8
|
+
# config.utm_medium = "email"
|
|
9
|
+
|
|
10
|
+
# Referral param (adds ?ref=yourname to all links)
|
|
11
|
+
# config.ref_param = "yourname"
|
|
12
|
+
|
|
13
|
+
# Dashboard authentication (optional)
|
|
14
|
+
# config.authenticate_dashboard = -> {
|
|
15
|
+
# redirect_to main_app.login_path unless current_user&.admin?
|
|
16
|
+
# }
|
|
17
|
+
|
|
18
|
+
# Fallback URL for invalid/missing signatures (default: "/")
|
|
19
|
+
# String: static URL
|
|
20
|
+
# Proc: receives decoded payload Hash (unverified — treat as untrusted)
|
|
21
|
+
# config.fallback_url = "/"
|
|
22
|
+
# config.fallback_url = ->(payload) {
|
|
23
|
+
# slug = payload&.dig("shop")
|
|
24
|
+
# slug.present? ? "/#{slug}" : "/"
|
|
25
|
+
# }
|
|
26
|
+
end
|