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 @@
1
+ var e=[],t=[],n=-1,r,i,a=!1,o={},s=window,c=document,l=`-contentful-paint`,u=`first${l}`,d=`largest${l}`,f={...c.currentScript.dataset},p=()=>{r??=setTimeout(m,500)},m=()=>{r&&=(clearTimeout(r),null),navigator.sendBeacon(f.endpoint,new Blob([JSON.stringify({view:f.view,demographics:e,events:t,consent:o,...a&&{exit_page:i}})],{type:`application/json`}))&&(e=[],t=[],o={})},h=(e,t)=>{if(e<=0)return null;if(e<t[0])return`< ${t[0]}ms`;for(let n=1;n<t.length;n++)if(e<=t[n])return`${t[n-1]}ms to ${t[n]}ms`;return`> ${t[3]}ms`},g=(t,n,r=!1)=>{if(n===null)return;let i={name:t,value:n.toString()};r&&(i.uri=f.uri),e.push(i)};new PerformanceObserver(e=>{let t=e.getEntries().at(-1);t&&(n=t.startTime)}).observe({type:d,buffered:!0}),new PerformanceObserver((e,t)=>{let n=e.getEntriesByName(u)[0];n&&(g(u,h(n.startTime,[1e3,1800,3e3,4500]),!0),t.disconnect())}).observe({type:`paint`,buffered:!0}),s.addEventListener(`load`,()=>{f.visit===`1`&&(g(`timezone`,Intl.DateTimeFormat().resolvedOptions().timeZone),g(`locale`,Intl.NumberFormat().resolvedOptions().locale),g(`screen-size`,`${s.innerWidth}x${s.innerHeight}`),g(`input-device`,s.matchMedia(`(pointer: fine)`).matches?`mouse`:`touch`)),p()}),c.addEventListener(`click`,e=>{let t=e.target?.closest(`a`);t?.href&&(t.target===`_blank`||e.ctrlKey||e.metaKey||(i=t.href))}),s.addEventListener(`pagehide`,()=>{g(d,h(n,[1500,2500,4e3,6e3]),!0),a=!0,m()}),s.addEventListener(`visibilitychange`,()=>{r&&m()}),s.skadi={event:(e,n={})=>{t.push({name:e,properties:n}),p()},demographic:(e,t,n=!1)=>{g(e,t,n),p()},consent:e=>{o=e,m()}};
@@ -0,0 +1,4 @@
1
+ module Skadi
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -0,0 +1,28 @@
1
+ module Skadi
2
+ # Controller to serve assets directly so Skadi assets can be loaded regardless of asset pipeline
3
+ class AssetController < ActionController::Metal
4
+ include ActionController::ConditionalGet
5
+ include ActionController::DataStreaming
6
+
7
+ def tracking_script
8
+ serve_built_asset("skadi.js")
9
+ end
10
+
11
+ def dashboard_js
12
+ serve_built_asset("dashboard.js")
13
+ end
14
+
15
+ def dashboard_css
16
+ serve_built_asset("dashboard.css", type: "text/css")
17
+ end
18
+
19
+ private def serve_built_asset(file_name, type: "application/javascript")
20
+ file_path = Engine.root.join("app", "assets", "builds", file_name)
21
+
22
+ # Set a long expiry time - the include helper will append the app version number ensuring any changes will be loaded
23
+ expires_in 1.year, public: true, must_revalidate: false
24
+
25
+ send_file file_path, disposition: nil, type: type
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,105 @@
1
+ module Skadi
2
+ class DashboardController < ::ApplicationController
3
+ layout false
4
+
5
+ do_not_track! if defined?(do_not_track!)
6
+
7
+ before_action :set_dashboard
8
+ before_action :set_dashboard_permissions
9
+
10
+ def show
11
+ return head :forbidden unless can_view
12
+
13
+ render :show, locals: {
14
+ dashboard_configuration: @dashboard.configuration,
15
+ dataset_schema: Skadi::Schema.frontend_schema,
16
+ can_edit: can_edit,
17
+ can_dangerously_use_sql: can_dangerously_use_sql,
18
+ }
19
+ end
20
+
21
+ def data
22
+ return head :forbidden unless can_view
23
+
24
+ query_filters = params.permit(:date_from, :date_to, :page)
25
+
26
+ if can_edit && params[:configuration].present?
27
+ return custom_chart_data(query_filters)
28
+ end
29
+
30
+ unless params[:chart_id].present?
31
+ return render json: { error: "The chart_id parameter must be specified" }, status: :unprocessable_content
32
+ end
33
+
34
+ return render json: @dashboard.chart_data(params[:chart_id], query_filters.to_h)
35
+ rescue ActiveRecord::StatementInvalid => e
36
+ message = can_dangerously_use_sql ? e.message : "Something went wrong fetching the data. Please contact a site admin."
37
+ render json: { error: message }, status: :unprocessable_content
38
+ rescue Skadi::Dashboard::Error => e
39
+ render json: { error: e.message }, status: :unprocessable_content
40
+ end
41
+
42
+ def update
43
+ return head :forbidden unless can_view && can_edit
44
+
45
+ # The dashboard validator will strongly check the structure of :configuration
46
+ @dashboard.configuration = params.to_unsafe_h[:configuration]
47
+
48
+ if @dashboard.save
49
+ head :ok
50
+ else
51
+ render json: { error: "Dashboard validation failed. #{@dashboard.errors.full_messages.join("\n")}" }, status: :unprocessable_content
52
+ end
53
+ end
54
+
55
+ private def custom_chart_data(query_filters)
56
+ # The dashboard validator will strongly check the structure of :configuration
57
+ chart_configuration = params[:configuration].to_unsafe_h
58
+
59
+ # Temporarily add the chart to the current dashboard so we can validate it, ensuring it's in the right format and that no illegal SQL has been added.
60
+ chart_configuration["id"] = "temporary-chart"
61
+ @dashboard.configuration[0]["children"] << chart_configuration
62
+
63
+ unless @dashboard.valid?
64
+ return render json: { error: "Invalid chart configuration:\n#{@dashboard.errors.full_messages.join("\n")}" }, status: :unprocessable_content
65
+ end
66
+
67
+ return render json: @dashboard.chart_data("temporary-chart", query_filters.to_h)
68
+ end
69
+
70
+ private def set_dashboard
71
+ @dashboard = Skadi::Dashboard.first_or_create!(name: "Default dashboard", configuration: Skadi::Dashboard.default_configuration)
72
+ end
73
+
74
+ private def set_dashboard_permissions
75
+ @dashboard.can_dangerously_use_sql = can_dangerously_use_sql
76
+ end
77
+
78
+ private def can_view
79
+ return @can_view if defined?(@can_view)
80
+
81
+ return false if Skadi.configuration.dashboard_view_controller_method.nil?
82
+ return false unless respond_to?(Skadi.configuration.dashboard_view_controller_method, true)
83
+
84
+ return @can_view = send(Skadi.configuration.dashboard_view_controller_method)
85
+ end
86
+
87
+ private def can_edit
88
+ return @can_edit if defined?(@can_edit)
89
+
90
+ return false if Skadi.configuration.dashboard_edit_controller_method.nil?
91
+ return false unless respond_to?(Skadi.configuration.dashboard_edit_controller_method, true)
92
+
93
+ return @can_edit = send(Skadi.configuration.dashboard_edit_controller_method)
94
+ end
95
+
96
+ private def can_dangerously_use_sql
97
+ return @can_dangerously_use_sql if defined?(@can_dangerously_use_sql)
98
+
99
+ return false if Skadi.configuration.dashboard_dangerously_use_sql_controller_method.nil?
100
+ return false unless respond_to?(Skadi.configuration.dashboard_dangerously_use_sql_controller_method, true)
101
+
102
+ return @can_dangerously_use_sql = send(Skadi.configuration.dashboard_dangerously_use_sql_controller_method)
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,106 @@
1
+ module Skadi
2
+ class TrackingController < ActionController::API
3
+ include ActionController::Cookies
4
+
5
+ # Disables the automatic wrapping of JSON parameters into a "tracking" hash
6
+ wrap_parameters false
7
+
8
+ prepend_before_action :limit_payload_size!
9
+
10
+ rate_limit to: 60, within: 1.minute, with: -> { head :too_many_requests }
11
+
12
+ before_action :set_params
13
+ before_action :set_view
14
+
15
+ def track
16
+ if @params["exit_page"].present? && @params["exit_page"].is_a?(String)
17
+ @view.exit_page = Skadi::Url.redact_and_normalise_url(@params["exit_page"])
18
+ end
19
+
20
+ handle_consent @params["consent"]
21
+
22
+ if @params["events"].present? && @params["events"].is_a?(Array)
23
+ handle_events @params["events"]
24
+ end
25
+
26
+ if @params["demographics"].present? && @params["demographics"].is_a?(Array)
27
+ handle_demographics @params["demographics"]
28
+ end
29
+
30
+ @view.verified = true
31
+ @view.visit.verified = true if @view.visit
32
+
33
+ skadi._persist
34
+
35
+ head :no_content
36
+ end
37
+
38
+ private def skadi
39
+ # Disable bot protection, since that will have been done when the visit/view was created
40
+ @_skadi ||= Skadi::ControllerDelegate.new(self, bot_protection: false)
41
+ end
42
+
43
+ private def set_params
44
+ @params = request.request_parameters
45
+ end
46
+
47
+ private def set_view
48
+ # Check that the view token is a valid UUID
49
+ unless @params["view"].is_a?(String) && @params["view"].length == 36
50
+ return head :bad_request
51
+ end
52
+
53
+ @view = Skadi::View.includes(:visit).find_by(view_token: @params["view"])
54
+
55
+ return head :not_found unless @view
56
+
57
+ return head :gone unless @view.created_at > Time.current - Skadi.configuration.visit_duration
58
+
59
+ # We're not using _prepare to generate the view/visit, so we have to set them manually
60
+ skadi._attach(view: @view, visit: @view.visit)
61
+ end
62
+
63
+ private def handle_consent(consent)
64
+ return unless consent.is_a?(Hash)
65
+
66
+ if consent["cookie"] == true || consent["cookie"] == false
67
+ skadi.cookie_consent!(consent["cookie"])
68
+ end
69
+
70
+ if consent["anonymity_set"] == true || consent["anonymity_set"] == false
71
+ skadi.anonymity_set_consent!(consent["anonymity_set"])
72
+ end
73
+
74
+ if consent["user"] == true || consent["user"] == false
75
+ skadi.user_consent!(consent["user"])
76
+ end
77
+ end
78
+
79
+ private def handle_events(events)
80
+ events.each do |event|
81
+ next unless event.is_a?(Hash)
82
+ next unless event["name"].is_a?(String) && event["name"].present?
83
+ next unless event["properties"].is_a?(Hash)
84
+
85
+ skadi.event(event["name"], event["properties"])
86
+ end
87
+ end
88
+
89
+ private def handle_demographics(demographics)
90
+ demographics.each do |demographic|
91
+ next unless demographic.is_a?(Hash)
92
+ next unless demographic["name"].is_a?(String) && demographic["name"].present?
93
+ next unless demographic["value"].is_a?(String) && demographic["value"].present?
94
+ next unless demographic["uri"].nil? || demographic["uri"].is_a?(String)
95
+
96
+ skadi.demographic(demographic["name"], demographic["value"], action_specific: true, uri: demographic["uri"] || "")
97
+ end
98
+ end
99
+
100
+ def limit_payload_size!
101
+ if request.content_length && request.content_length > Skadi.configuration.max_tracking_payload_size
102
+ render json: { error: "Payload too large" }, status: :content_too_large
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,38 @@
1
+ module Skadi
2
+ module ApplicationHelper
3
+ class InvalidSkadiTagType < StandardError; end
4
+
5
+ mattr_accessor :skadi_script_src
6
+
7
+ def skadi_tag(type = :inline)
8
+ return if skadi.do_not_track? || skadi.view.nil?
9
+
10
+ tag_attributes = {
11
+ data: {
12
+ uri: request.route_uri_pattern,
13
+ endpoint: Skadi::Engine.routes.url_helpers.tracking_endpoint_path,
14
+ view: skadi.view.token,
15
+ },
16
+ nonce: content_security_policy_nonce,
17
+ }
18
+ tag_attributes[:data][:visit] = "1" if skadi.new_visit?
19
+
20
+ case type
21
+ when :route
22
+ content_tag("script", "", {
23
+ src: Skadi::Engine.routes.url_helpers.tracking_script_path(v: Skadi::VERSION),
24
+ **tag_attributes,
25
+ })
26
+ when :inline
27
+ self.skadi_script_src ||= Engine.root.join("app", "assets", "builds", "skadi.js").read.html_safe
28
+ content_tag(
29
+ "script",
30
+ skadi_script_src,
31
+ tag_attributes,
32
+ )
33
+ else
34
+ raise InvalidSkadiTagType.new("Invalid type given to skadi_tag. Expecting :route, :inline, but got :#{type}.")
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,5 @@
1
+ module Skadi
2
+ class ApplicationRecord < ::ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,83 @@
1
+ module Skadi
2
+ class Dashboard < ApplicationRecord
3
+ class Error < StandardError; end
4
+ class ChartNotFoundError < Error; end
5
+ class UnsupportedDatabaseError < Error; end
6
+ class UnsupportedOperatorError < Error; end
7
+ class DatasetConfigurationError < Error; end
8
+
9
+ validates_with DashboardValidator
10
+
11
+ # Used by the validator to prevent the unauthorised use of SQL, while allowing existing charts that use SQL to be duplicated.
12
+ attr_accessor :can_dangerously_use_sql
13
+
14
+ DEFAULT_CONFIGURATION = [
15
+ {
16
+ "id" => "7cec5a7a-7bf7-403f-b15e-b2e45944182c",
17
+ "title" => "Dashboard 1",
18
+ "children" => [
19
+ {
20
+ "id" => "7cec5a7a-7bf7-403f-b15e-b2e45944182d",
21
+ "type" => "line",
22
+ "title" => "Visits and Checkouts",
23
+ "time_series" => "weekly",
24
+
25
+ "verified_visits" => true,
26
+ "unique_by" => "visit",
27
+ "visit_tracking" => "any",
28
+
29
+ "datasets" => [
30
+ {
31
+ "id" => "7cec5a7a-7bf7-403f-b15e-b2e45944182e",
32
+ "label" => "Visits",
33
+ "type" => "visits",
34
+ }.freeze,
35
+ ].freeze,
36
+ }.freeze,
37
+ ].freeze,
38
+ }.freeze,
39
+ ].freeze
40
+
41
+ def self.default_configuration = DEFAULT_CONFIGURATION.deep_dup
42
+
43
+ def chart_data(chart_id, filters)
44
+ chart_configuration = find_chart_by_id(chart_id) unless chart_id.nil?
45
+
46
+ raise ChartNotFoundError.new("Unable to find chart with id #{chart_id.inspect}") unless chart_configuration
47
+
48
+ return DashboardQuery.chart_query(chart_configuration, filters)
49
+ end
50
+
51
+ # Returns an array containing the SQL queries in the _persisted_ record - i.e. those that non-permitted users can use/run
52
+ def sql_strings
53
+ return [] unless configuration_was.is_a?(Array)
54
+
55
+ charts = configuration_was.flat_map do |tab|
56
+ (tab.is_a?(Hash) && tab["children"].is_a?(Array)) ? tab["children"] : []
57
+ end
58
+ datasets = charts.flat_map do |chart|
59
+ (chart.is_a?(Hash) && chart["datasets"].is_a?(Array)) ? chart["datasets"] : []
60
+ end
61
+ return datasets.flat_map do |dataset|
62
+ next [] unless dataset.is_a?(Hash) && dataset["type"] == "sql" && dataset["sql"].is_a?(String)
63
+
64
+ [ dataset["sql"] ]
65
+ end
66
+ end
67
+
68
+ private def find_chart_by_id(chart_id) = find_item_by_id(configuration, chart_id)
69
+
70
+ private def find_item_by_id(items, id)
71
+ items.each do |item|
72
+ return item if item["id"] == id
73
+
74
+ if item["children"].is_a?(Array)
75
+ child_item = find_item_by_id(item["children"], id)
76
+ return child_item unless child_item.nil?
77
+ end
78
+ end
79
+
80
+ return nil
81
+ end
82
+ end
83
+ end