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,234 @@
|
|
|
1
|
+
<% content_for :title, "Trackguard" %>
|
|
2
|
+
|
|
3
|
+
<%# Summary cards %>
|
|
4
|
+
<div class="tg-stats">
|
|
5
|
+
<div class="tg-stat">
|
|
6
|
+
<p class="tg-stat__label">Today</p>
|
|
7
|
+
<p class="tg-stat__value"><%= @total_today %></p>
|
|
8
|
+
</div>
|
|
9
|
+
<div class="tg-stat">
|
|
10
|
+
<p class="tg-stat__label">This Week</p>
|
|
11
|
+
<p class="tg-stat__value"><%= @total_week %></p>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="tg-stat">
|
|
14
|
+
<p class="tg-stat__label">This Month</p>
|
|
15
|
+
<p class="tg-stat__value"><%= @total_month %></p>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<%# Top pages / referrers / sources grid %>
|
|
20
|
+
<div class="tg-panels">
|
|
21
|
+
<div class="tg-panel">
|
|
22
|
+
<h2 class="tg-panel__heading">Top Pages <span class="tg-panel__sub">last 30 days</span></h2>
|
|
23
|
+
<% if @top_pages.any? %>
|
|
24
|
+
<table class="tg-table">
|
|
25
|
+
<thead>
|
|
26
|
+
<tr>
|
|
27
|
+
<th class="tg-th">Path</th>
|
|
28
|
+
<th class="tg-th tg-th--right">Views</th>
|
|
29
|
+
</tr>
|
|
30
|
+
</thead>
|
|
31
|
+
<tbody>
|
|
32
|
+
<% @top_pages.each do |path, count| %>
|
|
33
|
+
<tr>
|
|
34
|
+
<td class="tg-td"><%= path %></td>
|
|
35
|
+
<td class="tg-td tg-td--num"><%= count %></td>
|
|
36
|
+
</tr>
|
|
37
|
+
<% end %>
|
|
38
|
+
</tbody>
|
|
39
|
+
</table>
|
|
40
|
+
<% else %>
|
|
41
|
+
<p class="tg-empty">No data yet.</p>
|
|
42
|
+
<% end %>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div class="tg-panel">
|
|
46
|
+
<h2 class="tg-panel__heading">Top Referrers <span class="tg-panel__sub">last 30 days</span></h2>
|
|
47
|
+
<% if @top_referrers.any? %>
|
|
48
|
+
<table class="tg-table">
|
|
49
|
+
<thead>
|
|
50
|
+
<tr>
|
|
51
|
+
<th class="tg-th">Referrer</th>
|
|
52
|
+
<th class="tg-th tg-th--right">Views</th>
|
|
53
|
+
</tr>
|
|
54
|
+
</thead>
|
|
55
|
+
<tbody>
|
|
56
|
+
<% @top_referrers.each do |referer, count| %>
|
|
57
|
+
<tr>
|
|
58
|
+
<td class="tg-td tg-td--break"><%= referer %></td>
|
|
59
|
+
<td class="tg-td tg-td--num"><%= count %></td>
|
|
60
|
+
</tr>
|
|
61
|
+
<% end %>
|
|
62
|
+
</tbody>
|
|
63
|
+
</table>
|
|
64
|
+
<% else %>
|
|
65
|
+
<p class="tg-empty">No referrer data yet.</p>
|
|
66
|
+
<% end %>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div class="tg-panel">
|
|
70
|
+
<h2 class="tg-panel__heading">Top Sources <span class="tg-panel__sub">last 30 days</span></h2>
|
|
71
|
+
<% if @top_sources.any? %>
|
|
72
|
+
<table class="tg-table">
|
|
73
|
+
<thead>
|
|
74
|
+
<tr>
|
|
75
|
+
<th class="tg-th">Source</th>
|
|
76
|
+
<th class="tg-th tg-th--right">Views</th>
|
|
77
|
+
</tr>
|
|
78
|
+
</thead>
|
|
79
|
+
<tbody>
|
|
80
|
+
<% @top_sources.each do |source, count| %>
|
|
81
|
+
<tr>
|
|
82
|
+
<td class="tg-td"><%= source %></td>
|
|
83
|
+
<td class="tg-td tg-td--num"><%= count %></td>
|
|
84
|
+
</tr>
|
|
85
|
+
<% end %>
|
|
86
|
+
</tbody>
|
|
87
|
+
</table>
|
|
88
|
+
<% else %>
|
|
89
|
+
<p class="tg-empty">No source data yet.</p>
|
|
90
|
+
<% end %>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<%# Recent visits %>
|
|
95
|
+
<div class="tg-panel">
|
|
96
|
+
<h2 class="tg-panel__heading">Recent Visits</h2>
|
|
97
|
+
<% if @recent.any? %>
|
|
98
|
+
<table class="tg-table">
|
|
99
|
+
<tbody>
|
|
100
|
+
<% @recent.each do |pv| %>
|
|
101
|
+
<% visitor = pv.visitor %>
|
|
102
|
+
<% flagged = visitor&.flagged_at.present? %>
|
|
103
|
+
<% whitelisted = visitor&.whitelisted_ip&.active? %>
|
|
104
|
+
<% row_classes = [ ("tg-row--flagged" if flagged), ("tg-row--whitelisted" if whitelisted) ].compact.join(" ") %>
|
|
105
|
+
<tr class="<%= row_classes %>">
|
|
106
|
+
<td class="tg-td--bare">
|
|
107
|
+
<details>
|
|
108
|
+
<summary class="tg-summary">
|
|
109
|
+
<span class="tg-summary__path"><%= pv.path %></span>
|
|
110
|
+
<span class="tg-summary__ip"><%= visitor&.ip || "—" %></span>
|
|
111
|
+
<span class="tg-summary__flag">
|
|
112
|
+
<% if flagged %>
|
|
113
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="tg-flag-icon" title="Flagged">
|
|
114
|
+
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
|
115
|
+
</svg>
|
|
116
|
+
<% else %>
|
|
117
|
+
—
|
|
118
|
+
<% end %>
|
|
119
|
+
</span>
|
|
120
|
+
<span class="tg-summary__whitelist">
|
|
121
|
+
<% if whitelisted %>
|
|
122
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="tg-whitelist-icon" title="Whitelisted">
|
|
123
|
+
<path fill-rule="evenodd" d="M9.661 2.237a.531.531 0 01.678 0 11.947 11.947 0 007.078 2.749.5.5 0 01.479.425c.069.52.104 1.05.104 1.589 0 5.162-3.26 9.563-7.834 11.256a.48.48 0 01-.332 0C5.26 16.563 2 12.162 2 7c0-.538.035-1.069.104-1.589a.5.5 0 01.48-.425 11.947 11.947 0 007.077-2.749zm2.55 5.513a.75.75 0 00-1.06-1.06L9 8.79l-.84-.84a.75.75 0 10-1.061 1.06l1.37 1.37a.75.75 0 001.06 0l2.682-2.67z" clip-rule="evenodd" />
|
|
124
|
+
</svg>
|
|
125
|
+
<% else %>
|
|
126
|
+
—
|
|
127
|
+
<% end %>
|
|
128
|
+
</span>
|
|
129
|
+
<span class="tg-summary__time"><%= pv.created_at.strftime("%b %-d, %H:%M") %></span>
|
|
130
|
+
</summary>
|
|
131
|
+
<div class="tg-detail">
|
|
132
|
+
<% if visitor %>
|
|
133
|
+
<div class="tg-detail__actions">
|
|
134
|
+
<% if whitelisted %>
|
|
135
|
+
<%= button_to "Remove from whitelist", unwhitelist_visitor_path,
|
|
136
|
+
params: { id: visitor.id },
|
|
137
|
+
method: :patch,
|
|
138
|
+
class: "tg-btn tg-btn--ghost",
|
|
139
|
+
data: { confirm: "Remove whitelist entry for #{visitor.ip}?" } %>
|
|
140
|
+
<% else %>
|
|
141
|
+
<%= button_to "Whitelist", whitelist_visitor_path,
|
|
142
|
+
params: { id: visitor.id },
|
|
143
|
+
method: :patch,
|
|
144
|
+
class: "tg-btn tg-btn--whitelist",
|
|
145
|
+
data: { confirm: "Whitelist #{visitor.ip} for 7 days?" } %>
|
|
146
|
+
<% end %>
|
|
147
|
+
<% if flagged %>
|
|
148
|
+
<%= button_to "Unflag", unflag_visitor_path, params: { id: visitor.id }, method: :patch, class: "tg-btn tg-btn--ghost" %>
|
|
149
|
+
<% else %>
|
|
150
|
+
<%= form_with url: flag_visitor_path, method: :patch, class: "tg-flag-form" do |f| %>
|
|
151
|
+
<%= hidden_field_tag :id, visitor.id %>
|
|
152
|
+
<%= f.submit "Flag", class: "tg-btn tg-btn--danger" %>
|
|
153
|
+
<%= f.text_field :flag_reason, placeholder: "Flag reason (optional)", class: "tg-input", autocomplete: "off" %>
|
|
154
|
+
<% end %>
|
|
155
|
+
<% end %>
|
|
156
|
+
</div>
|
|
157
|
+
<% end %>
|
|
158
|
+
<div class="tg-detail__grid">
|
|
159
|
+
<div>
|
|
160
|
+
<p class="tg-detail__group-label">Visitor</p>
|
|
161
|
+
<div class="tg-dl">
|
|
162
|
+
<div class="tg-dl__row">
|
|
163
|
+
<span class="tg-dl__term">IP</span>
|
|
164
|
+
<span class="tg-dl__def tg-dl__def--mono"><%= visitor&.ip || "—" %></span>
|
|
165
|
+
</div>
|
|
166
|
+
<div class="tg-dl__row">
|
|
167
|
+
<span class="tg-dl__term">First seen</span>
|
|
168
|
+
<span class="tg-dl__def"><%= visitor&.first_seen_at&.strftime("%b %-d %Y, %H:%M") || "—" %></span>
|
|
169
|
+
</div>
|
|
170
|
+
<div class="tg-dl__row">
|
|
171
|
+
<span class="tg-dl__term">Last seen</span>
|
|
172
|
+
<span class="tg-dl__def"><%= visitor&.last_seen_at&.strftime("%b %-d %Y, %H:%M") || "—" %></span>
|
|
173
|
+
</div>
|
|
174
|
+
<div class="tg-dl__row">
|
|
175
|
+
<span class="tg-dl__term">User agent</span>
|
|
176
|
+
<span class="tg-dl__def tg-dl__def--break"><%= visitor&.user_agent.presence || "—" %></span>
|
|
177
|
+
</div>
|
|
178
|
+
<div class="tg-dl__row">
|
|
179
|
+
<span class="tg-dl__term">Flag status</span>
|
|
180
|
+
<% if flagged %>
|
|
181
|
+
<span class="tg-dl__def tg-dl__def--flagged">
|
|
182
|
+
Flagged <%= visitor.flagged_at.strftime("%b %-d %Y, %H:%M") %>
|
|
183
|
+
<% if visitor.flagged_by.present? %> by <strong><%= visitor.flagged_by %></strong><% end %>
|
|
184
|
+
<% if visitor.flag_reason.present? %> — <%= visitor.flag_reason %><% end %>
|
|
185
|
+
</span>
|
|
186
|
+
<% else %>
|
|
187
|
+
<span class="tg-dl__def tg-dl__def--muted">Not flagged</span>
|
|
188
|
+
<% end %>
|
|
189
|
+
</div>
|
|
190
|
+
<div class="tg-dl__row">
|
|
191
|
+
<span class="tg-dl__term">Whitelist</span>
|
|
192
|
+
<% if whitelisted %>
|
|
193
|
+
<span class="tg-dl__def tg-dl__def--whitelisted">
|
|
194
|
+
Active until <%= visitor.whitelisted_ip.expires_at.strftime("%b %-d %Y, %H:%M") %>
|
|
195
|
+
</span>
|
|
196
|
+
<% else %>
|
|
197
|
+
<span class="tg-dl__def tg-dl__def--muted">Not whitelisted</span>
|
|
198
|
+
<% end %>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
<div>
|
|
203
|
+
<p class="tg-detail__group-label">This Visit</p>
|
|
204
|
+
<div class="tg-dl">
|
|
205
|
+
<div class="tg-dl__row">
|
|
206
|
+
<span class="tg-dl__term">Session</span>
|
|
207
|
+
<span class="tg-dl__def tg-dl__def--mono"><%= pv.session_id.presence || "—" %></span>
|
|
208
|
+
</div>
|
|
209
|
+
<div class="tg-dl__row">
|
|
210
|
+
<span class="tg-dl__term">Trace ID</span>
|
|
211
|
+
<span class="tg-dl__def tg-dl__def--mono"><%= pv.trace_id.presence || "—" %></span>
|
|
212
|
+
</div>
|
|
213
|
+
<div class="tg-dl__row">
|
|
214
|
+
<span class="tg-dl__term">Referrer</span>
|
|
215
|
+
<span class="tg-dl__def tg-dl__def--break"><%= pv.referer.presence || "—" %></span>
|
|
216
|
+
</div>
|
|
217
|
+
<div class="tg-dl__row">
|
|
218
|
+
<span class="tg-dl__term">Source</span>
|
|
219
|
+
<span class="tg-dl__def"><%= pv.source.presence || "—" %></span>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</details>
|
|
226
|
+
</td>
|
|
227
|
+
</tr>
|
|
228
|
+
<% end %>
|
|
229
|
+
</tbody>
|
|
230
|
+
</table>
|
|
231
|
+
<% else %>
|
|
232
|
+
<p class="tg-empty">No visits recorded yet.</p>
|
|
233
|
+
<% end %>
|
|
234
|
+
</div>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<% if @pages > 1 %>
|
|
2
|
+
<% window_start = [ @page - 1, 2 ].max %>
|
|
3
|
+
<% window_end = [ @page + 1, @pages - 1 ].min %>
|
|
4
|
+
<nav class="tg-pagination" aria-label="Pagination">
|
|
5
|
+
<% if @page > 1 %>
|
|
6
|
+
<a href="<%= visits_path(page: @page - 1) %>" class="tg-pagination__link">← Prev</a>
|
|
7
|
+
<% else %>
|
|
8
|
+
<span class="tg-pagination__link tg-pagination__link--disabled">← Prev</span>
|
|
9
|
+
<% end %>
|
|
10
|
+
|
|
11
|
+
<%# First page — always shown %>
|
|
12
|
+
<% if @page == 1 %>
|
|
13
|
+
<span class="tg-pagination__link tg-pagination__link--active">1</span>
|
|
14
|
+
<% else %>
|
|
15
|
+
<a href="<%= visits_path(page: 1) %>" class="tg-pagination__link">1</a>
|
|
16
|
+
<% end %>
|
|
17
|
+
|
|
18
|
+
<% if window_start > 2 %>
|
|
19
|
+
<span class="tg-pagination__ellipsis">…</span>
|
|
20
|
+
<% end %>
|
|
21
|
+
|
|
22
|
+
<%# Sliding window around current page %>
|
|
23
|
+
<% (window_start..window_end).each do |n| %>
|
|
24
|
+
<% if n == @page %>
|
|
25
|
+
<span class="tg-pagination__link tg-pagination__link--active"><%= n %></span>
|
|
26
|
+
<% else %>
|
|
27
|
+
<a href="<%= visits_path(page: n) %>" class="tg-pagination__link"><%= n %></a>
|
|
28
|
+
<% end %>
|
|
29
|
+
<% end %>
|
|
30
|
+
|
|
31
|
+
<% if window_end < @pages - 1 %>
|
|
32
|
+
<span class="tg-pagination__ellipsis">…</span>
|
|
33
|
+
<% end %>
|
|
34
|
+
|
|
35
|
+
<%# Last page — always shown %>
|
|
36
|
+
<% if @page == @pages %>
|
|
37
|
+
<span class="tg-pagination__link tg-pagination__link--active"><%= @pages %></span>
|
|
38
|
+
<% else %>
|
|
39
|
+
<a href="<%= visits_path(page: @pages) %>" class="tg-pagination__link"><%= @pages %></a>
|
|
40
|
+
<% end %>
|
|
41
|
+
|
|
42
|
+
<% if @page < @pages %>
|
|
43
|
+
<a href="<%= visits_path(page: @page + 1) %>" class="tg-pagination__link">Next →</a>
|
|
44
|
+
<% else %>
|
|
45
|
+
<span class="tg-pagination__link tg-pagination__link--disabled">Next →</span>
|
|
46
|
+
<% end %>
|
|
47
|
+
</nav>
|
|
48
|
+
<% end %>
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
<% content_for :title, "All Visits – Trackguard" %>
|
|
2
|
+
|
|
3
|
+
<div class="tg-page-header">
|
|
4
|
+
<h1 class="tg-page-title">All Visits <span class="tg-page-title__count"><%= @total %></span></h1>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<div class="tg-panel">
|
|
8
|
+
<% if @visits.any? %>
|
|
9
|
+
<%= render "pagination" %>
|
|
10
|
+
<table class="tg-table">
|
|
11
|
+
<tbody>
|
|
12
|
+
<% @visits.each do |pv| %>
|
|
13
|
+
<% visitor = pv.visitor %>
|
|
14
|
+
<% flagged = visitor&.flagged_at.present? %>
|
|
15
|
+
<% whitelisted = visitor&.whitelisted_ip&.active? %>
|
|
16
|
+
<% row_classes = [ ("tg-row--flagged" if flagged), ("tg-row--whitelisted" if whitelisted) ].compact.join(" ") %>
|
|
17
|
+
<tr class="<%= row_classes %>">
|
|
18
|
+
<td class="tg-td--bare">
|
|
19
|
+
<details>
|
|
20
|
+
<summary class="tg-summary">
|
|
21
|
+
<span class="tg-summary__path"><%= pv.path %></span>
|
|
22
|
+
<span class="tg-summary__ip"><%= visitor&.ip || "—" %></span>
|
|
23
|
+
<span class="tg-summary__flag">
|
|
24
|
+
<% if flagged %>
|
|
25
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="tg-flag-icon" title="Flagged">
|
|
26
|
+
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
|
27
|
+
</svg>
|
|
28
|
+
<% else %>
|
|
29
|
+
—
|
|
30
|
+
<% end %>
|
|
31
|
+
</span>
|
|
32
|
+
<span class="tg-summary__whitelist">
|
|
33
|
+
<% if whitelisted %>
|
|
34
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="tg-whitelist-icon" title="Whitelisted">
|
|
35
|
+
<path fill-rule="evenodd" d="M9.661 2.237a.531.531 0 01.678 0 11.947 11.947 0 007.078 2.749.5.5 0 01.479.425c.069.52.104 1.05.104 1.589 0 5.162-3.26 9.563-7.834 11.256a.48.48 0 01-.332 0C5.26 16.563 2 12.162 2 7c0-.538.035-1.069.104-1.589a.5.5 0 01.48-.425 11.947 11.947 0 007.077-2.749zm2.55 5.513a.75.75 0 00-1.06-1.06L9 8.79l-.84-.84a.75.75 0 10-1.061 1.06l1.37 1.37a.75.75 0 001.06 0l2.682-2.67z" clip-rule="evenodd" />
|
|
36
|
+
</svg>
|
|
37
|
+
<% else %>
|
|
38
|
+
—
|
|
39
|
+
<% end %>
|
|
40
|
+
</span>
|
|
41
|
+
<span class="tg-summary__time"><%= pv.created_at.strftime("%b %-d, %H:%M") %></span>
|
|
42
|
+
</summary>
|
|
43
|
+
<div class="tg-detail">
|
|
44
|
+
<% if visitor %>
|
|
45
|
+
<div class="tg-detail__actions">
|
|
46
|
+
<% if whitelisted %>
|
|
47
|
+
<%= button_to "Remove from whitelist", unwhitelist_visitor_path,
|
|
48
|
+
params: { id: visitor.id },
|
|
49
|
+
method: :patch,
|
|
50
|
+
class: "tg-btn tg-btn--ghost",
|
|
51
|
+
data: { confirm: "Remove whitelist entry for #{visitor.ip}?" } %>
|
|
52
|
+
<% else %>
|
|
53
|
+
<%= button_to "Whitelist", whitelist_visitor_path,
|
|
54
|
+
params: { id: visitor.id },
|
|
55
|
+
method: :patch,
|
|
56
|
+
class: "tg-btn tg-btn--whitelist",
|
|
57
|
+
data: { confirm: "Whitelist #{visitor.ip} for 7 days?" } %>
|
|
58
|
+
<% end %>
|
|
59
|
+
<% if flagged %>
|
|
60
|
+
<%= button_to "Unflag", unflag_visitor_path, params: { id: visitor.id }, method: :patch, class: "tg-btn tg-btn--ghost" %>
|
|
61
|
+
<% else %>
|
|
62
|
+
<%= form_with url: flag_visitor_path, method: :patch, class: "tg-flag-form" do |f| %>
|
|
63
|
+
<%= hidden_field_tag :id, visitor.id %>
|
|
64
|
+
<%= f.submit "Flag", class: "tg-btn tg-btn--danger" %>
|
|
65
|
+
<%= f.text_field :flag_reason, placeholder: "Flag reason (optional)", class: "tg-input", autocomplete: "off" %>
|
|
66
|
+
<% end %>
|
|
67
|
+
<% end %>
|
|
68
|
+
</div>
|
|
69
|
+
<% end %>
|
|
70
|
+
<div class="tg-detail__grid">
|
|
71
|
+
<div>
|
|
72
|
+
<p class="tg-detail__group-label">Visitor</p>
|
|
73
|
+
<div class="tg-dl">
|
|
74
|
+
<div class="tg-dl__row">
|
|
75
|
+
<span class="tg-dl__term">IP</span>
|
|
76
|
+
<span class="tg-dl__def tg-dl__def--mono"><%= visitor&.ip || "—" %></span>
|
|
77
|
+
</div>
|
|
78
|
+
<div class="tg-dl__row">
|
|
79
|
+
<span class="tg-dl__term">First seen</span>
|
|
80
|
+
<span class="tg-dl__def"><%= visitor&.first_seen_at&.strftime("%b %-d %Y, %H:%M") || "—" %></span>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="tg-dl__row">
|
|
83
|
+
<span class="tg-dl__term">Last seen</span>
|
|
84
|
+
<span class="tg-dl__def"><%= visitor&.last_seen_at&.strftime("%b %-d %Y, %H:%M") || "—" %></span>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="tg-dl__row">
|
|
87
|
+
<span class="tg-dl__term">User agent</span>
|
|
88
|
+
<span class="tg-dl__def tg-dl__def--break"><%= visitor&.user_agent.presence || "—" %></span>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="tg-dl__row">
|
|
91
|
+
<span class="tg-dl__term">Flag status</span>
|
|
92
|
+
<% if flagged %>
|
|
93
|
+
<span class="tg-dl__def tg-dl__def--flagged">
|
|
94
|
+
Flagged <%= visitor.flagged_at.strftime("%b %-d %Y, %H:%M") %>
|
|
95
|
+
<% if visitor.flagged_by.present? %> by <strong><%= visitor.flagged_by %></strong><% end %>
|
|
96
|
+
<% if visitor.flag_reason.present? %> — <%= visitor.flag_reason %><% end %>
|
|
97
|
+
</span>
|
|
98
|
+
<% else %>
|
|
99
|
+
<span class="tg-dl__def tg-dl__def--muted">Not flagged</span>
|
|
100
|
+
<% end %>
|
|
101
|
+
</div>
|
|
102
|
+
<div class="tg-dl__row">
|
|
103
|
+
<span class="tg-dl__term">Whitelist</span>
|
|
104
|
+
<% if whitelisted %>
|
|
105
|
+
<span class="tg-dl__def tg-dl__def--whitelisted">
|
|
106
|
+
Active until <%= visitor.whitelisted_ip.expires_at.strftime("%b %-d %Y, %H:%M") %>
|
|
107
|
+
</span>
|
|
108
|
+
<% else %>
|
|
109
|
+
<span class="tg-dl__def tg-dl__def--muted">Not whitelisted</span>
|
|
110
|
+
<% end %>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
<div>
|
|
115
|
+
<p class="tg-detail__group-label">This Visit</p>
|
|
116
|
+
<div class="tg-dl">
|
|
117
|
+
<div class="tg-dl__row">
|
|
118
|
+
<span class="tg-dl__term">Session</span>
|
|
119
|
+
<span class="tg-dl__def tg-dl__def--mono"><%= pv.session_id.presence || "—" %></span>
|
|
120
|
+
</div>
|
|
121
|
+
<div class="tg-dl__row">
|
|
122
|
+
<span class="tg-dl__term">Trace ID</span>
|
|
123
|
+
<span class="tg-dl__def tg-dl__def--mono"><%= pv.trace_id.presence || "—" %></span>
|
|
124
|
+
</div>
|
|
125
|
+
<div class="tg-dl__row">
|
|
126
|
+
<span class="tg-dl__term">Referrer</span>
|
|
127
|
+
<span class="tg-dl__def tg-dl__def--break"><%= pv.referer.presence || "—" %></span>
|
|
128
|
+
</div>
|
|
129
|
+
<div class="tg-dl__row">
|
|
130
|
+
<span class="tg-dl__term">Source</span>
|
|
131
|
+
<span class="tg-dl__def"><%= pv.source.presence || "—" %></span>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</details>
|
|
138
|
+
</td>
|
|
139
|
+
</tr>
|
|
140
|
+
<% end %>
|
|
141
|
+
</tbody>
|
|
142
|
+
</table>
|
|
143
|
+
|
|
144
|
+
<%= render "pagination" %>
|
|
145
|
+
<% else %>
|
|
146
|
+
<p class="tg-empty">No visits recorded yet.</p>
|
|
147
|
+
<% end %>
|
|
148
|
+
</div>
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pin_all_from File.expand_path("../app/assets/javascripts/controllers", __dir__), under: "controllers"
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Trackguard::Engine.routes.draw do
|
|
2
|
+
post "/page_views", to: "page_views#create"
|
|
3
|
+
|
|
4
|
+
scope "/admin", module: "admin" do
|
|
5
|
+
resource :dashboard, only: :show
|
|
6
|
+
resource :analytics, only: :show
|
|
7
|
+
resources :visits, only: :index
|
|
8
|
+
resources :blocked_user_agents, only: %i[index create]
|
|
9
|
+
patch "visitors/flag", to: "visitors#flag", as: :flag_visitor
|
|
10
|
+
patch "visitors/unflag", to: "visitors#unflag", as: :unflag_visitor
|
|
11
|
+
patch "visitors/whitelist", to: "whitelisted_ips#create", as: :whitelist_visitor
|
|
12
|
+
patch "visitors/unwhitelist", to: "whitelisted_ips#destroy", as: :unwhitelist_visitor
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
|
|
4
|
+
module Trackguard
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
include Rails::Generators::Migration
|
|
7
|
+
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
def self.next_migration_number(dirname)
|
|
11
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create_migration_file
|
|
15
|
+
migration_template "create_trackguard_tables.rb", "db/migrate/create_trackguard_tables.rb"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def print_next_steps
|
|
19
|
+
say "\nNext steps:", :green
|
|
20
|
+
say " 1. rails db:migrate"
|
|
21
|
+
say " 2. rails trackguard:seed_blocked_user_agents"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
class CreateTrackguardTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :trackguard_visitors do |t|
|
|
4
|
+
t.string :ip
|
|
5
|
+
t.string :user_agent
|
|
6
|
+
t.datetime :first_seen_at, null: false
|
|
7
|
+
t.datetime :last_seen_at, null: false
|
|
8
|
+
t.datetime :flagged_at
|
|
9
|
+
t.string :flag_reason
|
|
10
|
+
t.string :flagged_by
|
|
11
|
+
t.timestamps
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
add_index :trackguard_visitors, :ip, unique: true
|
|
15
|
+
|
|
16
|
+
create_table :trackguard_page_views do |t|
|
|
17
|
+
t.string :path, null: false
|
|
18
|
+
t.string :user_agent
|
|
19
|
+
t.string :referer
|
|
20
|
+
t.string :session_id
|
|
21
|
+
t.string :trace_id
|
|
22
|
+
t.string :source
|
|
23
|
+
t.references :visitor, null: false, foreign_key: { to_table: :trackguard_visitors }
|
|
24
|
+
t.datetime :created_at, null: false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
add_index :trackguard_page_views, :path
|
|
28
|
+
add_index :trackguard_page_views, :created_at
|
|
29
|
+
add_index :trackguard_page_views, :source
|
|
30
|
+
|
|
31
|
+
create_table :trackguard_whitelisted_ips do |t|
|
|
32
|
+
t.string :ip, null: false
|
|
33
|
+
t.datetime :expires_at, null: false
|
|
34
|
+
t.references :visitor, foreign_key: { to_table: :trackguard_visitors }
|
|
35
|
+
t.timestamps
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
add_index :trackguard_whitelisted_ips, :ip, unique: true
|
|
39
|
+
add_index :trackguard_whitelisted_ips, :expires_at
|
|
40
|
+
|
|
41
|
+
create_table :trackguard_blocked_user_agents do |t|
|
|
42
|
+
t.string :pattern, null: false
|
|
43
|
+
t.timestamps
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
add_index :trackguard_blocked_user_agents, :pattern, unique: true
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
namespace :trackguard do
|
|
2
|
+
desc "Seed default blocked user agent patterns into trackguard_blocked_user_agents"
|
|
3
|
+
task seed_blocked_user_agents: :environment do
|
|
4
|
+
patterns = [
|
|
5
|
+
# Generic scanners & vulnerability tools
|
|
6
|
+
"masscan", "zgrab", "nmap", "nikto", "sqlmap", "nuclei",
|
|
7
|
+
"gobuster", "dirbuster", "wfuzz", "ffuf", "burpsuite",
|
|
8
|
+
"acunetix", "nessus", "openvas", "w3af", "skipfish", "arachni",
|
|
9
|
+
# Search engine crawlers
|
|
10
|
+
"googleother", "googlebot", "bingbot",
|
|
11
|
+
# SEO & data harvesting bots
|
|
12
|
+
"semrushbot", "ahrefsbot", "mj12bot", "dotbot", "blexbot",
|
|
13
|
+
"petalbot", "bytespider", "claudebot", "gptbot", "ccbot",
|
|
14
|
+
# Headless/automation browsers
|
|
15
|
+
"headlesschrome", "phantomjs",
|
|
16
|
+
# Generic scraper/crawler signals
|
|
17
|
+
"scrapy", "python-requests", "go-http-client", "okhttp",
|
|
18
|
+
"curl/", "wget/",
|
|
19
|
+
# Old/legacy clients
|
|
20
|
+
"konqueror/4", "jakarta", "java/",
|
|
21
|
+
# Other
|
|
22
|
+
"fasthttp", "palo alto", "cortex xpanse"
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
inserted = patterns.count do |p|
|
|
26
|
+
Trackguard::BlockedUserAgent.find_or_create_by!(pattern: p).previously_new_record?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
puts "Done: #{inserted} inserted, #{patterns.size - inserted} already existed " \
|
|
30
|
+
"(#{Trackguard::BlockedUserAgent.count} total)"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trackguard
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace Trackguard
|
|
6
|
+
|
|
7
|
+
initializer "trackguard.helpers" do
|
|
8
|
+
ActiveSupport.on_load(:action_controller) do
|
|
9
|
+
helper Trackguard::ApplicationHelper
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
initializer "trackguard.assets" do |app|
|
|
14
|
+
app.config.assets.precompile += %w[trackguard/admin.css] if app.config.respond_to?(:assets)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
initializer "trackguard.importmap", before: "importmap" do |app|
|
|
18
|
+
app.config.importmap.paths << root.join("config/importmap.rb") if app.config.respond_to?(:importmap)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
config.after_initialize do
|
|
22
|
+
Trackguard::RackAttack.configure
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack/attack"
|
|
4
|
+
|
|
5
|
+
module Trackguard
|
|
6
|
+
module RackAttack
|
|
7
|
+
def self.configure
|
|
8
|
+
::Rack::Attack.safelist("trackguard/allow local") do |req|
|
|
9
|
+
[ "127.0.0.1", "::1" ].include?(req.ip)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
::Rack::Attack.safelist("trackguard/allow whitelisted ips") do |req|
|
|
13
|
+
Trackguard::WhitelistedIp.whitelisted?(req.ip)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
::Rack::Attack.blocklist("trackguard/block known scanners") do |req|
|
|
17
|
+
Trackguard::BlockedUserAgent.blocked?(req.user_agent)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
::Rack::Attack.blocklist("trackguard/flagged visitors") do |req|
|
|
21
|
+
Trackguard::Visitor.flagged?(req.ip)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
::Rack::Attack.throttle(
|
|
25
|
+
"trackguard/requests by ip",
|
|
26
|
+
limit: Trackguard.throttle_limit,
|
|
27
|
+
period: Trackguard.throttle_period, &:ip
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/trackguard.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "trackguard/version"
|
|
4
|
+
require "trackguard/engine"
|
|
5
|
+
require "trackguard/rack_attack"
|
|
6
|
+
|
|
7
|
+
module Trackguard
|
|
8
|
+
class << self
|
|
9
|
+
attr_writer :authenticate_admin_with, :admin_layout, :back_url, :back_label, :api_token, :throttle_limit,
|
|
10
|
+
:throttle_period
|
|
11
|
+
|
|
12
|
+
def authenticate_admin_with
|
|
13
|
+
@authenticate_admin_with ||= proc {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def admin_layout
|
|
17
|
+
@admin_layout ||= "trackguard/admin"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def back_url
|
|
21
|
+
@back_url ||= "/admin"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def back_label
|
|
25
|
+
@back_label ||= "Back to app"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def api_token
|
|
29
|
+
@api_token.to_s
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def throttle_limit
|
|
33
|
+
@throttle_limit ||= 100
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def throttle_period
|
|
37
|
+
@throttle_period ||= 60
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|