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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0de5b2d1e0a03f4935d196a8485f5a919c65a766f43a2f6c1d116c489a71fa47
4
+ data.tar.gz: 1c78129586cdd55b4498374e8116ae99655354e938b5b439a76791188bf02379
5
+ SHA512:
6
+ metadata.gz: '0758f1a980d0e3fca8f6bf787f2a313ff785defcbd9057f3d7fb05b4814f77e0401a5d15e01e6bc8b9b15469f2e9b3cec1cf70e45bf7ab182de93c2bf4950d40'
7
+ data.tar.gz: 92526230b8ae4edbe68f39dfc9560243c702da99ae542db8c051151186edb45e36618d0ecfce63b2eafdae21711a778fbebe87c9464ce56d7b8ed700d49c7369
@@ -0,0 +1,41 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = { url: { type: String, default: "" } }
5
+ connect() {
6
+ this.lastTracked = null
7
+ this.boundLoad = () => this.trackCurrent()
8
+ this.boundHash = () => this.trackCurrent()
9
+
10
+ document.addEventListener("turbo:load", this.boundLoad)
11
+ window.addEventListener("hashchange", this.boundHash)
12
+
13
+ // Cover initial load if turbo:load already fired before connect()
14
+ // Pass initial=true so the job can deduplicate/enrich against the server-side record
15
+ this.trackCurrent(true)
16
+ }
17
+
18
+ disconnect() {
19
+ document.removeEventListener("turbo:load", this.boundLoad)
20
+ window.removeEventListener("hashchange", this.boundHash)
21
+ }
22
+
23
+ trackCurrent(initial = false) {
24
+ const path = window.location.pathname + window.location.hash
25
+ if (path === this.lastTracked) return
26
+ this.lastTracked = path
27
+ this.track(path, initial)
28
+ }
29
+
30
+ track(path, initial = false) {
31
+ const token = document.querySelector('meta[name="csrf-token"]')?.content
32
+ const traceId = document.querySelector('meta[name="trace-id"]')?.content
33
+ const url = this.urlValue || document.querySelector('meta[name="trackguard-url"]')?.content
34
+ const ref = new URLSearchParams(window.location.search).get("ref")
35
+ fetch(url, {
36
+ method: "POST",
37
+ headers: { "Content-Type": "application/json", "X-CSRF-Token": token || "" },
38
+ body: JSON.stringify({ path, trace_id: traceId, ref, initial })
39
+ })
40
+ }
41
+ }
@@ -0,0 +1,479 @@
1
+ /* ── Reset ─────────────────────────────────────────────────────────── */
2
+ *, *::before, *::after { box-sizing: border-box; }
3
+
4
+ /* ── Base ──────────────────────────────────────────────────────────── */
5
+ .tg-body {
6
+ margin: 0;
7
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Inter", sans-serif;
8
+ font-size: 16px;
9
+ line-height: 1.5;
10
+ background-color: #07101f;
11
+ color: #e0e0e0;
12
+ -webkit-font-smoothing: antialiased;
13
+ min-height: 100vh;
14
+ }
15
+
16
+ /* ── Layout ────────────────────────────────────────────────────────── */
17
+ .tg-container {
18
+ max-width: 1100px;
19
+ margin: 0 auto;
20
+ padding: 0 1.5rem;
21
+ }
22
+
23
+ /* ── Header ────────────────────────────────────────────────────────── */
24
+ .tg-header {
25
+ background: #0d1829;
26
+ border-bottom: 1px solid #1c2d4a;
27
+ padding: 0.875rem 0;
28
+ }
29
+
30
+ .tg-header__inner {
31
+ display: flex;
32
+ align-items: center;
33
+ gap: 0.5rem;
34
+ }
35
+
36
+ .tg-brand {
37
+ display: flex;
38
+ align-items: center;
39
+ gap: 0.5rem;
40
+ font-size: 0.9375rem;
41
+ font-weight: 600;
42
+ letter-spacing: 0.03em;
43
+ color: #60a5fa;
44
+ text-decoration: none;
45
+ flex: 1;
46
+ }
47
+
48
+ .tg-brand__logo {
49
+ width: 20px;
50
+ height: 22px;
51
+ flex-shrink: 0;
52
+ filter: drop-shadow(0 0 4px rgba(59, 130, 246, 0.35));
53
+ }
54
+
55
+ /* ── Back link ─────────────────────────────────────────────────────── */
56
+ .tg-back-link {
57
+ display: flex;
58
+ align-items: center;
59
+ gap: 0.3rem;
60
+ font-size: 0.8125rem;
61
+ color: #666666;
62
+ text-decoration: none;
63
+ transition: color 0.15s;
64
+ white-space: nowrap;
65
+ }
66
+
67
+ .tg-back-link svg {
68
+ width: 14px;
69
+ height: 14px;
70
+ flex-shrink: 0;
71
+ }
72
+
73
+ .tg-back-link:hover {
74
+ color: #e0e0e0;
75
+ }
76
+
77
+ /* ── Nav ───────────────────────────────────────────────────────────── */
78
+ .tg-nav {
79
+ background: #0d1829;
80
+ border-bottom: 1px solid #1c2d4a;
81
+ }
82
+
83
+ .tg-nav > .tg-container {
84
+ display: flex;
85
+ }
86
+
87
+ .tg-nav__link {
88
+ display: inline-block;
89
+ padding: 0.5rem 1rem;
90
+ font-size: 0.8125rem;
91
+ color: #666666;
92
+ text-decoration: none;
93
+ border-bottom: 2px solid transparent;
94
+ transition: color 0.15s;
95
+ }
96
+
97
+ .tg-nav__link:hover { color: #e0e0e0; }
98
+
99
+ .tg-nav__link--active {
100
+ color: #60a5fa;
101
+ border-bottom-color: #60a5fa;
102
+ }
103
+
104
+ /* ── Main ──────────────────────────────────────────────────────────── */
105
+ .tg-main {
106
+ padding: 1.25rem 0 4rem;
107
+ }
108
+
109
+ /* ── Page header ───────────────────────────────────────────────────── */
110
+ .tg-page-header {
111
+ margin-bottom: 1.75rem;
112
+ }
113
+
114
+ .tg-page-title {
115
+ margin: 0;
116
+ font-size: 1.25rem;
117
+ font-weight: 700;
118
+ letter-spacing: -0.02em;
119
+ color: #f0f0f0;
120
+ }
121
+
122
+ /* ── Stats ─────────────────────────────────────────────────────────── */
123
+ .tg-stats {
124
+ display: grid;
125
+ grid-template-columns: repeat(3, 1fr);
126
+ gap: 1rem;
127
+ margin-bottom: 2rem;
128
+ }
129
+
130
+ .tg-stat {
131
+ background: #0d1829;
132
+ border: 1px solid #1c2d4a;
133
+ border-radius: 8px;
134
+ padding: 1.25rem;
135
+ }
136
+
137
+ .tg-stat__label {
138
+ font-size: 0.6875rem;
139
+ font-weight: 500;
140
+ text-transform: uppercase;
141
+ letter-spacing: 0.08em;
142
+ color: #666666;
143
+ margin: 0 0 0.25rem;
144
+ }
145
+
146
+ .tg-stat__value {
147
+ font-size: 1.625rem;
148
+ font-weight: 700;
149
+ color: #f0f0f0;
150
+ margin: 0;
151
+ }
152
+
153
+ /* ── Page title count ──────────────────────────────────────────────── */
154
+ .tg-page-title__count {
155
+ font-size: 0.9375rem;
156
+ font-weight: 400;
157
+ color: #666666;
158
+ margin-left: 0.5rem;
159
+ }
160
+
161
+ /* ── Panel grid ────────────────────────────────────────────────────── */
162
+ .tg-panels {
163
+ display: grid;
164
+ grid-template-columns: repeat(3, 1fr);
165
+ gap: 1rem;
166
+ margin-bottom: 1rem;
167
+ }
168
+
169
+ .tg-panel {
170
+ background: #0d1829;
171
+ border: 1px solid #1c2d4a;
172
+ border-radius: 8px;
173
+ padding: 1rem;
174
+ margin-bottom: 1rem;
175
+ overflow: hidden;
176
+ }
177
+
178
+ .tg-panel__heading {
179
+ font-size: 0.6875rem;
180
+ font-weight: 600;
181
+ text-transform: uppercase;
182
+ letter-spacing: 0.08em;
183
+ color: #f0f0f0;
184
+ margin: 0 0 0.75rem;
185
+ }
186
+
187
+ .tg-panel__sub {
188
+ font-weight: 400;
189
+ color: #666666;
190
+ text-transform: none;
191
+ letter-spacing: 0;
192
+ }
193
+
194
+ .tg-panel .tg-table { margin-bottom: 0; }
195
+
196
+ .tg-panel tr:last-child .tg-td,
197
+ .tg-panel tr:last-child .tg-td--bare { border-bottom: none; }
198
+
199
+ /* ── Pagination ────────────────────────────────────────────────────── */
200
+ .tg-pagination {
201
+ display: flex;
202
+ align-items: center;
203
+ gap: 0.25rem;
204
+ padding: 1rem 0 0;
205
+ flex-wrap: wrap;
206
+ }
207
+
208
+ .tg-pagination__link {
209
+ padding: 0.25rem 0.625rem;
210
+ border-radius: 4px;
211
+ font-size: 0.8125rem;
212
+ color: #e0e0e0;
213
+ text-decoration: none;
214
+ background: #1c2d4a;
215
+ }
216
+
217
+ .tg-pagination__link:hover { background: #243a5e; }
218
+
219
+ .tg-pagination__link--active {
220
+ background: #60a5fa;
221
+ color: #07101f;
222
+ font-weight: 600;
223
+ }
224
+
225
+ .tg-pagination__link--disabled {
226
+ opacity: 0.4;
227
+ pointer-events: none;
228
+ }
229
+
230
+ .tg-pagination__ellipsis {
231
+ padding: 0.25rem 0.25rem;
232
+ font-size: 0.8125rem;
233
+ color: #666666;
234
+ }
235
+
236
+ /* ── Table ─────────────────────────────────────────────────────────── */
237
+ .tg-table {
238
+ width: 100%;
239
+ border-collapse: collapse;
240
+ font-size: 0.875rem;
241
+ margin-bottom: 2rem;
242
+ }
243
+
244
+ .tg-th {
245
+ text-align: left;
246
+ padding: 0.625rem 0.875rem;
247
+ font-size: 0.6875rem;
248
+ font-weight: 500;
249
+ text-transform: uppercase;
250
+ letter-spacing: 0.08em;
251
+ color: #666666;
252
+ border-bottom: 1px solid #1c2d4a;
253
+ }
254
+
255
+ .tg-th--right { text-align: right; }
256
+
257
+ .tg-td {
258
+ padding: 0.75rem 0.875rem;
259
+ border-bottom: 1px solid #1c2d4a;
260
+ color: #f0f0f0;
261
+ font-weight: 500;
262
+ }
263
+
264
+ .tg-td--num {
265
+ text-align: right;
266
+ color: #e0e0e0;
267
+ font-weight: 400;
268
+ }
269
+
270
+ .tg-td--break { word-break: break-all; }
271
+
272
+ .tg-td--bare {
273
+ padding: 0;
274
+ border-bottom: 1px solid #1c2d4a;
275
+ }
276
+
277
+ /* ── Empty state ───────────────────────────────────────────────────── */
278
+ .tg-empty {
279
+ font-size: 0.9375rem;
280
+ color: #666666;
281
+ padding: 1rem 0 2rem;
282
+ margin: 0;
283
+ }
284
+
285
+ /* ── Accordion row ─────────────────────────────────────────────────── */
286
+ .tg-row--flagged {
287
+ background: rgba(127, 29, 29, 0.15);
288
+ }
289
+
290
+ .tg-row--whitelisted {
291
+ background: rgba(6, 78, 59, 0.15);
292
+ }
293
+
294
+ .tg-summary {
295
+ display: flex;
296
+ align-items: center;
297
+ gap: 0.75rem;
298
+ padding: 0.875rem;
299
+ cursor: pointer;
300
+ list-style: none;
301
+ }
302
+
303
+ .tg-summary::-webkit-details-marker { display: none; }
304
+
305
+ .tg-summary__path {
306
+ flex: 1;
307
+ min-width: 0;
308
+ overflow: hidden;
309
+ text-overflow: ellipsis;
310
+ white-space: nowrap;
311
+ color: #f0f0f0;
312
+ font-weight: 500;
313
+ font-size: 0.875rem;
314
+ }
315
+
316
+ .tg-summary__ip {
317
+ font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
318
+ font-size: 0.75rem;
319
+ color: #e0e0e0;
320
+ width: 8.5rem;
321
+ flex-shrink: 0;
322
+ }
323
+
324
+ .tg-summary__flag {
325
+ width: 1.5rem;
326
+ text-align: center;
327
+ flex-shrink: 0;
328
+ color: #666666;
329
+ font-size: 0.875rem;
330
+ }
331
+
332
+ .tg-summary__whitelist {
333
+ width: 1.5rem;
334
+ text-align: center;
335
+ flex-shrink: 0;
336
+ color: #666666;
337
+ font-size: 0.875rem;
338
+ }
339
+
340
+ .tg-summary__time {
341
+ font-size: 0.8125rem;
342
+ color: #e0e0e0;
343
+ width: 7.5rem;
344
+ flex-shrink: 0;
345
+ }
346
+
347
+ .tg-flag-icon {
348
+ width: 1rem;
349
+ height: 1rem;
350
+ color: #ef4444;
351
+ vertical-align: middle;
352
+ }
353
+
354
+ .tg-whitelist-icon {
355
+ width: 1rem;
356
+ height: 1rem;
357
+ color: #22c55e;
358
+ vertical-align: middle;
359
+ }
360
+
361
+ /* ── Detail panel ──────────────────────────────────────────────────── */
362
+ .tg-detail {
363
+ padding: 0 1rem 1rem 1rem;
364
+ background: #0d1829;
365
+ }
366
+
367
+ .tg-detail__grid {
368
+ display: grid;
369
+ grid-template-columns: repeat(2, 1fr);
370
+ gap: 1.25rem;
371
+ padding-top: 0.75rem;
372
+ }
373
+
374
+ .tg-detail__group-label {
375
+ font-size: 0.6875rem;
376
+ font-weight: 500;
377
+ text-transform: uppercase;
378
+ letter-spacing: 0.08em;
379
+ color: #666666;
380
+ margin: 0 0 0.5rem;
381
+ }
382
+
383
+ /* ── Definition list ───────────────────────────────────────────────── */
384
+ .tg-dl {
385
+ display: flex;
386
+ flex-direction: column;
387
+ gap: 0.3rem;
388
+ }
389
+
390
+ .tg-dl__row {
391
+ display: flex;
392
+ gap: 0.5rem;
393
+ font-size: 0.75rem;
394
+ }
395
+
396
+ .tg-dl__term {
397
+ color: #666666;
398
+ width: 6rem;
399
+ flex-shrink: 0;
400
+ }
401
+
402
+ .tg-dl__def {
403
+ color: #e0e0e0;
404
+ margin: 0;
405
+ }
406
+
407
+ .tg-dl__def--mono {
408
+ font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
409
+ word-break: break-all;
410
+ }
411
+
412
+ .tg-dl__def--break { word-break: break-all; }
413
+
414
+ .tg-dl__def--flagged { color: #fca5a5; }
415
+
416
+ .tg-dl__def--muted { color: #666666; }
417
+
418
+ .tg-dl__def--whitelisted { color: #6ee7b7; }
419
+
420
+ /* ── Flag actions ──────────────────────────────────────────────────── */
421
+ .tg-detail__actions {
422
+ display: flex;
423
+ align-items: center;
424
+ gap: 0.5rem;
425
+ padding: 0.75rem 0 0;
426
+ border-top: 1px solid #1c2d4a;
427
+ margin-top: 0.75rem;
428
+ }
429
+
430
+ .tg-flag-form {
431
+ display: flex;
432
+ align-items: center;
433
+ gap: 0.5rem;
434
+ flex: 1;
435
+ }
436
+
437
+ .tg-input {
438
+ flex: 1;
439
+ background: #07101f;
440
+ border: 1px solid #1c2d4a;
441
+ border-radius: 4px;
442
+ color: #e0e0e0;
443
+ font-size: 0.8125rem;
444
+ padding: 0.375rem 0.625rem;
445
+ outline: none;
446
+ }
447
+
448
+ .tg-input:focus { border-color: #60a5fa; }
449
+ .tg-input::placeholder { color: #444; }
450
+
451
+ .tg-btn {
452
+ border: none;
453
+ border-radius: 4px;
454
+ cursor: pointer;
455
+ font-size: 0.8125rem;
456
+ font-weight: 500;
457
+ padding: 0.375rem 0.75rem;
458
+ white-space: nowrap;
459
+ }
460
+
461
+ .tg-btn--danger { background: #7f1d1d; color: #fca5a5; }
462
+ .tg-btn--danger:hover { background: #991b1b; }
463
+
464
+ .tg-btn--ghost { background: #1c2d4a; color: #e0e0e0; }
465
+ .tg-btn--ghost:hover { background: #243a5e; }
466
+
467
+ .tg-btn--whitelist { background: #064e3b; color: #6ee7b7; }
468
+ .tg-btn--whitelist:hover { background: #065f46; }
469
+
470
+ /* ── Responsive ────────────────────────────────────────────────────── */
471
+ @media (max-width: 900px) {
472
+ .tg-panels { grid-template-columns: 1fr; }
473
+ }
474
+
475
+ @media (max-width: 640px) {
476
+ .tg-stats { grid-template-columns: 1fr; }
477
+ .tg-detail__grid { grid-template-columns: 1fr; }
478
+ .tg-summary__ip { display: none; }
479
+ }
@@ -0,0 +1,41 @@
1
+ module Trackguard
2
+ module PageTracker
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ before_action :set_trace_id
7
+ end
8
+
9
+ module ClassMethods
10
+ def track_page_views(**)
11
+ after_action(:track_page_view, **)
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def set_trace_id
18
+ @trace_id = SecureRandom.uuid
19
+ end
20
+
21
+ def track_page_view
22
+ return unless request.get? || request.head?
23
+ return unless request.format.html?
24
+
25
+ PageViewRecorder.call(
26
+ path: request.path,
27
+ ip: request.remote_ip,
28
+ user_agent: request.user_agent.to_s,
29
+ referer: request.referer,
30
+ session_id: session.id.to_s,
31
+ trace_id: @trace_id,
32
+ source: extract_source
33
+ )
34
+ end
35
+
36
+ def extract_source
37
+ raw = params[:ref].presence || params[:utm_source].presence
38
+ raw && raw.strip.downcase.first(64)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,85 @@
1
+ module Trackguard
2
+ module Admin
3
+ class AnalyticsController < BaseController
4
+ skip_before_action :verify_authenticity_token, if: :valid_api_token?
5
+
6
+ # rubocop:disable Metrics/AbcSize
7
+ def show
8
+ @total_today = PageView.today.count
9
+ @total_week = PageView.this_week.count
10
+ @total_month = PageView.this_month.count
11
+
12
+ base = time_scope
13
+ @top_pages = base.group(:path).order("count_all DESC").limit(10).count
14
+ @top_referrers = base.with_referrer.group(:referer).order("count_all DESC").limit(10).count
15
+ @top_sources = base.with_source.group(:source).order("count_all DESC").limit(10).count
16
+
17
+ @recent = visitor_filtered(
18
+ time_scope(PageView.order(created_at: :desc).limit(20).includes(visitor: :whitelisted_ip))
19
+ )
20
+
21
+ render json: {
22
+ totals: { today: @total_today, week: @total_week, month: @total_month },
23
+ top_pages: @top_pages,
24
+ top_referrers: @top_referrers,
25
+ top_sources: @top_sources,
26
+ recent: @recent.map do |pv|
27
+ {
28
+ path: pv.path,
29
+ ip: pv.visitor&.ip,
30
+ flagged_at: pv.visitor.flagged_at,
31
+ flagged_by: pv.visitor.flagged_by,
32
+ whitelisted: pv.visitor.whitelisted_ip&.active? || false,
33
+ user_agent: pv.user_agent,
34
+ session_id: pv.session_id,
35
+ trace_id: pv.trace_id,
36
+ referer: pv.referer,
37
+ source: pv.source,
38
+ created_at: pv.created_at
39
+ }
40
+ end
41
+ }
42
+ end
43
+ # rubocop:enable Metrics/AbcSize
44
+
45
+ private
46
+
47
+ def authenticate_admin!
48
+ return if valid_api_token?
49
+
50
+ super
51
+ end
52
+
53
+ def time_scope(base = PageView.all)
54
+ params[:since].present? ? base.where(created_at: parsed_since..) : base.last_30
55
+ end
56
+
57
+ def parsed_since
58
+ @parsed_since ||= begin
59
+ Date.parse(params[:since])
60
+ rescue ArgumentError, TypeError
61
+ 30.days.ago.to_date
62
+ end
63
+ end
64
+
65
+ def visitor_filtered(scope)
66
+ if params.key?(:flagged)
67
+ visitor_scope = cast_bool(params[:flagged]) ? Visitor.flagged : Visitor.unflagged
68
+ scope = scope.joins(:visitor).merge(visitor_scope)
69
+ end
70
+ if params.key?(:whitelisted)
71
+ scope = if cast_bool(params[:whitelisted])
72
+ scope.joins(visitor: :whitelisted_ip).merge(WhitelistedIp.active)
73
+ else
74
+ scope.where.not(visitor_id: Visitor.joins(:whitelisted_ip).merge(WhitelistedIp.active).select(:id))
75
+ end
76
+ end
77
+ scope
78
+ end
79
+
80
+ def cast_bool(val)
81
+ ActiveRecord::Type::Boolean.new.cast(val)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,25 @@
1
+ module Trackguard
2
+ module Admin
3
+ class BaseController < ActionController::Base
4
+ layout -> { Trackguard.admin_layout }
5
+
6
+ before_action :authenticate_admin!
7
+
8
+ private
9
+
10
+ def authenticate_admin!
11
+ instance_exec(&Trackguard.authenticate_admin_with)
12
+ end
13
+
14
+ def valid_api_token?
15
+ expected = Trackguard.api_token
16
+ return false unless expected.present?
17
+
18
+ token = request.headers["Authorization"]&.then { |h| h[/\ABearer (.+)\z/, 1] }
19
+ return false unless token.present?
20
+
21
+ ActiveSupport::SecurityUtils.secure_compare(token, expected)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,27 @@
1
+ module Trackguard
2
+ module Admin
3
+ class BlockedUserAgentsController < BaseController
4
+ skip_before_action :verify_authenticity_token, if: :valid_api_token?
5
+
6
+ def index
7
+ render json: BlockedUserAgent.order(:pattern).pluck(:pattern)
8
+ end
9
+
10
+ def create
11
+ record = BlockedUserAgent.find_or_create_by!(pattern: params.fetch(:pattern))
12
+ Rails.cache.delete(BlockedUserAgent::CACHE_KEY)
13
+ render json: { status: "ok", pattern: record.pattern }
14
+ rescue ActionController::ParameterMissing, ActiveRecord::RecordInvalid => e
15
+ render json: { status: "error", message: e.message }, status: :unprocessable_entity
16
+ end
17
+
18
+ private
19
+
20
+ def authenticate_admin!
21
+ return if valid_api_token?
22
+
23
+ super
24
+ end
25
+ end
26
+ end
27
+ end