trackguard 0.15.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/app/assets/javascripts/controllers/page_tracker_controller.js +41 -0
- data/app/assets/stylesheets/trackguard/admin.css +479 -0
- data/app/controllers/concerns/trackguard/page_tracker.rb +41 -0
- data/app/controllers/trackguard/admin/analytics_controller.rb +85 -0
- data/app/controllers/trackguard/admin/base_controller.rb +25 -0
- data/app/controllers/trackguard/admin/blocked_user_agents_controller.rb +27 -0
- data/app/controllers/trackguard/admin/dashboards_controller.rb +17 -0
- data/app/controllers/trackguard/admin/visitors_controller.rb +57 -0
- data/app/controllers/trackguard/admin/visits_controller.rb +17 -0
- data/app/controllers/trackguard/admin/whitelisted_ips_controller.rb +64 -0
- data/app/controllers/trackguard/page_views_controller.rb +18 -0
- data/app/helpers/trackguard/application_helper.rb +10 -0
- data/app/jobs/trackguard/detect_suspicious_visitors_job.rb +130 -0
- data/app/jobs/trackguard/track_page_view_job.rb +29 -0
- data/app/models/trackguard/blocked_user_agent.rb +16 -0
- data/app/models/trackguard/page_view.rb +17 -0
- data/app/models/trackguard/visitor.rb +24 -0
- data/app/models/trackguard/whitelisted_ip.rb +26 -0
- data/app/services/trackguard/application_service.rb +7 -0
- data/app/services/trackguard/page_view_recorder.rb +39 -0
- data/app/views/layouts/trackguard/admin.html.erb +68 -0
- data/app/views/trackguard/admin/dashboards/show.html.erb +234 -0
- data/app/views/trackguard/admin/visits/_pagination.html.erb +48 -0
- data/app/views/trackguard/admin/visits/index.html.erb +148 -0
- data/config/importmap.rb +1 -0
- data/config/routes.rb +14 -0
- data/lib/generators/trackguard/install_generator.rb +24 -0
- data/lib/generators/trackguard/templates/create_trackguard_tables.rb +48 -0
- data/lib/tasks/trackguard.rake +32 -0
- data/lib/trackguard/engine.rb +25 -0
- data/lib/trackguard/rack_attack.rb +31 -0
- data/lib/trackguard/version.rb +3 -0
- data/lib/trackguard.rb +40 -0
- data/trackguard.gemspec +18 -0
- metadata +102 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Trackguard
|
|
2
|
+
module Admin
|
|
3
|
+
class DashboardsController < BaseController
|
|
4
|
+
def show
|
|
5
|
+
@total_today = PageView.today.count
|
|
6
|
+
@total_week = PageView.this_week.count
|
|
7
|
+
@total_month = PageView.this_month.count
|
|
8
|
+
|
|
9
|
+
@top_pages = PageView.last_30.group(:path).order("count_all DESC").limit(5).count
|
|
10
|
+
@top_referrers = PageView.last_30.with_referrer.group(:referer).order("count_all DESC").limit(5).count
|
|
11
|
+
@top_sources = PageView.last_30.with_source.group(:source).order("count_all DESC").limit(5).count
|
|
12
|
+
|
|
13
|
+
@recent = PageView.order(created_at: :desc).limit(20).includes(visitor: :whitelisted_ip)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module Trackguard
|
|
2
|
+
module Admin
|
|
3
|
+
class VisitorsController < BaseController
|
|
4
|
+
skip_before_action :verify_authenticity_token, if: :valid_api_token?
|
|
5
|
+
before_action :set_visitor
|
|
6
|
+
|
|
7
|
+
rescue_from ActiveRecord::RecordNotFound do
|
|
8
|
+
respond_to do |format|
|
|
9
|
+
format.html { redirect_to dashboard_path, alert: "Visitor not found." }
|
|
10
|
+
format.json { render json: { error: "Visitor not found" }, status: :not_found }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def flag
|
|
15
|
+
if @visitor.update(
|
|
16
|
+
flagged_at: Time.current,
|
|
17
|
+
flag_reason: params[:flag_reason].presence,
|
|
18
|
+
flagged_by: params[:flagged_by].presence || Visitor::FLAGGED_BY.first
|
|
19
|
+
)
|
|
20
|
+
respond_to do |format|
|
|
21
|
+
format.html { redirect_back_or_to dashboard_path }
|
|
22
|
+
format.json { render json: { status: "ok", ip: @visitor.ip, flagged_at: @visitor.flagged_at } }
|
|
23
|
+
end
|
|
24
|
+
else
|
|
25
|
+
respond_to do |format|
|
|
26
|
+
format.html { redirect_back_or_to dashboard_path, alert: @visitor.errors.full_messages.join(", ") }
|
|
27
|
+
format.json { render json: { errors: @visitor.errors.full_messages }, status: :unprocessable_entity }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def unflag
|
|
33
|
+
@visitor.update!(flagged_at: nil, flag_reason: nil, flagged_by: nil)
|
|
34
|
+
respond_to do |format|
|
|
35
|
+
format.html { redirect_back_or_to dashboard_path }
|
|
36
|
+
format.json { render json: { status: "ok", ip: @visitor.ip } }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def authenticate_admin!
|
|
43
|
+
return if valid_api_token?
|
|
44
|
+
|
|
45
|
+
super
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def set_visitor
|
|
49
|
+
@visitor = if params[:ip].present?
|
|
50
|
+
Visitor.find_by!(ip: params[:ip])
|
|
51
|
+
else
|
|
52
|
+
Visitor.find(params[:id])
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Trackguard
|
|
2
|
+
module Admin
|
|
3
|
+
class VisitsController < BaseController
|
|
4
|
+
PER_PAGE = 20
|
|
5
|
+
|
|
6
|
+
def index
|
|
7
|
+
@page = [ (params[:page] || 1).to_i, 1 ].max
|
|
8
|
+
@total = PageView.count
|
|
9
|
+
@pages = (@total.to_f / PER_PAGE).ceil
|
|
10
|
+
@visits = PageView.order(created_at: :desc)
|
|
11
|
+
.limit(PER_PAGE)
|
|
12
|
+
.offset((@page - 1) * PER_PAGE)
|
|
13
|
+
.includes(visitor: :whitelisted_ip)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module Trackguard
|
|
2
|
+
module Admin
|
|
3
|
+
class WhitelistedIpsController < BaseController
|
|
4
|
+
skip_before_action :verify_authenticity_token, if: :valid_api_token?
|
|
5
|
+
before_action :set_visitor
|
|
6
|
+
|
|
7
|
+
rescue_from ActiveRecord::RecordNotFound do
|
|
8
|
+
respond_to do |format|
|
|
9
|
+
format.html { redirect_to dashboard_path, alert: "Visitor not found." }
|
|
10
|
+
format.json { render json: { error: "Visitor not found" }, status: :not_found }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create
|
|
15
|
+
record = WhitelistedIp.find_or_initialize_by(ip: @visitor.ip)
|
|
16
|
+
record.visitor = @visitor
|
|
17
|
+
record.expires_at = params[:expires_at].presence || 7.days.from_now
|
|
18
|
+
record.save!
|
|
19
|
+
respond_to do |format|
|
|
20
|
+
format.html { redirect_back_or_to dashboard_path }
|
|
21
|
+
format.json { render json: { status: "ok", ip: @visitor.ip, expires_at: record.expires_at } }
|
|
22
|
+
end
|
|
23
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
24
|
+
respond_to do |format|
|
|
25
|
+
format.html { redirect_back_or_to dashboard_path, alert: e.message }
|
|
26
|
+
format.json { render json: { status: "error", message: e.message }, status: :unprocessable_entity }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def destroy
|
|
31
|
+
record = @visitor.whitelisted_ip
|
|
32
|
+
|
|
33
|
+
if record
|
|
34
|
+
record.destroy!
|
|
35
|
+
respond_to do |format|
|
|
36
|
+
format.html { redirect_back_or_to dashboard_path }
|
|
37
|
+
format.json { render json: { status: "ok", ip: @visitor.ip } }
|
|
38
|
+
end
|
|
39
|
+
else
|
|
40
|
+
respond_to do |format|
|
|
41
|
+
format.html { redirect_back_or_to dashboard_path, alert: "No whitelist entry found." }
|
|
42
|
+
format.json { render json: { error: "Not whitelisted" }, status: :not_found }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def authenticate_admin!
|
|
50
|
+
return if valid_api_token?
|
|
51
|
+
|
|
52
|
+
super
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def set_visitor
|
|
56
|
+
@visitor = if params[:ip].present?
|
|
57
|
+
Visitor.find_by!(ip: params[:ip])
|
|
58
|
+
else
|
|
59
|
+
Visitor.find(params[:id])
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Trackguard
|
|
2
|
+
class PageViewsController < ApplicationController
|
|
3
|
+
def create
|
|
4
|
+
PageViewRecorder.call(
|
|
5
|
+
path: params[:path].to_s,
|
|
6
|
+
ip: request.remote_ip,
|
|
7
|
+
user_agent: request.user_agent.to_s,
|
|
8
|
+
referer: request.referer,
|
|
9
|
+
session_id: session.id.to_s,
|
|
10
|
+
trace_id: params[:trace_id].to_s.presence,
|
|
11
|
+
source: params[:ref].to_s.strip.downcase.first(64).presence,
|
|
12
|
+
initial: params[:initial] == true
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
head :no_content
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
module Trackguard
|
|
2
|
+
class DetectSuspiciousVisitorsJob < ApplicationJob
|
|
3
|
+
queue_as :default
|
|
4
|
+
|
|
5
|
+
HARD_FLAG_THRESHOLD = 50
|
|
6
|
+
HIGH_VOLUME_MIN = 20
|
|
7
|
+
MEDIUM_VOLUME_MIN = 10
|
|
8
|
+
FLAG_SCORE_THRESHOLD = 6
|
|
9
|
+
MIN_VIEWS = 3
|
|
10
|
+
|
|
11
|
+
WEIGHTS = {
|
|
12
|
+
high_volume: 4,
|
|
13
|
+
medium_volume: 2,
|
|
14
|
+
no_session: 3,
|
|
15
|
+
no_referer: 2
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def perform
|
|
19
|
+
recent_cutoff = 24.hours.ago
|
|
20
|
+
|
|
21
|
+
flag_shared_trace_id_visitors(recent_cutoff)
|
|
22
|
+
|
|
23
|
+
views_by_visitor = PageView
|
|
24
|
+
.where(created_at: recent_cutoff..)
|
|
25
|
+
.joins(:visitor)
|
|
26
|
+
.merge(Visitor.unflagged)
|
|
27
|
+
.preload(visitor: :whitelisted_ip)
|
|
28
|
+
.select(:visitor_id, :session_id, :referer, :path, :trace_id)
|
|
29
|
+
.group_by(&:visitor)
|
|
30
|
+
|
|
31
|
+
return if views_by_visitor.empty?
|
|
32
|
+
|
|
33
|
+
views_by_visitor.each do |visitor, views|
|
|
34
|
+
analyze_visitor(visitor, views)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
41
|
+
def analyze_visitor(visitor, views)
|
|
42
|
+
count = views.size
|
|
43
|
+
return if count.zero?
|
|
44
|
+
return if visitor.whitelisted_ip&.active?
|
|
45
|
+
|
|
46
|
+
if count >= HARD_FLAG_THRESHOLD
|
|
47
|
+
flag!(visitor, "#{count} page views in 24h (hard flag threshold)")
|
|
48
|
+
return
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if (reason = ua_flag_reason(visitor.user_agent))
|
|
52
|
+
flag!(visitor, reason)
|
|
53
|
+
return
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Don't flag casual visitors with very few views — on a single-page site,
|
|
57
|
+
# legitimate users naturally hit only "/" once or twice.
|
|
58
|
+
return if count < MIN_VIEWS
|
|
59
|
+
|
|
60
|
+
if views.all? { |pv| pv.session_id.nil? && pv.referer.nil? && pv.path == "/" }
|
|
61
|
+
flag!(visitor, "no session, no referrer, single root hit")
|
|
62
|
+
return
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
score = 0
|
|
66
|
+
reasons = []
|
|
67
|
+
|
|
68
|
+
if count >= HIGH_VOLUME_MIN
|
|
69
|
+
score += WEIGHTS[:high_volume]
|
|
70
|
+
reasons << "#{count} page views in 24h"
|
|
71
|
+
elsif count >= MEDIUM_VOLUME_MIN
|
|
72
|
+
score += WEIGHTS[:medium_volume]
|
|
73
|
+
reasons << "#{count} page views in 24h"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if blank_ratio(views, :session_id) > 0.8
|
|
77
|
+
score += WEIGHTS[:no_session]
|
|
78
|
+
reasons << "#{pct(views, :session_id)}% of views had no session"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if blank_ratio(views, :referer) > 0.0
|
|
82
|
+
score += WEIGHTS[:no_referer]
|
|
83
|
+
reasons << "#{pct(views, :referer)}% of views had no referer"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
return if score < FLAG_SCORE_THRESHOLD
|
|
87
|
+
|
|
88
|
+
flag!(visitor, reasons.join("; "))
|
|
89
|
+
end
|
|
90
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
91
|
+
|
|
92
|
+
def flag_shared_trace_id_visitors(cutoff)
|
|
93
|
+
shared = PageView
|
|
94
|
+
.where(created_at: cutoff..)
|
|
95
|
+
.where.not(trace_id: nil)
|
|
96
|
+
.group(:trace_id)
|
|
97
|
+
.having("COUNT(DISTINCT visitor_id) > 1")
|
|
98
|
+
.pluck(:trace_id)
|
|
99
|
+
|
|
100
|
+
return if shared.empty?
|
|
101
|
+
|
|
102
|
+
Visitor
|
|
103
|
+
.unflagged
|
|
104
|
+
.joins("LEFT OUTER JOIN trackguard_whitelisted_ips wi ON wi.visitor_id = trackguard_visitors.id")
|
|
105
|
+
.joins(:page_views)
|
|
106
|
+
.where(trackguard_page_views: { trace_id: shared, created_at: cutoff.. })
|
|
107
|
+
.where("wi.id IS NULL OR wi.expires_at <= ?", Time.current)
|
|
108
|
+
.distinct
|
|
109
|
+
.each do |visitor|
|
|
110
|
+
flag!(visitor, "trace_id shared across multiple visitors (cross-visitor bot detected)")
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def ua_flag_reason(user_agent)
|
|
115
|
+
"blank or minimal user-agent" if user_agent.blank? || user_agent.to_s.length < 10
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def flag!(visitor, reason)
|
|
119
|
+
visitor.update!(flagged_at: Time.current, flag_reason: reason, flagged_by: "claw:auto")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def blank_ratio(views, attr)
|
|
123
|
+
views.count { |v| v.public_send(attr).blank? }.to_f / views.size
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def pct(views, attr)
|
|
127
|
+
(blank_ratio(views, attr) * 100).round
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Trackguard
|
|
2
|
+
class TrackPageViewJob < ApplicationJob
|
|
3
|
+
queue_as :default
|
|
4
|
+
|
|
5
|
+
def perform(path:, ip:, user_agent:, referer:, session_id: nil, trace_id: nil, source: nil, initial: false)
|
|
6
|
+
hashed_session_id = Digest::SHA256.hexdigest(session_id) if session_id.present?
|
|
7
|
+
|
|
8
|
+
visitor = Visitor.find_or_create_by!(ip: ip) do |v|
|
|
9
|
+
v.user_agent = user_agent
|
|
10
|
+
v.first_seen_at = Time.current
|
|
11
|
+
v.last_seen_at = Time.current
|
|
12
|
+
end
|
|
13
|
+
visitor.update!(last_seen_at: Time.current, user_agent: user_agent)
|
|
14
|
+
|
|
15
|
+
if initial && trace_id.present?
|
|
16
|
+
existing = PageView.find_by(trace_id: trace_id, visitor: visitor)
|
|
17
|
+
if existing
|
|
18
|
+
if path.include?("#") && !existing.path.include?("#") && path.start_with?("#{existing.path}#")
|
|
19
|
+
existing.update!(path: path)
|
|
20
|
+
end
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
PageView.create_with(source:, referer:)
|
|
26
|
+
.find_or_create_by!(path:, user_agent:, session_id: hashed_session_id, trace_id:, visitor:)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Trackguard
|
|
2
|
+
class BlockedUserAgent < ApplicationRecord
|
|
3
|
+
self.table_name = "trackguard_blocked_user_agents"
|
|
4
|
+
|
|
5
|
+
CACHE_KEY = "trackguard/blocked_user_agent_patterns".freeze
|
|
6
|
+
|
|
7
|
+
validates :pattern, presence: true, uniqueness: true
|
|
8
|
+
|
|
9
|
+
def self.blocked?(user_agent)
|
|
10
|
+
patterns = Rails.cache.fetch(CACHE_KEY, expires_in: 10.minutes) do
|
|
11
|
+
pluck(:pattern)
|
|
12
|
+
end
|
|
13
|
+
patterns.any? { |p| user_agent.to_s.downcase.include?(p.downcase) }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Trackguard
|
|
2
|
+
class PageView < ApplicationRecord
|
|
3
|
+
self.table_name = "trackguard_page_views"
|
|
4
|
+
|
|
5
|
+
belongs_to :visitor, class_name: "Trackguard::Visitor"
|
|
6
|
+
|
|
7
|
+
validates :path, presence: true
|
|
8
|
+
|
|
9
|
+
scope :today, -> { where(created_at: Time.current.beginning_of_day..) }
|
|
10
|
+
scope :this_week, -> { where(created_at: 1.week.ago..) }
|
|
11
|
+
scope :this_month, -> { where(created_at: 1.month.ago..) }
|
|
12
|
+
scope :last_30, -> { where(created_at: 30.days.ago..) }
|
|
13
|
+
scope :last_24h, -> { where(created_at: 24.hours.ago..) }
|
|
14
|
+
scope :with_referrer, -> { where.not(referer: [ nil, "" ]) }
|
|
15
|
+
scope :with_source, -> { where.not(source: [ nil, "" ]) }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Trackguard
|
|
2
|
+
class Visitor < ApplicationRecord
|
|
3
|
+
self.table_name = "trackguard_visitors"
|
|
4
|
+
|
|
5
|
+
FLAGGED_BY = [ "User", "claw:auto" ].freeze
|
|
6
|
+
CACHE_KEY = "trackguard/flagged_ips".freeze
|
|
7
|
+
|
|
8
|
+
validates :flagged_by, inclusion: { in: FLAGGED_BY }, allow_blank: true
|
|
9
|
+
|
|
10
|
+
has_many :page_views, class_name: "Trackguard::PageView", foreign_key: "visitor_id"
|
|
11
|
+
has_one :whitelisted_ip, class_name: "Trackguard::WhitelistedIp", foreign_key: "visitor_id"
|
|
12
|
+
|
|
13
|
+
scope :unflagged, -> { where(flagged_at: nil) }
|
|
14
|
+
scope :flagged, -> { where.not(flagged_at: nil) }
|
|
15
|
+
|
|
16
|
+
def self.flagged?(ip)
|
|
17
|
+
flagged_ips = Rails.cache.fetch(CACHE_KEY, expires_in: 5.minutes) do
|
|
18
|
+
flagged.pluck(:ip)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
flagged_ips.include?(ip)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Trackguard
|
|
2
|
+
class WhitelistedIp < ApplicationRecord
|
|
3
|
+
self.table_name = "trackguard_whitelisted_ips"
|
|
4
|
+
|
|
5
|
+
CACHE_KEY = "trackguard/whitelisted_ips".freeze
|
|
6
|
+
|
|
7
|
+
belongs_to :visitor, class_name: "Trackguard::Visitor", optional: true
|
|
8
|
+
|
|
9
|
+
validates :ip, presence: true, uniqueness: true
|
|
10
|
+
validates :expires_at, presence: true
|
|
11
|
+
|
|
12
|
+
scope :active, -> { where(expires_at: Time.current..) }
|
|
13
|
+
|
|
14
|
+
def self.whitelisted?(ip)
|
|
15
|
+
active_ips = Rails.cache.fetch(CACHE_KEY, expires_in: 10.minutes) do
|
|
16
|
+
active.pluck(:ip)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
active_ips.include?(ip)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def active?
|
|
23
|
+
expires_at > Time.current
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Trackguard
|
|
2
|
+
class PageViewRecorder < ApplicationService
|
|
3
|
+
BOT_REGEX = /
|
|
4
|
+
Googlebot|Bingbot|Slurp|DuckDuckBot|Baidu|YandexBot|
|
|
5
|
+
facebookexternalhit|Twitterbot|LinkedInBot|
|
|
6
|
+
curl|wget|python-requests|python-urllib|
|
|
7
|
+
Go-http-client|libwww|Java|Ruby|
|
|
8
|
+
bot|crawl|spider
|
|
9
|
+
/ix
|
|
10
|
+
|
|
11
|
+
def initialize(path:, ip:, user_agent:, referer:, session_id:, trace_id:, source: nil, initial: false)
|
|
12
|
+
@path = path.to_s
|
|
13
|
+
@ip = ip
|
|
14
|
+
@user_agent = user_agent.to_s
|
|
15
|
+
@referer = referer
|
|
16
|
+
@session_id = session_id
|
|
17
|
+
@trace_id = trace_id
|
|
18
|
+
@source = source.presence
|
|
19
|
+
@initial = initial
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
return if BOT_REGEX.match?(@user_agent)
|
|
24
|
+
return if BlockedUserAgent.blocked?(@user_agent)
|
|
25
|
+
return if @path.blank? || @path.start_with?("/admin")
|
|
26
|
+
|
|
27
|
+
TrackPageViewJob.perform_later(
|
|
28
|
+
path: @path,
|
|
29
|
+
ip: @ip,
|
|
30
|
+
user_agent: @user_agent,
|
|
31
|
+
referer: @referer,
|
|
32
|
+
session_id: @session_id,
|
|
33
|
+
trace_id: @trace_id,
|
|
34
|
+
source: @source,
|
|
35
|
+
initial: @initial
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title><%= content_for?(:title) ? yield(:title) : "Trackguard" %></title>
|
|
7
|
+
<%= stylesheet_link_tag "trackguard/admin", media: "all" %>
|
|
8
|
+
</head>
|
|
9
|
+
<body class="tg-body">
|
|
10
|
+
<header class="tg-header">
|
|
11
|
+
<div class="tg-container">
|
|
12
|
+
<div class="tg-header__inner">
|
|
13
|
+
<span class="tg-brand">
|
|
14
|
+
<svg class="tg-brand__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 22" aria-hidden="true">
|
|
15
|
+
<defs>
|
|
16
|
+
<linearGradient id="tg-shield-grad" x1="0" y1="0" x2="0" y2="1">
|
|
17
|
+
<stop offset="0%" stop-color="#1e3a5f"/>
|
|
18
|
+
<stop offset="100%" stop-color="#0d1f3c"/>
|
|
19
|
+
</linearGradient>
|
|
20
|
+
</defs>
|
|
21
|
+
<!-- Shield -->
|
|
22
|
+
<path d="M10 1L19 4.5V11.5C19 16.4 15 20.3 10 21.5C5 20.3 1 16.4 1 11.5V4.5Z"
|
|
23
|
+
fill="url(#tg-shield-grad)" stroke="#3b82f6" stroke-width="1.25" stroke-linejoin="round"/>
|
|
24
|
+
<!-- Left rail -->
|
|
25
|
+
<line x1="7.5" y1="6" x2="7.5" y2="17" stroke="#60a5fa" stroke-width="1.6" stroke-linecap="round"/>
|
|
26
|
+
<!-- Right rail -->
|
|
27
|
+
<line x1="12.5" y1="6" x2="12.5" y2="17" stroke="#60a5fa" stroke-width="1.6" stroke-linecap="round"/>
|
|
28
|
+
<!-- Cross-tie 1 -->
|
|
29
|
+
<line x1="6.25" y1="8" x2="13.75" y2="8" stroke="#3b82f6" stroke-width="1.25" stroke-linecap="round"/>
|
|
30
|
+
<!-- Cross-tie 2 -->
|
|
31
|
+
<line x1="6.25" y1="11.5" x2="13.75" y2="11.5" stroke="#3b82f6" stroke-width="1.25" stroke-linecap="round"/>
|
|
32
|
+
<!-- Cross-tie 3 -->
|
|
33
|
+
<line x1="6.25" y1="15" x2="13.75" y2="15" stroke="#3b82f6" stroke-width="1.25" stroke-linecap="round"/>
|
|
34
|
+
</svg>
|
|
35
|
+
Trackguard
|
|
36
|
+
</span>
|
|
37
|
+
|
|
38
|
+
<a class="tg-back-link" href="<%= Trackguard.back_url %>">
|
|
39
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" aria-hidden="true">
|
|
40
|
+
<path stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
|
|
41
|
+
stroke-linejoin="round" fill="none" d="M10.5 3.5L5.5 8l5 4.5"/>
|
|
42
|
+
</svg>
|
|
43
|
+
<%= Trackguard.back_label %>
|
|
44
|
+
</a>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</header>
|
|
48
|
+
<nav class="tg-nav">
|
|
49
|
+
<div class="tg-container">
|
|
50
|
+
<a href="<%= dashboard_path %>" class="tg-nav__link<%= ' tg-nav__link--active' if request.path == dashboard_path %>">Dashboard</a>
|
|
51
|
+
<a href="<%= visits_path %>" class="tg-nav__link<%= ' tg-nav__link--active' if request.path.start_with?(visits_path) %>">All Visits</a>
|
|
52
|
+
</div>
|
|
53
|
+
</nav>
|
|
54
|
+
<main class="tg-main">
|
|
55
|
+
<div class="tg-container">
|
|
56
|
+
<%= yield %>
|
|
57
|
+
</div>
|
|
58
|
+
</main>
|
|
59
|
+
<script>
|
|
60
|
+
(function () {
|
|
61
|
+
var key = 'tg-scroll';
|
|
62
|
+
var saved = sessionStorage.getItem(key);
|
|
63
|
+
if (saved !== null) { window.scrollTo(0, parseInt(saved, 10)); sessionStorage.removeItem(key); }
|
|
64
|
+
document.addEventListener('submit', function () { sessionStorage.setItem(key, window.scrollY); });
|
|
65
|
+
})();
|
|
66
|
+
</script>
|
|
67
|
+
</body>
|
|
68
|
+
</html>
|