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
@@ -0,0 +1,86 @@
1
+ module Skadi
2
+ module Helpers
3
+ class Sql
4
+ class << self
5
+ def operator(model, operator)
6
+ return case operator
7
+ when "like"
8
+ ilike(model)
9
+ when "not like"
10
+ "NOT #{ilike(model)}"
11
+ else
12
+ operator
13
+ end
14
+ end
15
+
16
+ # Provides the keyword for case insensitive LIKE
17
+ def ilike(model)
18
+ case model.connection.adapter_name
19
+ when "PostgreSQL"
20
+ "ILIKE"
21
+ when "Mysql2", "SQLite"
22
+ "LIKE"
23
+ else
24
+ raise ::Skadi::Dashboard::UnsupportedDatabaseError.new("The database adapter #{model.connection.adapter_name} is not supported")
25
+ end
26
+ end
27
+
28
+ def string_before_separator(model, field, separator)
29
+ case model.connection.adapter_name
30
+ when "PostgreSQL"
31
+ "SPLIT_PART(#{model.table_name}.#{field}, '#{separator}', 1)"
32
+ when "Mysql2"
33
+ "SUBSTRING_INDEX(#{model.table_name}.#{field}, '#{separator}', 1)"
34
+ when "SQLite"
35
+ "SUBSTR(#{model.table_name}.#{field}, 1, INSTR(#{model.table_name}.#{field}, '#{separator}') - 1)"
36
+ else
37
+ raise ::Skadi::Dashboard::UnsupportedDatabaseError.new("The database adapter #{model.connection.adapter_name} is not supported")
38
+ end
39
+ end
40
+
41
+ def time_series(time_series, model, date_field)
42
+ case time_series
43
+ when "daily"
44
+ day_of_date(model, date_field)
45
+ when "weekly"
46
+ week_of_date(model, date_field)
47
+ when "monthly"
48
+ month_of_date(model, date_field)
49
+ else
50
+ raise ::Skadi::Dashboard::DatasetConfigurationError.new("The time_series #{time_series} is invalid")
51
+ end
52
+ end
53
+
54
+ def day_of_date(_model, date_field)
55
+ "DATE(#{date_field})"
56
+ end
57
+
58
+ def week_of_date(model, date_field)
59
+ case model.connection.adapter_name
60
+ when "PostgreSQL"
61
+ "DATE_TRUNC('week', #{date_field})::date"
62
+ when "Mysql2"
63
+ "DATE_SUB(DATE(#{date_field}), INTERVAL WEEKDAY(#{date_field}) DAY)"
64
+ when "SQLite"
65
+ "DATE(#{date_field}, '-' || ((CAST(STRFTIME('%w', #{date_field}) AS INTEGER) + 6) % 7) || ' days')"
66
+ else
67
+ raise ::Skadi::Dashboard::UnsupportedDatabaseError.new("The database adapter #{model.connection.adapter_name} is not supported")
68
+ end
69
+ end
70
+
71
+ def month_of_date(model, date_field)
72
+ case model.connection.adapter_name
73
+ when "PostgreSQL"
74
+ "DATE_TRUNC('month', #{date_field})::date"
75
+ when "Mysql2"
76
+ "DATE_FORMAT(#{date_field}, '%Y-%m-01')"
77
+ when "SQLite"
78
+ "DATE(#{date_field}, 'start of month')"
79
+ else
80
+ raise ::Skadi::Dashboard::UnsupportedDatabaseError.new("The database adapter #{model.connection.adapter_name} is not supported")
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,135 @@
1
+ module Skadi
2
+ class Schema
3
+ class << self
4
+ # Extract out the most common field config into a const
5
+ FILTER_AND_SPLIT = { filter: true, split: true }
6
+ private_constant :FILTER_AND_SPLIT
7
+
8
+ # A hash where the key represents a dataset name and the value is its configuration
9
+ def database_schema
10
+ return @_database_schema if defined?(@_database_schema)
11
+
12
+ @_database_schema ||= {
13
+ # A hash with the following keys:
14
+ # model: The model for this dataset
15
+ # visit_key: used for chart-level visit filters
16
+ # count_sql: The SQL expression used to count this field. Defaults to COUNT().
17
+ # fields: A hash where the key represents a field and the value is its configuration
18
+ visits: {
19
+ model: Skadi::Visit,
20
+ visit_key: "id",
21
+ # If the table has a date field, the fields Hash expects a value for :date
22
+ fields: {
23
+ # A hash with the following keys:
24
+ # label: The label to show in the front end
25
+ # type: The datatype, one of :one_of, :date, :string, :number, :boolean. Defaults to :string.
26
+ # filter: True if this field can be filtered
27
+ # split: True if this field can be split
28
+ # sql: The SQL expression used to get this value for derived fields
29
+ # options: A list of possible values
30
+ # description: used in the front end as help text for this field
31
+ date: {
32
+ type: :date,
33
+ filter: true,
34
+ sql: "skadi_visits.created_at",
35
+ },
36
+ landing_page: FILTER_AND_SPLIT,
37
+ referrer_domain: {
38
+ **FILTER_AND_SPLIT,
39
+ sql: Helpers::Sql.string_before_separator(Skadi::Visit, "referrer", "/"),
40
+ },
41
+ utm_source: FILTER_AND_SPLIT,
42
+ utm_medium: FILTER_AND_SPLIT,
43
+ utm_term: FILTER_AND_SPLIT,
44
+ utm_content: FILTER_AND_SPLIT,
45
+ utm_campaign: FILTER_AND_SPLIT,
46
+ },
47
+ },
48
+ views: {
49
+ model: Skadi::View,
50
+ visit_key: "visit_id",
51
+ fields: {
52
+ date: {
53
+ type: :date,
54
+ filter: true,
55
+ sql: "skadi_views.created_at",
56
+ },
57
+ verified: {
58
+ type: :boolean,
59
+ filter: true,
60
+ },
61
+ controller: FILTER_AND_SPLIT,
62
+ action: {
63
+ filter: true,
64
+ },
65
+ controller_and_action: {
66
+ split: true,
67
+ sql: "CONCAT(skadi_views.controller, '::', skadi_views.action)",
68
+ },
69
+ path: FILTER_AND_SPLIT,
70
+ verb: {
71
+ type: :one_of,
72
+ filter: true,
73
+ split: true,
74
+ description: "Typically, GET requests are page views, and POST, PUT, PATCH and DELETE are form submissions.",
75
+ options: %w[GET POST PUT PATCH DELETE],
76
+ },
77
+ version: FILTER_AND_SPLIT,
78
+ exit_page: FILTER_AND_SPLIT,
79
+ },
80
+ },
81
+ events: {
82
+ model: Skadi::Event,
83
+ visit_key: "visit_id",
84
+ fields: {
85
+ date: {
86
+ type: :date,
87
+ filter: true,
88
+ sql: "skadi_events.created_at",
89
+ },
90
+ name: FILTER_AND_SPLIT,
91
+ **(Skadi.configuration.dashboard_custom_event_fields || {}),
92
+ },
93
+ },
94
+ demographics: {
95
+ model: Demographic,
96
+ count_sql: "COALESCE(SUM(skadi_demographics.count), 0)",
97
+ fields: {
98
+ date: {
99
+ type: :date,
100
+ filter: true,
101
+ sql: "skadi_demographics.recorded_on",
102
+ },
103
+ uri: FILTER_AND_SPLIT,
104
+ name: FILTER_AND_SPLIT,
105
+ value: FILTER_AND_SPLIT,
106
+ count: {
107
+ **FILTER_AND_SPLIT,
108
+ type: :number,
109
+ },
110
+ },
111
+ },
112
+ **(Skadi.configuration.dashboard_custom_schema || {}),
113
+ }
114
+ end
115
+
116
+ def frontend_schema
117
+ return @_frontend_schema if defined?(@_frontend_schema)
118
+
119
+ # Redact the SQL from the dashboard schema
120
+ schema = database_schema.deep_dup
121
+ schema.each_value do |dataset|
122
+ dataset.keys.each do |key|
123
+ dataset.delete(key) unless key == :fields
124
+ end
125
+
126
+ dataset[:fields].each_value do |field|
127
+ field.delete(:sql)
128
+ end
129
+ end
130
+
131
+ @_frontend_schema = schema
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,9 @@
1
+ module Skadi
2
+ class View < ApplicationRecord
3
+ belongs_to :visit, class_name: "Skadi::Visit", optional: true, inverse_of: :views
4
+
5
+ has_many :events, class_name: "Skadi::Event", inverse_of: :view
6
+
7
+ def token = view_token
8
+ end
9
+ end
@@ -0,0 +1,59 @@
1
+ module Skadi
2
+ class Visit < ApplicationRecord
3
+ has_many :views, class_name: "Skadi::View", inverse_of: :visit
4
+ has_many :events, class_name: "Skadi::Event", inverse_of: :visit
5
+
6
+ def token = visit_token
7
+
8
+ def self.find_active_visit_for(tracking_token, user)
9
+ return nil if tracking_token.nil? && user.nil?
10
+
11
+ visit_query = nil
12
+ if tracking_token
13
+ visit_query = where(tracking_token: tracking_token)
14
+ .and(where("created_at > ?", Skadi.configuration.visit_duration.ago))
15
+ end
16
+ if user&.persisted?
17
+ user_visit_query = where(user_id: user.id)
18
+ .and(where("created_at > ?", Skadi.configuration.visit_duration.ago))
19
+
20
+ visit_query = visit_query ? visit_query.or(user_visit_query) : user_visit_query
21
+ end
22
+
23
+ visit = visit_query&.order(created_at: :desc)&.limit(1)&.first
24
+
25
+ # If the user has changed since the last visit, create a new visit
26
+ return nil if visit&.user_id && user&.persisted? && visit.user_id != user.id
27
+
28
+ visit
29
+ end
30
+
31
+ # @param tracking_token [String, nil]
32
+ # @param user [ActiveModel::Model, nil]
33
+ # @param request [ActionDispatch::Request]
34
+ # @return [Skadi::Visit]
35
+ def self.build_from(tracking_token, user_id = nil, request = nil, cookies_enabled: nil)
36
+ if cookies_enabled.nil?
37
+ cookies_enabled = request&.cookie_jar&.key?("skadi_id") || false
38
+ end
39
+
40
+ new(
41
+ visit_token: SecureRandom.uuid_v7,
42
+ tracking_token: tracking_token,
43
+ user_id: user_id,
44
+
45
+ referrer: request ? Skadi::Url.redact_and_normalise_url(request.referrer) : nil,
46
+ landing_page: request ? Skadi::Url.view_path_from_request(request) : nil,
47
+
48
+ utm_source: request ? request.query_parameters["utm_source"] : nil,
49
+ utm_medium: request ? request.query_parameters["utm_medium"] : nil,
50
+ utm_term: request ? request.query_parameters["utm_term"] : nil,
51
+ utm_content: request ? request.query_parameters["utm_content"] : nil,
52
+ utm_campaign: request ? request.query_parameters["utm_campaign"] : nil,
53
+
54
+ verified: false,
55
+ cookies_enabled: cookies_enabled,
56
+ )
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,21 @@
1
+ <%# locals: () %>
2
+ <!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <title>📈 Skadi Analytics</title>
6
+ <meta name="viewport" content="width=device-width,initial-scale=1">
7
+ <%= csrf_meta_tags %>
8
+ <%= csp_meta_tag %>
9
+
10
+ <%= yield :head %>
11
+ </head>
12
+
13
+ <body class="flex flex-col gap-4 min-h-screen">
14
+ <%= yield %>
15
+ <footer class="fixed bottom-2 right-6">
16
+ <p class="text-sm text-right text-dawn-900/65 font-semibold">
17
+ <a href="https://github.com/mwnciau/skadi" target="_blank">Powered by Skadi</a>
18
+ </p>
19
+ </footer>
20
+ </body>
21
+ </html>
@@ -0,0 +1,18 @@
1
+ <%# locals: (dashboard_configuration:, dataset_schema:, can_edit:, can_dangerously_use_sql:) %>
2
+
3
+ <% content_for :head do %>
4
+ <%= stylesheet_link_tag "#{Skadi::Engine.routes.url_helpers.dashboard_css_path}?#{Skadi::VERSION}", media: "all" %>
5
+ <%= javascript_include_tag "#{Skadi::Engine.routes.url_helpers.dashboard_js_path}?#{Skadi::VERSION}",
6
+ data: {
7
+ "dashboard-config": dashboard_configuration.to_json,
8
+ "dataset-schema": dataset_schema.to_json,
9
+ "can-edit": can_edit ? "true" : "false",
10
+ "can-dangerously-use-sql": can_dangerously_use_sql ? "true" : "false",
11
+ "fetch-data-path": dashboard_data_path,
12
+ "update-dashboard-path": dashboard_update_path,
13
+ } %>
14
+ <% end %>
15
+
16
+ <%= render "layout" do %>
17
+ <div id="skadi-dashboard"></div>
18
+ <% end %>
data/config/routes.rb ADDED
@@ -0,0 +1,14 @@
1
+ Skadi::Engine.routes.draw do
2
+ root to: "dashboard#show"
3
+
4
+ post "/", to: "tracking#track", as: :tracking_endpoint
5
+
6
+ get "/skadi.js", to: "asset#tracking_script", as: :tracking_script
7
+ get "/dashboard.css", to: "asset#dashboard_css", as: :dashboard_css
8
+ get "/dashboard.js", to: "asset#dashboard_js", as: :dashboard_js
9
+
10
+ get "/dashboard", to: "dashboard#show", as: :dashboard
11
+ post "/dashboard/update", to: "dashboard#update", as: :dashboard_update
12
+
13
+ post "/data", to: "dashboard#data", as: :dashboard_data
14
+ end
@@ -0,0 +1,20 @@
1
+ Description:
2
+ Creates a migration for the Skadi Analytics tables in your application.
3
+
4
+ Run `rails db:migrate` afterwards to apply the migration.
5
+
6
+ Options:
7
+ --db-engine=ENGINE # Database engine: postgres, mysql, sqlite
8
+ # Default: postgres
9
+ --user-id-type=TYPE # Column type of your users table primary key:
10
+ # bigint, integer, uuid, string
11
+ # Default: bigint
12
+
13
+ Example:
14
+ rails generate skadi:install
15
+
16
+ This will create:
17
+ db/migrate/<timestamp>_install_skadi.rb
18
+
19
+ For a MySQL app with UUID user ids:
20
+ rails generate skadi:install --db-engine=mysql --user-id-type=uuid
@@ -0,0 +1,46 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module Skadi
5
+ module Generators
6
+ class InstallGenerator < Rails::Generators::Base
7
+ class Error < StandardError; end
8
+
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+ desc "Generates the Skadi analytics migration"
13
+
14
+ VALID_DB_ENGINES = [ :postgres, :mysql, :sqlite ]
15
+ class_option :db_engine,
16
+ type: :string,
17
+ default: "postgres",
18
+ desc: "Database engine (postgres, mysql, sqlite)"
19
+
20
+ VALID_USER_ID_TYPES = [ :bigint, :integer, :uuid, :string ]
21
+ class_option :user_id_type,
22
+ type: :string,
23
+ default: "bigint",
24
+ desc: "Column type of your users table primary key (bigint, integer, uuid, string)"
25
+
26
+ def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
27
+
28
+ def create_migration_file
29
+ raise Error.new("Invalid database engine specified") unless VALID_DB_ENGINES.include?(db_engine)
30
+ raise Error.new("Invalid user id type specified") unless VALID_USER_ID_TYPES.include?(user_id_type)
31
+
32
+ migration_template "skadi_migration.rb.erb",
33
+ "db/migrate/install_skadi.rb"
34
+ end
35
+
36
+ # Thor requires us to use a private block rather than private on individual definitions
37
+ private
38
+
39
+ def user_id_type = options[:user_id_type].to_sym
40
+ def db_engine = options[:db_engine].to_sym
41
+ def uuid_column_type = (db_engine == :postgres) ? :uuid : :string
42
+ def uuid_column_options = (db_engine == :postgres) ? "" : ", limit: 36"
43
+ def json_column_type = (db_engine == :postgres) ? :jsonb : :json
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,111 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= "#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}" %>]
2
+ def change
3
+ create_table :skadi_visits do |t|
4
+ # A random uuid identifying the visit. This will be sent to the front-end to allow updates to the visit javascript flag without exposing details about the site analytics to the user.
5
+ t.<%= uuid_column_type %> :visit_token<%= uuid_column_options %>, null: false
6
+
7
+ # A token identifying the user. This will either be a token generated from an anonymity set based on the user's IP and User Agent, or it will be sourced from a cookie.
8
+ t.<%= uuid_column_type %> :tracking_token<%= uuid_column_options %>
9
+
10
+ # The ID of a logged in user. The FK is intentionally absent in case the host app doesn't have a users table.
11
+ <%- if user_id_type == :uuid && db_engine != :postgres -%>
12
+ t.references :user, type: :string, limit: 36, index: false
13
+ <%- else -%>
14
+ t.references :user, type: :<%= user_id_type %>, index: false
15
+ <%- end -%>
16
+
17
+ t.text :referrer
18
+ t.text :landing_page
19
+
20
+ # Standard UTM parameters
21
+ t.text :utm_source
22
+ t.text :utm_medium
23
+ t.text :utm_term
24
+ t.text :utm_content
25
+ t.text :utm_campaign
26
+
27
+ # Whether the visit has been verified by the front-end
28
+ t.boolean :verified, null: false, default: false
29
+
30
+ t.boolean :cookies_enabled, null: false, default: false
31
+
32
+ t.timestamps
33
+ end
34
+
35
+ add_index :skadi_visits, :visit_token, unique: true
36
+ add_index :skadi_visits, :created_at
37
+ add_index :skadi_visits, [:tracking_token, :created_at]
38
+ add_index :skadi_visits, [:user_id, :created_at]
39
+
40
+ create_table :skadi_views do |t|
41
+ # Intentionally left nullable as not all requests will have a visit in the case a user has requested no tracking.
42
+ t.references :visit, foreign_key: { to_table: :skadi_visits, on_delete: :cascade }, index: false
43
+
44
+ # A random uuid identifying the view. This will be sent to the front-end to allow updates to the view metrics without exposing details about the site analytics to the user.
45
+ t.<%= uuid_column_type %> :view_token<%= uuid_column_options %>, null: false
46
+
47
+ t.string :controller, null: false
48
+ t.string :action, null: false
49
+ t.string :verb, null: false
50
+ t.text :path, null: false
51
+ t.<%= json_column_type %> :query_params
52
+
53
+ # The page the user clicked on when leaving the page
54
+ t.text :exit_page
55
+
56
+ # Whether the view has been verified by the front-end
57
+ t.boolean :verified, null: false, default: false
58
+
59
+ # Version of the page presented to the user (e.g. branch A/B)
60
+ t.string :version
61
+
62
+ t.timestamps
63
+ end
64
+
65
+ add_index :skadi_views, :view_token, unique: true
66
+ add_index :skadi_views, :created_at
67
+ add_index :skadi_views, [:path, :created_at]
68
+ add_index :skadi_views, [:visit_id, :created_at]
69
+
70
+ create_table :skadi_events do |t|
71
+ # Intentionally left nullable as not all requests will have a visit in the case a user has requested no tracking.
72
+ t.references :visit, foreign_key: { to_table: :skadi_visits, on_delete: :cascade }, index: false
73
+
74
+ # Intentionally left nullable as not all requests will have a visit in the case a user has requested no tracking.
75
+ t.references :view, foreign_key: { to_table: :skadi_views, on_delete: :cascade }, index: false
76
+
77
+ t.string :name, null: false
78
+ t.<%= json_column_type %> :properties
79
+
80
+ t.datetime :created_at, null: false
81
+ end
82
+
83
+ add_index :skadi_events, :created_at
84
+ add_index :skadi_events, [:name, :created_at]
85
+ add_index :skadi_events, [:view_id, :created_at]
86
+ add_index :skadi_events, [:visit_id, :created_at]
87
+ <%- if db_engine == :postgres -%>
88
+ add_index :skadi_events, :properties, using: :gin, opclass: :jsonb_path_ops
89
+ <%- end -%>
90
+
91
+ # Store demographic data separately so that it cannot be used to identify users
92
+ # E.g. screen size, language, timezone, pointer type (mouse, touch), can-hover, prefers reduced motion, prefers contrast, forced colours, prefers dark mode
93
+ create_table :skadi_demographics do |t|
94
+ t.string :uri, null: false
95
+ t.string :name, null: false
96
+ t.string :value, null: false
97
+ t.date :recorded_on, null: false
98
+ t.integer :count, null: false, default: 0
99
+ end
100
+
101
+ add_index :skadi_demographics, [:uri, :name, :value, :recorded_on], unique: true
102
+
103
+ create_table :skadi_dashboards do |t|
104
+ t.string :name, null: false
105
+ t.text :description
106
+ t.<%= json_column_type %> :configuration, null: false
107
+
108
+ t.timestamps
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,35 @@
1
+ # Adds Skadi Analytics helper methods to your controller, and enabled automatic tracking if configured.
2
+ module Skadi
3
+ module Analytics
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ around_action :skadi_track
8
+
9
+ # Make skadi available in the view for when we output the frontend script
10
+ helper_method :skadi
11
+
12
+ # Add the Skadi view helper methods
13
+ helper Skadi::ApplicationHelper
14
+
15
+ # Disable Skadi tracking for the current controller
16
+ def self.do_not_track!(**kwargs)
17
+ skip_around_action :skadi_track, **kwargs
18
+ end
19
+ end
20
+
21
+ def skadi
22
+ @_skadi ||= Skadi::ControllerDelegate.new(self)
23
+ end
24
+
25
+ def skadi_track
26
+ skadi._prepare
27
+
28
+ yield
29
+
30
+ skadi._persist
31
+ end
32
+
33
+ delegate :do_not_track!, to: :skadi
34
+ end
35
+ end
@@ -0,0 +1,38 @@
1
+ module Skadi
2
+ module AnonymitySet
3
+ # Generates a unique token for the given IP and user agent
4
+ # @return [String]
5
+ def self.calculate(ip, user_agent)
6
+ user_fingerprint = "#{ip}|#{user_agent}"
7
+
8
+ hash = OpenSSL::HMAC.hexdigest("sha256", pepper, user_fingerprint)
9
+
10
+ # We want a UUID-like string to be compatible with the uuid type if the database is PostgreSQL, but don't need a valid UUID
11
+ "#{hash[0, 8]}-#{hash[8, 4]}-#{hash[12, 4]}-#{hash[16, 4]}-#{hash[20, 12]}"
12
+ end
13
+
14
+ # Generate a pepper to be used in the anonymity set hash
15
+ # @return [String]
16
+ def self.pepper
17
+ duration = Skadi.configuration.anonymity_set_duration
18
+ reset_hour = Skadi.configuration.anonymity_set_reset_hour
19
+
20
+ current_time = Time.current
21
+ pepper_expiry = current_time + duration
22
+
23
+ if reset_hour
24
+ # We always want to truncate the duration to the reset hour, so if the expiry hour is before the reset hour, go back to the previous day
25
+ pepper_expiry -= 1.day if pepper_expiry.hour < reset_hour
26
+
27
+ pepper_expiry = pepper_expiry.change(hour: reset_hour, min: 0, sec: 0)
28
+
29
+ # Handle the case where the duration is less than one day, so the expiry is in the past
30
+ pepper_expiry += 1.day if pepper_expiry < current_time
31
+ end
32
+
33
+ Rails.cache.fetch("#{Skadi.configuration.anonymity_set_cache_key}/v1", expires_in: [ 1, pepper_expiry.to_i - current_time.to_i ].max) do
34
+ SecureRandom.hex(32)
35
+ end
36
+ end
37
+ end
38
+ end