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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE.md +22 -0
- data/README.md +111 -0
- data/app/assets/builds/dashboard.css +2 -0
- data/app/assets/builds/dashboard.js +36 -0
- data/app/assets/builds/skadi.js +1 -0
- data/app/controllers/skadi/application_controller.rb +4 -0
- data/app/controllers/skadi/asset_controller.rb +28 -0
- data/app/controllers/skadi/dashboard_controller.rb +105 -0
- data/app/controllers/skadi/tracking_controller.rb +106 -0
- data/app/helpers/skadi/application_helper.rb +38 -0
- data/app/models/skadi/application_record.rb +5 -0
- data/app/models/skadi/dashboard.rb +83 -0
- data/app/models/skadi/dashboard_query.rb +314 -0
- data/app/models/skadi/dashboard_validator.rb +269 -0
- data/app/models/skadi/demographic.rb +31 -0
- data/app/models/skadi/event.rb +33 -0
- data/app/models/skadi/helpers/sql.rb +86 -0
- data/app/models/skadi/schema.rb +135 -0
- data/app/models/skadi/view.rb +9 -0
- data/app/models/skadi/visit.rb +59 -0
- data/app/views/skadi/dashboard/_layout.html.erb +21 -0
- data/app/views/skadi/dashboard/show.html.erb +18 -0
- data/config/routes.rb +14 -0
- data/lib/generators/skadi/install/USAGE +20 -0
- data/lib/generators/skadi/install/install_generator.rb +46 -0
- data/lib/generators/skadi/install/templates/skadi_migration.rb.erb +111 -0
- data/lib/skadi/analytics.rb +35 -0
- data/lib/skadi/anonymity_set.rb +38 -0
- data/lib/skadi/configuration.rb +232 -0
- data/lib/skadi/controller_delegate.rb +322 -0
- data/lib/skadi/cookie_manager.rb +81 -0
- data/lib/skadi/engine.rb +38 -0
- data/lib/skadi/url.rb +66 -0
- data/lib/skadi/user_agent.rb +458 -0
- data/lib/skadi/version.rb +3 -0
- data/lib/skadi.rb +26 -0
- 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,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,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
|