skadi 0.4.0.beta.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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +41 -0
  3. data/LICENSE.md +22 -0
  4. data/README.md +111 -0
  5. data/app/assets/builds/dashboard.css +2 -0
  6. data/app/assets/builds/dashboard.js +36 -0
  7. data/app/assets/builds/skadi.js +1 -0
  8. data/app/controllers/skadi/application_controller.rb +4 -0
  9. data/app/controllers/skadi/asset_controller.rb +28 -0
  10. data/app/controllers/skadi/dashboard_controller.rb +105 -0
  11. data/app/controllers/skadi/tracking_controller.rb +106 -0
  12. data/app/helpers/skadi/application_helper.rb +38 -0
  13. data/app/models/skadi/application_record.rb +5 -0
  14. data/app/models/skadi/dashboard.rb +83 -0
  15. data/app/models/skadi/dashboard_query.rb +314 -0
  16. data/app/models/skadi/dashboard_validator.rb +269 -0
  17. data/app/models/skadi/demographic.rb +31 -0
  18. data/app/models/skadi/event.rb +33 -0
  19. data/app/models/skadi/helpers/sql.rb +86 -0
  20. data/app/models/skadi/schema.rb +135 -0
  21. data/app/models/skadi/view.rb +9 -0
  22. data/app/models/skadi/visit.rb +59 -0
  23. data/app/views/skadi/dashboard/_layout.html.erb +21 -0
  24. data/app/views/skadi/dashboard/show.html.erb +18 -0
  25. data/config/routes.rb +14 -0
  26. data/lib/generators/skadi/install/USAGE +20 -0
  27. data/lib/generators/skadi/install/install_generator.rb +46 -0
  28. data/lib/generators/skadi/install/templates/skadi_migration.rb.erb +111 -0
  29. data/lib/skadi/analytics.rb +35 -0
  30. data/lib/skadi/anonymity_set.rb +38 -0
  31. data/lib/skadi/configuration.rb +232 -0
  32. data/lib/skadi/controller_delegate.rb +322 -0
  33. data/lib/skadi/cookie_manager.rb +81 -0
  34. data/lib/skadi/engine.rb +38 -0
  35. data/lib/skadi/url.rb +66 -0
  36. data/lib/skadi/user_agent.rb +458 -0
  37. data/lib/skadi/version.rb +3 -0
  38. data/lib/skadi.rb +26 -0
  39. metadata +203 -0
data/lib/skadi/url.rb ADDED
@@ -0,0 +1,66 @@
1
+ module Skadi
2
+ # Helper functions for generating and redacting URLs
3
+ module Url
4
+ # Formats the path for Skadi views. Note that the path here uses PATH_INFO, which does not include the query string or fragment.
5
+ # @param request [ActionDispatch::Request]
6
+ # @return [String]
7
+ def self.view_path_from_request(request)
8
+ path = +""
9
+
10
+ if Skadi.configuration.store_domain_in_views
11
+ path << request.host_with_port
12
+ end
13
+
14
+ # Normalise the path by removing any trailing slashes
15
+ path << ((request.path == "/" || request.path == "") ? "/" : request.path.chomp("/"))
16
+
17
+ path
18
+ end
19
+
20
+ # @param query_params [Hash, ActiveSupport::HashWithIndifferentAccess]
21
+ # @return [Hash]
22
+ def self.whitelist_query_params(query_params)
23
+ # Normalise the input to a Hash with symbolic keys
24
+ query_params = query_params.to_h.symbolize_keys
25
+
26
+ return query_params unless Skadi.configuration.use_query_param_whitelist
27
+
28
+ whitelist = Skadi.configuration.query_param_whitelist
29
+ return {} if whitelist.empty?
30
+
31
+ query_params.slice(*whitelist)
32
+ end
33
+
34
+ # Strips non-whitelisted query params and normalises URLs
35
+ # @param url [String]
36
+ # @return [String, nil]
37
+ def self.redact_and_normalise_url(url)
38
+ return nil unless url.is_a?(String) && url.present?
39
+
40
+ uri = URI.parse(url[0, Skadi.configuration.max_url_length])
41
+ return nil if uri.opaque
42
+
43
+ query_params = Rack::Utils.parse_nested_query(uri.query) if uri.query.present?
44
+ param_string = whitelist_query_params(query_params).to_query if query_params.present?
45
+
46
+ result = +""
47
+
48
+ # Only record interesting schemes, e.g. "android-app://"
49
+ result += "#{uri.scheme}://" if uri.scheme.present? && ![ "http", "https" ].include?(uri.scheme)
50
+
51
+ result += uri.host if uri.host.present?
52
+
53
+ # Only include port if it's non-standard
54
+ result << ":#{uri.port}" if uri.port != uri.default_port
55
+
56
+ # Normalise the trailing slash
57
+ result << ((uri.path == "" || uri.path == "/") ? "/" : uri.path.chomp("/")) unless uri.path.nil?
58
+
59
+ result << (param_string.present? ? "?#{param_string}" : "")
60
+
61
+ return result
62
+ rescue URI::InvalidURIError, Rack::QueryParser::ParameterTypeError, Rack::QueryParser::QueryLimitError
63
+ return nil
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,458 @@
1
+ module Skadi
2
+ # A minimal user agent parser, designed for speed rather than completeness, aiming to detect the most common browsers
3
+ # and operating systems.
4
+ #
5
+ # Why implement a custom user agent parsing script over an existing library?
6
+ #
7
+ # Firstly, to eliminate the transient dependencies consumers of this gem are exposed to.
8
+ #
9
+ # Secondly, existing libraries have relatively poor performance. Unlike other solutions, which use a large number of
10
+ # regular expressions to detect browsers, this parser tokenises the user agent and uses a lookup table to identify the
11
+ # majority of browsers with O(1) performance, using regular expressions as fallback. See the `user_agent_test.rb` file
12
+ # for performance statistics.
13
+ #
14
+ # Thirdly, the user agent parsing gems are years out of date. Modern browsers and modern bots are not properly
15
+ # detected, potentially skewing the analytics data collected.
16
+ class UserAgent
17
+ def initialize(user_agent)
18
+ @user_agent = (user_agent || "")[0, 2048]
19
+ end
20
+
21
+ def browser
22
+ parse_browser if @browser.nil?
23
+
24
+ @browser
25
+ end
26
+
27
+ def browser_version
28
+ parse_browser if @browser_version.nil?
29
+
30
+ @browser_version
31
+ end
32
+
33
+ def engine
34
+ parse_engine if @engine.nil?
35
+
36
+ @engine
37
+ end
38
+
39
+ def engine_version
40
+ parse_engine if @engine_version.nil?
41
+
42
+ @engine_version
43
+ end
44
+
45
+ def os
46
+ parse_os if @os.nil?
47
+
48
+ @os
49
+ end
50
+
51
+ def bot?
52
+ return @bot unless @bot.nil?
53
+
54
+ # If the user agent doesn't follow the normal browser patterns, we assume it's a bot
55
+ parse_browser if @browser.nil?
56
+ if @browser == "Unknown"
57
+ @bot = true
58
+
59
+ return true
60
+ end
61
+
62
+ @bot = detect_bot
63
+ end
64
+
65
+ def human? = !bot?
66
+
67
+ def to_h
68
+ {
69
+ browser: browser,
70
+ browser_version: browser_version,
71
+ engine: engine,
72
+ engine_version: engine_version,
73
+ os: os,
74
+ bot: bot?,
75
+ }
76
+ end
77
+
78
+ BROWSER_MATCHERS = [
79
+ {
80
+ regex: /Chrome\/(?<version>\d++).*WebView|; wv.*Chrome\/(?<version>\d++)/,
81
+ browser: "Chrome WebView",
82
+ },
83
+ {
84
+ regex: /Android.*Version\/(?<version>\d++)/,
85
+ browser: "Android Browser",
86
+ os: "Android",
87
+ },
88
+ {
89
+ regex: /Android.*Chrome\/(?<version>\d++)/,
90
+ browser: "Chrome for Android",
91
+ os: "Android",
92
+ },
93
+ {
94
+ regex: /(?:iOS|iPod|iPad|iPhone).+(?:CriOS|Chrome)\/(?<version>\d++)/,
95
+ browser: "Chrome for iOS",
96
+ os: "iOS",
97
+ },
98
+ {
99
+ regex: /(?:CriOS|Chrome)\/(?<version>\d++)/,
100
+ browser: "Chrome",
101
+ },
102
+ {
103
+ regex: /(?:iOS|iPod|iPad|iPhone).+Version\/(?<version>\d++)/,
104
+ browser: "Safari for iOS",
105
+ os: "iOS",
106
+ },
107
+ {
108
+ regex: /GSA\/(?<version>\d++)/,
109
+ browser: "GSA",
110
+ },
111
+ {
112
+ regex: /(?:iOS|iPod|iPad|iPhone).*Safari/,
113
+ browser: "Safari for iOS",
114
+ os: "iOS",
115
+ },
116
+ {
117
+ regex: /Version\/(?<version>\d++).*Safari/,
118
+ browser: "Safari",
119
+ },
120
+ {
121
+ regex: /Safari\//,
122
+ browser: "Safari",
123
+ },
124
+ {
125
+ regex: /WebKit\/(?<version>\d++)/,
126
+ browser: "WebKit",
127
+ },
128
+ {
129
+ regex: /Mozilla\/(?<version>\d++).*rv:(?<engine_version>\d++).*?Gecko\//,
130
+ browser: "Mozilla",
131
+ engine: "Gecko",
132
+ },
133
+ ]
134
+
135
+ BROWSER_TOKENS = {
136
+ "AlohaBrowser" => {
137
+ browser: "Aloha",
138
+ },
139
+ "Avast" => {
140
+ browser: "Avast Secure Browser",
141
+ },
142
+ "AVG" => {
143
+ browser: "AVG Secure Browser",
144
+ },
145
+ "baiduboxapp" => {
146
+ browser: "Baidu",
147
+ },
148
+ "BingSapphire" => {
149
+ browser: "Bing",
150
+ },
151
+ "Brave" => {},
152
+ "Chromium" => {
153
+ regex: /Chromium[\/ ](?<version>GOST|\d++)/,
154
+ },
155
+ "Ddg" => {
156
+ browser: "DuckDuckGo",
157
+ },
158
+ "DuckDuckGo" => {},
159
+ "Ecosia" => {
160
+ regex: /Ecosia ios@(?<version>\d++)/,
161
+ os: "iOS",
162
+ },
163
+ "Edg" => [
164
+ {
165
+ regex: /(?:iOS|iPod|iPad|iPhone).*Edg\/(?<version>\d++)/,
166
+ browser: "Edge for iOS",
167
+ os: "iOS",
168
+ },
169
+ {
170
+ regex: /Android.*Edg\/(?<version>\d++)/,
171
+ browser: "Edge for Android",
172
+ os: "Android",
173
+ },
174
+ {
175
+ regex: /Edg\/(?<version>\d++)/,
176
+ browser: "Edge",
177
+ },
178
+ ],
179
+ "EdgA" => {
180
+ browser: "Edge for Android",
181
+ os: "Android",
182
+ },
183
+ "Edge" => {},
184
+ "EdgiOS" => {
185
+ browser: "Edge for iOS",
186
+ os: "iOS",
187
+ },
188
+ "Electron" => {},
189
+ "FBAV" => {
190
+ browser: "Facebook",
191
+ },
192
+ "Firefox" => [
193
+ {
194
+ regex: /(?<browser>PaleMoon|Waterfox)\/(?<version>\d++)/,
195
+ },
196
+ {
197
+ regex: /(?:iOS|iPod|iPad|iPhone).*Firefox\/(?<version>\d++)/,
198
+ browser: "Firefox for iOS",
199
+ os: "iOS",
200
+ },
201
+ {
202
+ regex: /Android.*Firefox\/(?<version>\d++)/,
203
+ browser: "Firefox for Android",
204
+ os: "Android",
205
+ },
206
+ {
207
+ regex: /Firefox\/(?<version>\d++)/,
208
+ browser: "Firefox",
209
+ },
210
+ ],
211
+ "FxiOS" => {
212
+ browser: "Firefox for iOS",
213
+ },
214
+ "HeadlessChrome" => {
215
+ browser: "Chrome Headless",
216
+ },
217
+ "HeyTapBrowser" => {
218
+ browser: "HeyTap",
219
+ },
220
+ "HuaweiBrowser" => {
221
+ browser: "Huawei Browser",
222
+ },
223
+ "Instagram" => {
224
+ regex: /Instagram[\/ ](?<version>\d++)/,
225
+ },
226
+ "Iron" => {
227
+ regex: /Chrome\/(?<version>\d++).*?Iron/,
228
+ },
229
+ "KAKAOTALK" => {
230
+ regex: /KAKAOTALK[\/ ](?<version>\d++)/,
231
+ },
232
+ "Konqueror" => {},
233
+ "Line" => {},
234
+ "LinkedInApp" => {
235
+ regex: /\[LinkedInApp\]\/(?<version>\d++)/,
236
+ browser: "LinkedIn",
237
+ },
238
+ "Maxthon" => {},
239
+ "MicroMessenger" => {
240
+ browser: "WeChat",
241
+ },
242
+ "MiuiBrowser" => {
243
+ browser: "MIUI Browser",
244
+ },
245
+ "MQQBrowser" => {},
246
+ "MSIE" => {
247
+ regex: /MSIE (?<version>\d++)(?>.*(?<engine>Trident)\/(?<engine_version>\d++))?|(?<engine>Trident)\/(?<engine_version>\d++).*rv[: ](?<version>\d++)/,
248
+ browser: "IE",
249
+ os: "Windows",
250
+ },
251
+ "musical" => {
252
+ regex: /musical_ly_(?<version>\d++)/,
253
+ browser: "TikTok",
254
+ },
255
+ "Norton" => {
256
+ browser: "Norton Private Browser",
257
+ },
258
+ "OculusBrowser" => {
259
+ browser: "Oculus Browser",
260
+ },
261
+ "Opera" => [
262
+ {
263
+ regex: /Opera Mini[\/ ](?<version>\d++)/,
264
+ browser: "Opera Mini",
265
+ },
266
+ {
267
+ regex: /Opera(?>.*Version)?[\/ ](?<version>\d++)/,
268
+ browser: "Opera",
269
+ },
270
+ ],
271
+ "OPR" => {
272
+ browser: "Opera",
273
+ },
274
+ "OPT" => {
275
+ browser: "Opera Touch",
276
+ },
277
+ "OPX" => {
278
+ browser: "Opera GX",
279
+ },
280
+ "PaleMoon" => {},
281
+ "QQBrowser" => {},
282
+ "QuarkPC" => {
283
+ browser: "Quark",
284
+ },
285
+ "SamsungBrowser" => {
286
+ browser: "Samsung Internet",
287
+ },
288
+ "SeaMonkey" => {},
289
+ "Silk" => {},
290
+ "Snapchat" => {},
291
+ "TikTokLIVEStudio" => {},
292
+ "Trident" => {
293
+ regex: /MSIE (?<version>\d++).*Trident\/(?<engine_version>\d++)|Trident\/(?<engine_version>\d++).*rv[: ](?<version>\d++)/,
294
+ browser: "IE",
295
+ engine: "Trident",
296
+ os: "Windows",
297
+ },
298
+ "Twitter" => {
299
+ regex: /Twitter for iPhone\/(?<version>\d++)/,
300
+ },
301
+ "UCBrowser" => {},
302
+ "VivoBrowser" => {
303
+ browser: "Vivo Browser",
304
+ },
305
+ "Whale" => {},
306
+ "YaBrowser" => {
307
+ browser: "Yandex",
308
+ },
309
+ "YaSearchBrowser" => {
310
+ browser: "Yandex",
311
+ },
312
+ }
313
+
314
+ # Normalise the BROWSER_TOKENS hash with defaults, and ensure they are all arrays
315
+ BROWSER_TOKENS.each_pair do |key, options|
316
+ next unless options.is_a?(Hash)
317
+
318
+ options[:regex] ||= /#{key}\/(?<version>\d++)/
319
+ options[:browser] ||= key
320
+
321
+ # Normalise into an array so we don't have to check the type during runtime
322
+ BROWSER_TOKENS[key] = [ options ]
323
+ end
324
+
325
+ private def parse_browser
326
+ # First we check if any of the UA tokens exist as keys to our browser token list (fast!)
327
+ user_agent_tokens.each do |token|
328
+ next unless BROWSER_TOKENS.key?(token)
329
+
330
+ # We then use the matcher to extract the version from the full user agent
331
+ BROWSER_TOKENS[token].each do |matcher|
332
+ return if run_matcher(matcher) # rubocop:disable Lint/NonLocalExitFromIterator
333
+ end
334
+ end
335
+
336
+ # Then, if we don't get a match, we run the full list of fallback matchers against the user agent (slow!)
337
+ BROWSER_MATCHERS.each do |matcher|
338
+ return if run_matcher(matcher) # rubocop:disable Lint/NonLocalExitFromIterator
339
+ end
340
+
341
+ # Finally, falling back to unknown values for the browser variables
342
+ @browser = "Unknown"
343
+ @browser_version = "Unknown"
344
+ end
345
+
346
+ # Runs a matcher hash against the user agent, srtting relevant instance variables. Returns true if a match was found.
347
+ private def run_matcher(matcher)
348
+ match = matcher[:regex].match(@user_agent)
349
+
350
+ if match
351
+ named_captures = match.named_captures
352
+
353
+ @browser = named_captures["browser"] || matcher[:browser] || "Unknown"
354
+ @browser_version = named_captures["version"] || matcher[:browser_version] || "Unknown"
355
+ @engine = named_captures["engine"] || matcher[:engine]
356
+ @engine_version = named_captures["engine_version"]
357
+ @os = named_captures["os"] || matcher[:os]
358
+
359
+ return true
360
+ end
361
+
362
+ false
363
+ end
364
+
365
+ ENGINE_MATCHERS = [
366
+ {
367
+ regex: /AppleWebKit\/537\.36.*Edge\/(?<version>1[2-8])\./,
368
+ engine: "EdgeHTML",
369
+ },
370
+ {
371
+ regex: /AppleWebKit\/537\.36.*Chrome\/(?<version>\d++)/,
372
+ engine: "Blink",
373
+ },
374
+ {
375
+ regex: /(?<engine>WebKit|Presto|Trident|Goanna)\/(?<version>\d++)/,
376
+ },
377
+ {
378
+ regex: /rv:(?<version>\d++).*?Gecko\/\d++/,
379
+ engine: "Gecko",
380
+ },
381
+ ]
382
+
383
+ private def parse_engine
384
+ # Since there are only 4 engine matchers, there is little performance to be gained by using the token approach to parsing
385
+ ENGINE_MATCHERS.each do |matcher|
386
+ match = matcher[:regex].match(@user_agent)
387
+
388
+ if match
389
+ named_captures = match.named_captures
390
+
391
+ @engine = named_captures["engine"] || matcher[:engine] || "Unknown"
392
+ @engine_version = named_captures["version"] || "Unknown"
393
+
394
+ return # rubocop:disable Lint/NonLocalExitFromIterator
395
+ end
396
+ end
397
+
398
+ @engine = "Unknown"
399
+ @engine_version = "Unknown"
400
+ end
401
+
402
+ OS_TOKENS = {
403
+ "CFNetwork" => "iOS",
404
+ "CrOS" => "Chrome OS",
405
+ "Fedora" => "Fedora",
406
+ "Gentoo" => "Gentoo",
407
+ "HarmonyOS" => "HarmonyOS",
408
+ "iPad" => "iOS",
409
+ "iPhone" => "iOS",
410
+ "iPod" => "iOS",
411
+ "Mac" => "macOS",
412
+ "Ubuntu" => "Ubuntu",
413
+ "Windows" => "Windows",
414
+ }
415
+
416
+ private def parse_os
417
+ linux_fallback = false
418
+ android_fallback = false
419
+ user_agent_tokens.each do |token|
420
+ if OS_TOKENS.key?(token)
421
+ @os = OS_TOKENS[token]
422
+
423
+ return # rubocop:disable Lint/NonLocalExitFromIterator
424
+ end
425
+
426
+ android_fallback ||= token == "Android"
427
+ linux_fallback ||= token == "Linux"
428
+ end
429
+
430
+ # HarmonyOS UAs can contain Android, and Android UAs can contain "Linux" so we need to do these in a specific order
431
+ @os ||= "Android" if android_fallback
432
+ @os ||= "Linux" if linux_fallback
433
+ @os ||= "Unknown"
434
+ end
435
+
436
+ BOT_GLOBAL_MATCHERS = %w[bot crawl scan spider]
437
+
438
+ BOT_WORD_SET = Set.new(%w[AGENT Agent AppInsights ArchiveBox Archiver Archiving BingPreview BrandVerity Butterfly Charlotte Checkly Claude CloudFlare Cloudflare Code Collapsify CookieHubVerify Criticalcss Daily DareBoost DatadogSynthetics Datanyze Devin Dlc FeedBurner Feeder Feedly FlipboardProxy Fluid Foregenix GTmetrix GeedoProductSearch GeedoShopProductFinder Google GoogleAgent GoogleImageProxy GotSiteMonitor Hardenize HeadlessChrome Hotjar Inspector Lighthouse LinkTiger Mail Manus MarketGoo MarketingMiner MetaIAB Miniature MonitoRSS Monitor Netcraft NewRelicSynthetics NewsBlur NewsNow Newsify Nitro OpenGraph Optimizer PTST PWABuilderHttpAgent Perplexity PingdomTMS Playwright Preview PrintFriendly Puppeteer Readable RevvimGort Rigor SQWatcher Scope3 SecurityHeaders Selenium SeoSiteCheckup Silktide Sindup Siteimprove Specificfeeds Sucuri TestLocally ThousandEyes Trae YLT ZoteroTranslationServer adbeat agent archiver archiving brandverity butterfly claude cloudflare code contentkingapp deadlinkchecker devin europarchive feeder feedly google img2dataset infegy mail mailservertest2023 marketingminer mirrorweb monitor nbertaupete95 netcraft newsai newsblur newsify opencode opengraph oupwis perplexity preview retrevo scope3 scraping seositecheckup sitebulb slider splash sqwatcher turingos ubermetrics uptimedoctor watchTowr webresearch websitepulse woorankreview xmco])
439
+
440
+ BOT_FALLBACK_MATCHERS = [ "AP3A.240617.008" ]
441
+
442
+ private def detect_bot
443
+ return true if user_agent_tokens.any? { |it| BOT_WORD_SET.include? it }
444
+
445
+ user_agent_downcase = @user_agent.downcase
446
+ return true if BOT_GLOBAL_MATCHERS.any? { |it| user_agent_downcase.include? it }
447
+
448
+ return true if BOT_FALLBACK_MATCHERS.any? { |it| @user_agent.include? it }
449
+
450
+ false
451
+ end
452
+
453
+ # Splits the string into alphanumeric sequences of length 3 or more
454
+ private def user_agent_tokens
455
+ @user_agent_tokens ||= @user_agent.tr("^a-zA-Z0-9", " ").split.keep_if { |it| it.length >= 3 }
456
+ end
457
+ end
458
+ end
@@ -0,0 +1,3 @@
1
+ module Skadi
2
+ VERSION = "0.4.0.beta.1"
3
+ end
data/lib/skadi.rb ADDED
@@ -0,0 +1,26 @@
1
+ require "active_support"
2
+ require "active_support/concern"
3
+ require "active_support/core_ext"
4
+
5
+ require "action_dispatch/http/request"
6
+
7
+ require_relative "skadi/anonymity_set"
8
+ require_relative "skadi/analytics"
9
+ require_relative "skadi/configuration"
10
+ require_relative "skadi/controller_delegate"
11
+ require_relative "skadi/cookie_manager"
12
+ require_relative "skadi/engine"
13
+ require_relative "skadi/url"
14
+ require_relative "skadi/user_agent"
15
+ require_relative "skadi/version"
16
+
17
+ module Skadi
18
+ # @return [Skadi::Configuration] The Skadi configuration
19
+ mattr_accessor :configuration, default: Configuration.new
20
+
21
+ # @yieldparam config [Skadi::Configuration]
22
+ # @return [void]
23
+ def self.configure
24
+ yield(configuration)
25
+ end
26
+ end