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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/app/assets/javascripts/controllers/page_tracker_controller.js +41 -0
  3. data/app/assets/stylesheets/trackguard/admin.css +479 -0
  4. data/app/controllers/concerns/trackguard/page_tracker.rb +41 -0
  5. data/app/controllers/trackguard/admin/analytics_controller.rb +85 -0
  6. data/app/controllers/trackguard/admin/base_controller.rb +25 -0
  7. data/app/controllers/trackguard/admin/blocked_user_agents_controller.rb +27 -0
  8. data/app/controllers/trackguard/admin/dashboards_controller.rb +17 -0
  9. data/app/controllers/trackguard/admin/visitors_controller.rb +57 -0
  10. data/app/controllers/trackguard/admin/visits_controller.rb +17 -0
  11. data/app/controllers/trackguard/admin/whitelisted_ips_controller.rb +64 -0
  12. data/app/controllers/trackguard/page_views_controller.rb +18 -0
  13. data/app/helpers/trackguard/application_helper.rb +10 -0
  14. data/app/jobs/trackguard/detect_suspicious_visitors_job.rb +130 -0
  15. data/app/jobs/trackguard/track_page_view_job.rb +29 -0
  16. data/app/models/trackguard/blocked_user_agent.rb +16 -0
  17. data/app/models/trackguard/page_view.rb +17 -0
  18. data/app/models/trackguard/visitor.rb +24 -0
  19. data/app/models/trackguard/whitelisted_ip.rb +26 -0
  20. data/app/services/trackguard/application_service.rb +7 -0
  21. data/app/services/trackguard/page_view_recorder.rb +39 -0
  22. data/app/views/layouts/trackguard/admin.html.erb +68 -0
  23. data/app/views/trackguard/admin/dashboards/show.html.erb +234 -0
  24. data/app/views/trackguard/admin/visits/_pagination.html.erb +48 -0
  25. data/app/views/trackguard/admin/visits/index.html.erb +148 -0
  26. data/config/importmap.rb +1 -0
  27. data/config/routes.rb +14 -0
  28. data/lib/generators/trackguard/install_generator.rb +24 -0
  29. data/lib/generators/trackguard/templates/create_trackguard_tables.rb +48 -0
  30. data/lib/tasks/trackguard.rake +32 -0
  31. data/lib/trackguard/engine.rb +25 -0
  32. data/lib/trackguard/rack_attack.rb +31 -0
  33. data/lib/trackguard/version.rb +3 -0
  34. data/lib/trackguard.rb +40 -0
  35. data/trackguard.gemspec +18 -0
  36. 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>
@@ -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
@@ -0,0 +1,3 @@
1
+ module Trackguard
2
+ VERSION = "0.15.1".freeze
3
+ 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