ruby_cms 0.1.0
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/.cursor/dhh.mdc +698 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/README.md +235 -0
- data/Rakefile +30 -0
- data/app/components/ruby_cms/admin/admin_page/admin_table_content.rb +32 -0
- data/app/components/ruby_cms/admin/admin_page.rb +345 -0
- data/app/components/ruby_cms/admin/base_component.rb +78 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table.rb +149 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_actions.rb +127 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_body.rb +15 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_cell.rb +41 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_head.rb +33 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_delete_modal.rb +174 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header.rb +59 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header_bar.rb +159 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_pagination.rb +192 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_row.rb +97 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +137 -0
- data/app/controllers/concerns/ruby_cms/admin_pagination.rb +120 -0
- data/app/controllers/concerns/ruby_cms/admin_turbo_table.rb +68 -0
- data/app/controllers/concerns/ruby_cms/page_tracking.rb +52 -0
- data/app/controllers/concerns/ruby_cms/visitor_error_capture.rb +39 -0
- data/app/controllers/ruby_cms/admin/analytics_controller.rb +191 -0
- data/app/controllers/ruby_cms/admin/base_controller.rb +105 -0
- data/app/controllers/ruby_cms/admin/content_blocks_controller.rb +390 -0
- data/app/controllers/ruby_cms/admin/dashboard_controller.rb +50 -0
- data/app/controllers/ruby_cms/admin/locale_controller.rb +20 -0
- data/app/controllers/ruby_cms/admin/permissions_controller.rb +66 -0
- data/app/controllers/ruby_cms/admin/settings_controller.rb +223 -0
- data/app/controllers/ruby_cms/admin/user_permissions_controller.rb +59 -0
- data/app/controllers/ruby_cms/admin/users_controller.rb +107 -0
- data/app/controllers/ruby_cms/admin/visitor_errors_controller.rb +89 -0
- data/app/controllers/ruby_cms/admin/visual_editor_controller.rb +322 -0
- data/app/controllers/ruby_cms/errors_controller.rb +35 -0
- data/app/helpers/ruby_cms/admin/admin_page_helper.rb +21 -0
- data/app/helpers/ruby_cms/admin/bulk_action_table_helper.rb +159 -0
- data/app/helpers/ruby_cms/application_helper.rb +41 -0
- data/app/helpers/ruby_cms/bulk_action_table_helper.rb +151 -0
- data/app/helpers/ruby_cms/content_blocks_helper.rb +375 -0
- data/app/helpers/ruby_cms/settings_helper.rb +160 -0
- data/app/javascript/controllers/ruby_cms/auto_save_preference_controller.js +73 -0
- data/app/javascript/controllers/ruby_cms/bulk_action_table_controller.js +553 -0
- data/app/javascript/controllers/ruby_cms/clickable_row_controller.js +28 -0
- data/app/javascript/controllers/ruby_cms/flash_messages_controller.js +29 -0
- data/app/javascript/controllers/ruby_cms/index.js +104 -0
- data/app/javascript/controllers/ruby_cms/locale_tabs_controller.js +34 -0
- data/app/javascript/controllers/ruby_cms/mobile_menu_controller.js +55 -0
- data/app/javascript/controllers/ruby_cms/nav_order_sortable_controller.js +192 -0
- data/app/javascript/controllers/ruby_cms/page_preview_controller.js +135 -0
- data/app/javascript/controllers/ruby_cms/toggle_controller.js +39 -0
- data/app/javascript/controllers/ruby_cms/visual_editor_controller.js +321 -0
- data/app/models/concerns/content_block/publishable.rb +54 -0
- data/app/models/concerns/content_block/searchable.rb +22 -0
- data/app/models/content_block.rb +155 -0
- data/app/models/ruby_cms/content_block.rb +8 -0
- data/app/models/ruby_cms/permission.rb +28 -0
- data/app/models/ruby_cms/permittable.rb +39 -0
- data/app/models/ruby_cms/preference.rb +111 -0
- data/app/models/ruby_cms/user_permission.rb +12 -0
- data/app/models/ruby_cms/visitor_error.rb +109 -0
- data/app/services/ruby_cms/analytics/report.rb +362 -0
- data/app/services/ruby_cms/security_tracker.rb +92 -0
- data/app/views/layouts/ruby_cms/_admin_flash_messages.html.erb +37 -0
- data/app/views/layouts/ruby_cms/_admin_sidebar.html.erb +121 -0
- data/app/views/layouts/ruby_cms/admin.html.erb +81 -0
- data/app/views/layouts/ruby_cms/minimal.html.erb +181 -0
- data/app/views/ruby_cms/admin/analytics/index.html.erb +160 -0
- data/app/views/ruby_cms/admin/analytics/page_details.html.erb +84 -0
- data/app/views/ruby_cms/admin/analytics/partials/_back_button.html.erb +3 -0
- data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +40 -0
- data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +58 -0
- data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +51 -0
- data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +31 -0
- data/app/views/ruby_cms/admin/analytics/partials/_security_alert.html.erb +4 -0
- data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +21 -0
- data/app/views/ruby_cms/admin/analytics/visitor_details.html.erb +125 -0
- data/app/views/ruby_cms/admin/content_blocks/_form.html.erb +161 -0
- data/app/views/ruby_cms/admin/content_blocks/_row.html.erb +25 -0
- data/app/views/ruby_cms/admin/content_blocks/edit.html.erb +17 -0
- data/app/views/ruby_cms/admin/content_blocks/index.html.erb +66 -0
- data/app/views/ruby_cms/admin/content_blocks/new.html.erb +5 -0
- data/app/views/ruby_cms/admin/content_blocks/show.html.erb +110 -0
- data/app/views/ruby_cms/admin/dashboard/index.html.erb +198 -0
- data/app/views/ruby_cms/admin/permissions/_row.html.erb +11 -0
- data/app/views/ruby_cms/admin/permissions/index.html.erb +62 -0
- data/app/views/ruby_cms/admin/settings/index.html.erb +220 -0
- data/app/views/ruby_cms/admin/shared/_bulk_action_table_index.html.erb +56 -0
- data/app/views/ruby_cms/admin/user_permissions/index.html.erb +55 -0
- data/app/views/ruby_cms/admin/users/_row.html.erb +14 -0
- data/app/views/ruby_cms/admin/users/index.html.erb +70 -0
- data/app/views/ruby_cms/admin/visitor_errors/_row.html.erb +35 -0
- data/app/views/ruby_cms/admin/visitor_errors/index.html.erb +57 -0
- data/app/views/ruby_cms/admin/visitor_errors/show.html.erb +147 -0
- data/app/views/ruby_cms/admin/visual_editor/index.html.erb +144 -0
- data/app/views/ruby_cms/errors/not_found.html.erb +92 -0
- data/config/database.yml +6 -0
- data/config/importmap.rb +36 -0
- data/config/locales/en.yml +101 -0
- data/config/routes.rb +65 -0
- data/db/migrate/20260125000001_create_ruby_cms_permissions.rb +14 -0
- data/db/migrate/20260125000002_create_ruby_cms_user_permissions.rb +14 -0
- data/db/migrate/20260125000003_create_ruby_cms_content_blocks.rb +19 -0
- data/db/migrate/20260125000010_add_indexes_to_ruby_cms_tables.rb +9 -0
- data/db/migrate/20260127000001_add_locale_to_ruby_cms_content_blocks.rb +34 -0
- data/db/migrate/20260129000001_create_ruby_cms_visitor_errors.rb +24 -0
- data/db/migrate/20260130000001_add_referer_and_query_to_ruby_cms_visitor_errors.rb +8 -0
- data/db/migrate/20260130000002_create_ruby_cms_preferences.rb +16 -0
- data/db/migrate/20260130000003_add_category_to_ruby_cms_preferences.rb +8 -0
- data/db/migrate/20260211000001_add_ruby_cms_analytics_fields_to_ahoy_events.rb +19 -0
- data/db/migrate/20260212000001_use_unprefixed_cms_tables.rb +146 -0
- data/exe/ruby_cms +25 -0
- data/lib/generators/ruby_cms/install_generator.rb +1062 -0
- data/lib/generators/ruby_cms/templates/admin.html.erb +82 -0
- data/lib/generators/ruby_cms/templates/ruby_cms.rb +86 -0
- data/lib/ruby_cms/app_integration.rb +82 -0
- data/lib/ruby_cms/cli.rb +169 -0
- data/lib/ruby_cms/content_blocks_grouping.rb +41 -0
- data/lib/ruby_cms/content_blocks_sync.rb +329 -0
- data/lib/ruby_cms/css_compiler.rb +35 -0
- data/lib/ruby_cms/engine.rb +498 -0
- data/lib/ruby_cms/settings.rb +145 -0
- data/lib/ruby_cms/settings_registry.rb +289 -0
- data/lib/ruby_cms/version.rb +5 -0
- data/lib/ruby_cms.rb +195 -0
- data/lib/tasks/ruby_cms.rake +27 -0
- data/log/test.log +17875 -0
- data/sig/ruby_cms.rbs +4 -0
- metadata +223 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCms
|
|
4
|
+
# Stores configuration preferences for the CMS admin interface.
|
|
5
|
+
# Preferences are key-value pairs with optional type casting.
|
|
6
|
+
class Preference < ::ApplicationRecord
|
|
7
|
+
self.table_name = "preferences"
|
|
8
|
+
|
|
9
|
+
VALUE_TYPES = %w[string integer boolean json].freeze
|
|
10
|
+
|
|
11
|
+
validates :key, presence: true, uniqueness: true
|
|
12
|
+
validates :value_type, inclusion: { in: VALUE_TYPES }
|
|
13
|
+
|
|
14
|
+
def self.get(key, default: nil)
|
|
15
|
+
pref = find_by(key: key.to_s)
|
|
16
|
+
return default if pref.nil?
|
|
17
|
+
|
|
18
|
+
pref.typed_value
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.set(key, value)
|
|
22
|
+
pref = find_or_initialize_by(key: key.to_s)
|
|
23
|
+
pref.assign_value(value)
|
|
24
|
+
pref.save!
|
|
25
|
+
pref.typed_value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.all_as_hash
|
|
29
|
+
all.to_h {|pref| [pref.key.to_sym, pref.typed_value] }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.ensure_defaults!
|
|
33
|
+
defaults.each do |key, config|
|
|
34
|
+
next if exists?(key: key.to_s)
|
|
35
|
+
|
|
36
|
+
create!(
|
|
37
|
+
key: key.to_s,
|
|
38
|
+
value: serialize_seed_value(config[:value], config[:type]),
|
|
39
|
+
value_type: config[:type],
|
|
40
|
+
description: config[:description],
|
|
41
|
+
category: config[:category] || "general"
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.by_category
|
|
47
|
+
all.group_by(&:category)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.defaults
|
|
51
|
+
RubyCms::SettingsRegistry.defaults_hash
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def typed_value
|
|
55
|
+
case value_type
|
|
56
|
+
when "integer" then value.to_i
|
|
57
|
+
when "boolean" then boolean_cast(value)
|
|
58
|
+
when "json" then parse_json_value(value)
|
|
59
|
+
else value
|
|
60
|
+
end
|
|
61
|
+
rescue JSON::ParserError, TypeError
|
|
62
|
+
value.to_s
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def assign_value(new_value)
|
|
66
|
+
self.value_type ||= detect_type(new_value)
|
|
67
|
+
self.value = serialize_value(new_value)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class << self
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def serialize_seed_value(val, type)
|
|
74
|
+
case type.to_s
|
|
75
|
+
when "json"
|
|
76
|
+
val.to_json
|
|
77
|
+
when "boolean"
|
|
78
|
+
ActiveModel::Type::Boolean.new.cast(val).to_s
|
|
79
|
+
else
|
|
80
|
+
val.to_s
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def boolean_cast(val)
|
|
88
|
+
ActiveModel::Type::Boolean.new.cast(val)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def parse_json_value(val)
|
|
92
|
+
JSON.parse(val.to_s)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def detect_type(val)
|
|
96
|
+
case val
|
|
97
|
+
when Integer then "integer"
|
|
98
|
+
when TrueClass, FalseClass then "boolean"
|
|
99
|
+
when Hash, Array then "json"
|
|
100
|
+
else "string"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def serialize_value(val)
|
|
105
|
+
case val
|
|
106
|
+
when Hash, Array then val.to_json
|
|
107
|
+
else val.to_s
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCms
|
|
4
|
+
class UserPermission < ::ApplicationRecord
|
|
5
|
+
self.table_name = "user_permissions"
|
|
6
|
+
|
|
7
|
+
belongs_to :user, class_name: "User", optional: false
|
|
8
|
+
belongs_to :permission, class_name: "RubyCms::Permission"
|
|
9
|
+
|
|
10
|
+
validates :user_id, uniqueness: { scope: :permission_id }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module RubyCms
|
|
6
|
+
class VisitorError < ::ApplicationRecord
|
|
7
|
+
self.table_name = "visitor_errors"
|
|
8
|
+
|
|
9
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
10
|
+
scope :unresolved, -> { where(resolved: false) }
|
|
11
|
+
scope :by_page, ->(path) { where(request_path: path) }
|
|
12
|
+
scope :today, -> { where(created_at: Date.current.beginning_of_day..) }
|
|
13
|
+
|
|
14
|
+
def self.log_error(exception, request)
|
|
15
|
+
create!(
|
|
16
|
+
**base_request_attrs(request),
|
|
17
|
+
**exception_attrs(exception),
|
|
18
|
+
request_params: sanitize_params(request.params)
|
|
19
|
+
)
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
Rails.logger.error "Failed to log visitor error: #{e.message}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Log routing errors (404s) from catch-all route.
|
|
25
|
+
# Called by RubyCms::ErrorsController#not_found
|
|
26
|
+
def self.log_routing_error(request)
|
|
27
|
+
return if Rails.env.development?
|
|
28
|
+
return if request.path.start_with?("/admin")
|
|
29
|
+
|
|
30
|
+
create!(
|
|
31
|
+
**base_request_attrs(request),
|
|
32
|
+
error_class: "ActionController::RoutingError",
|
|
33
|
+
error_message: routing_error_message(request),
|
|
34
|
+
backtrace: nil,
|
|
35
|
+
request_params: nil
|
|
36
|
+
)
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
Rails.logger.error "Failed to log routing error: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns the code path: backtrace for exceptions, or synthetic path for routing errors
|
|
42
|
+
def codepath
|
|
43
|
+
if backtrace.present?
|
|
44
|
+
backtrace
|
|
45
|
+
elsif error_class == "ActionController::RoutingError"
|
|
46
|
+
<<~TEXT.strip
|
|
47
|
+
Request → Router (no matching route) → RubyCms::ErrorsController#not_found
|
|
48
|
+
(Routing errors don't generate stack traces; the request never reached a controller action.)
|
|
49
|
+
TEXT
|
|
50
|
+
else
|
|
51
|
+
"No stack trace available."
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def browser_info
|
|
56
|
+
return "Unknown" if user_agent.blank?
|
|
57
|
+
|
|
58
|
+
case user_agent
|
|
59
|
+
when /Chrome/ then "Chrome"
|
|
60
|
+
when /Firefox/ then "Firefox"
|
|
61
|
+
when /Safari(?!.*Chrome)/ then "Safari"
|
|
62
|
+
when /Edge/ then "Edge"
|
|
63
|
+
else "Other"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class << self
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def sanitize_params(params)
|
|
71
|
+
return nil unless params
|
|
72
|
+
|
|
73
|
+
h = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h
|
|
74
|
+
filtered = h.except("password", "password_confirmation", "authenticity_token")
|
|
75
|
+
filtered.to_json.truncate(500)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def base_request_attrs(request)
|
|
79
|
+
{
|
|
80
|
+
request_path: request.path,
|
|
81
|
+
request_method: request.request_method,
|
|
82
|
+
ip_address: request.remote_ip,
|
|
83
|
+
user_agent: request.user_agent,
|
|
84
|
+
session_id: safe_session_id(request),
|
|
85
|
+
referer: request.referer.presence&.truncate(500),
|
|
86
|
+
query_string: request.query_string.presence&.truncate(500)
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def exception_attrs(exception)
|
|
91
|
+
{
|
|
92
|
+
error_class: exception.class.name,
|
|
93
|
+
error_message: exception.message,
|
|
94
|
+
backtrace: exception.backtrace&.first(10)&.join("\n")
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def routing_error_message(request)
|
|
99
|
+
"No route matches [#{request.request_method}] \"#{request.path}\""
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def safe_session_id(request)
|
|
103
|
+
request.session.id
|
|
104
|
+
rescue StandardError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCms
|
|
4
|
+
module Analytics
|
|
5
|
+
class Report
|
|
6
|
+
DEFAULT_CACHE_DURATION_SECONDS = 600
|
|
7
|
+
DEFAULT_MAX_POPULAR_PAGES = 10
|
|
8
|
+
DEFAULT_MAX_TOP_VISITORS = 10
|
|
9
|
+
DEFAULT_MAX_REFERRERS = 10
|
|
10
|
+
DEFAULT_MAX_LANDING_PAGES = 10
|
|
11
|
+
DEFAULT_MAX_UTM_SOURCES = 10
|
|
12
|
+
DEFAULT_HIGH_VOLUME_THRESHOLD = 1000
|
|
13
|
+
DEFAULT_RAPID_REQUEST_THRESHOLD = 50
|
|
14
|
+
DEFAULT_RECENT_PAGE_VIEWS_LIMIT = 25
|
|
15
|
+
DEFAULT_PAGE_DETAILS_LIMIT = 100
|
|
16
|
+
DEFAULT_VISITOR_DETAILS_LIMIT = 100
|
|
17
|
+
|
|
18
|
+
def initialize(start_date:, end_date:, period: nil)
|
|
19
|
+
@start_date = start_date.to_date.beginning_of_day
|
|
20
|
+
@end_date = end_date.to_date.end_of_day
|
|
21
|
+
@period = period.presence || RubyCms::Settings.get(:analytics_default_period,
|
|
22
|
+
default: "week").to_s
|
|
23
|
+
@range = @start_date..@end_date
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def dashboard_stats
|
|
27
|
+
Rails.cache.fetch(cache_key("dashboard"), expires_in: cache_duration) do
|
|
28
|
+
{
|
|
29
|
+
total_page_views: page_view_events.count,
|
|
30
|
+
unique_visitors: visits.distinct.count(:visitor_token),
|
|
31
|
+
total_sessions: visits.distinct.count(:visit_token),
|
|
32
|
+
popular_pages: popular_pages_data,
|
|
33
|
+
top_visitors: top_visitors_data,
|
|
34
|
+
hourly_activity: hourly_activity_data,
|
|
35
|
+
daily_activity: daily_activity_data,
|
|
36
|
+
daily_visitors: daily_visitors_data,
|
|
37
|
+
top_referrers: referrer_data,
|
|
38
|
+
landing_pages: landing_pages_data,
|
|
39
|
+
utm_sources: utm_sources_data,
|
|
40
|
+
browser_stats: visits.where.not(browser: [nil, ""]).group(:browser).count,
|
|
41
|
+
device_stats: visits.where.not(device_type: [nil, ""]).group(:device_type).count,
|
|
42
|
+
os_stats: visits.where.not(os: [nil, ""]).group(:os).count,
|
|
43
|
+
suspicious_activity: suspicious_activity_data,
|
|
44
|
+
recent_page_views: page_view_events.order(time: :desc).limit(recent_page_views_limit),
|
|
45
|
+
extra_cards: extra_cards_data
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def page_stats(page_name)
|
|
51
|
+
scoped = page_view_events.where(page_name:)
|
|
52
|
+
|
|
53
|
+
{
|
|
54
|
+
page_views: scoped.order(time: :desc).limit(page_details_limit),
|
|
55
|
+
stats: {
|
|
56
|
+
total_views: scoped.count,
|
|
57
|
+
unique_visitors: scoped.joins(:visit).distinct.count("ahoy_visits.visitor_token"),
|
|
58
|
+
avg_views_per_day: (scoped.count.to_f / days_in_range).round(2)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def visitor_stats(ip_address)
|
|
64
|
+
visitor_visits = visits.where(ip: ip_address)
|
|
65
|
+
visitor_events = page_view_events.joins(:visit).where(ahoy_visits: { ip: ip_address })
|
|
66
|
+
|
|
67
|
+
{
|
|
68
|
+
visitor_views: visitor_events.order(time: :desc).limit(visitor_details_limit),
|
|
69
|
+
stats: {
|
|
70
|
+
total_views: visitor_events.count,
|
|
71
|
+
unique_pages: visitor_events.where.not(page_name: [nil, ""]).distinct.count(:page_name),
|
|
72
|
+
first_visit: visitor_visits.minimum(:started_at),
|
|
73
|
+
last_visit: visitor_visits.maximum(:started_at)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def ruby_cms_config
|
|
81
|
+
Rails.application.config.ruby_cms
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def cache_duration
|
|
85
|
+
RubyCms::Settings.get(
|
|
86
|
+
:analytics_cache_duration_seconds,
|
|
87
|
+
default: DEFAULT_CACHE_DURATION_SECONDS
|
|
88
|
+
).to_i.seconds
|
|
89
|
+
rescue StandardError
|
|
90
|
+
DEFAULT_CACHE_DURATION_SECONDS.seconds
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def cache_key(suffix)
|
|
94
|
+
"ruby_cms:analytics:#{@start_date.to_date}:#{@end_date.to_date}:#{@period}:#{suffix}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def visits
|
|
98
|
+
base = Ahoy::Visit.where(started_at: @range)
|
|
99
|
+
# Use all visits by default so existing DB records are included. Bot exclusion
|
|
100
|
+
# can be applied via config.analytics_visit_scope (e.g. ->(s) { s.exclude_bots }).
|
|
101
|
+
apply_visit_scope_hook(base)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def page_view_events
|
|
105
|
+
base = Ahoy::Event.where(name: "page_view", time: @range).joins(:visit).merge(visits)
|
|
106
|
+
apply_event_scope_hook(base)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def apply_visit_scope_hook(scope)
|
|
110
|
+
hook = ruby_cms_config.analytics_visit_scope
|
|
111
|
+
return scope unless hook.respond_to?(:call)
|
|
112
|
+
|
|
113
|
+
hook.call(scope)
|
|
114
|
+
rescue StandardError
|
|
115
|
+
scope
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def apply_event_scope_hook(scope)
|
|
119
|
+
hook = ruby_cms_config.analytics_event_scope
|
|
120
|
+
return scope unless hook.respond_to?(:call)
|
|
121
|
+
|
|
122
|
+
hook.call(scope)
|
|
123
|
+
rescue StandardError
|
|
124
|
+
scope
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def popular_pages_data
|
|
128
|
+
limit = RubyCms::Settings.get(:analytics_max_popular_pages,
|
|
129
|
+
default: DEFAULT_MAX_POPULAR_PAGES).to_i
|
|
130
|
+
|
|
131
|
+
page_view_events
|
|
132
|
+
.where.not(page_name: [nil, ""])
|
|
133
|
+
.group(:page_name)
|
|
134
|
+
.order(Arel.sql("count_all DESC"))
|
|
135
|
+
.limit(limit)
|
|
136
|
+
.count
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def top_visitors_data
|
|
140
|
+
limit = RubyCms::Settings.get(:analytics_max_top_visitors,
|
|
141
|
+
default: DEFAULT_MAX_TOP_VISITORS).to_i
|
|
142
|
+
visits.group(:ip).order(Arel.sql("COUNT(*) DESC")).limit(limit).count
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def hourly_activity_data
|
|
146
|
+
# Portable: group in Ruby so we work on SQLite, PostgreSQL, MySQL
|
|
147
|
+
raw = page_view_events.pluck(:time).each_with_object(Hash.new(0)) do |t, acc|
|
|
148
|
+
acc[t.utc.strftime("%H")] += 1
|
|
149
|
+
end
|
|
150
|
+
("00".."23").index_with {|h| raw[h] || 0 }.sort.to_h
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def daily_activity_data
|
|
154
|
+
case @period
|
|
155
|
+
when "day"
|
|
156
|
+
hourly_activity_data
|
|
157
|
+
when "year"
|
|
158
|
+
group_by_month
|
|
159
|
+
else
|
|
160
|
+
group_by_date
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def group_by_date
|
|
165
|
+
result = Hash.new(0)
|
|
166
|
+
page_view_events.pluck(:time).each do |time|
|
|
167
|
+
result[time.to_date.strftime("%Y-%m-%d")] += 1
|
|
168
|
+
end
|
|
169
|
+
fill_date_gaps(result)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def group_by_month
|
|
173
|
+
raw = page_view_events.pluck(:time).each_with_object(Hash.new(0)) do |t, acc|
|
|
174
|
+
acc[t.strftime("%Y-%m")] += 1
|
|
175
|
+
end
|
|
176
|
+
fill_month_gaps(raw)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def fill_month_gaps(data)
|
|
180
|
+
result = {}
|
|
181
|
+
current = @start_date.to_date.beginning_of_month
|
|
182
|
+
end_month = @end_date.to_date.beginning_of_month
|
|
183
|
+
while current <= end_month
|
|
184
|
+
key = current.strftime("%Y-%m")
|
|
185
|
+
result[key] = data[key] || 0
|
|
186
|
+
current = current.next_month
|
|
187
|
+
end
|
|
188
|
+
result
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def daily_visitors_data
|
|
192
|
+
case @period
|
|
193
|
+
when "day"
|
|
194
|
+
{}
|
|
195
|
+
when "year"
|
|
196
|
+
group_visitors_by_month
|
|
197
|
+
else
|
|
198
|
+
group_visitors_by_date
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def group_visitors_by_date
|
|
203
|
+
h = visits.pluck(:visitor_token, :started_at).each_with_object(Hash.new do |hash, k|
|
|
204
|
+
hash[k] = Set.new
|
|
205
|
+
end) do |(vt, st), acc|
|
|
206
|
+
next if st.blank?
|
|
207
|
+
|
|
208
|
+
key = st.respond_to?(:strftime) ? st.strftime("%Y-%m-%d") : st.to_s[0, 10]
|
|
209
|
+
acc[key] << vt
|
|
210
|
+
end
|
|
211
|
+
fill_date_gaps(h.transform_values(&:size))
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def group_visitors_by_month
|
|
215
|
+
h = visits.pluck(:visitor_token, :started_at).each_with_object(Hash.new do |hash, k|
|
|
216
|
+
hash[k] = Set.new
|
|
217
|
+
end) do |(vt, st), acc|
|
|
218
|
+
next if st.blank?
|
|
219
|
+
|
|
220
|
+
key = st.respond_to?(:strftime) ? st.strftime("%Y-%m") : st.to_s[0, 7]
|
|
221
|
+
acc[key] << vt
|
|
222
|
+
end
|
|
223
|
+
raw = h.transform_values(&:size)
|
|
224
|
+
fill_month_gaps(raw)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def referrer_data
|
|
228
|
+
limit = RubyCms::Settings.get(:analytics_max_referrers, default: DEFAULT_MAX_REFERRERS).to_i
|
|
229
|
+
return {} unless Ahoy::Visit.column_names.include?("referrer")
|
|
230
|
+
|
|
231
|
+
visits.where.not(referrer: [nil, ""])
|
|
232
|
+
.group(:referrer)
|
|
233
|
+
.order(Arel.sql("COUNT(*) DESC"))
|
|
234
|
+
.limit(limit)
|
|
235
|
+
.count
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def landing_pages_data
|
|
239
|
+
limit = RubyCms::Settings.get(:analytics_max_landing_pages,
|
|
240
|
+
default: DEFAULT_MAX_LANDING_PAGES).to_i
|
|
241
|
+
return {} unless Ahoy::Visit.column_names.include?("landing_page")
|
|
242
|
+
|
|
243
|
+
visits.where.not(landing_page: [nil, ""])
|
|
244
|
+
.group(:landing_page)
|
|
245
|
+
.order(Arel.sql("COUNT(*) DESC"))
|
|
246
|
+
.limit(limit)
|
|
247
|
+
.count
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def utm_sources_data
|
|
251
|
+
limit = RubyCms::Settings.get(:analytics_max_utm_sources,
|
|
252
|
+
default: DEFAULT_MAX_UTM_SOURCES).to_i
|
|
253
|
+
return {} unless Ahoy::Visit.column_names.include?("utm_source")
|
|
254
|
+
|
|
255
|
+
raw = visits.where.not(utm_source: [nil, ""])
|
|
256
|
+
.group(:utm_source, :utm_medium)
|
|
257
|
+
.order(Arel.sql("COUNT(*) DESC"))
|
|
258
|
+
.limit(limit)
|
|
259
|
+
.count
|
|
260
|
+
raw.transform_keys do |(source, medium)|
|
|
261
|
+
"#{source}#{" / #{medium}" if medium.present?}"
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def suspicious_activity_data
|
|
266
|
+
[].tap do |items|
|
|
267
|
+
add_high_volume_ips(items)
|
|
268
|
+
add_rapid_requests(items)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def add_high_volume_ips(items)
|
|
273
|
+
threshold = RubyCms::Settings.get(
|
|
274
|
+
:analytics_high_volume_threshold,
|
|
275
|
+
default: DEFAULT_HIGH_VOLUME_THRESHOLD
|
|
276
|
+
).to_i
|
|
277
|
+
|
|
278
|
+
visits.group(:ip).having("COUNT(*) > ?", threshold).count.each do |ip, count|
|
|
279
|
+
items << {
|
|
280
|
+
type: "high_volume",
|
|
281
|
+
ip: ip,
|
|
282
|
+
count: count,
|
|
283
|
+
description: "High volume traffic from IP #{ip}"
|
|
284
|
+
}
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def add_rapid_requests(items)
|
|
289
|
+
threshold = RubyCms::Settings.get(
|
|
290
|
+
:analytics_rapid_request_threshold,
|
|
291
|
+
default: DEFAULT_RAPID_REQUEST_THRESHOLD
|
|
292
|
+
).to_i
|
|
293
|
+
|
|
294
|
+
per_ip_per_minute = Hash.new(0)
|
|
295
|
+
visits.pluck(:ip, :started_at).each do |ip, started_at|
|
|
296
|
+
next if started_at.blank?
|
|
297
|
+
|
|
298
|
+
minute_key = if started_at.respond_to?(:strftime)
|
|
299
|
+
started_at.strftime("%Y-%m-%d %H:%M")
|
|
300
|
+
else
|
|
301
|
+
started_at.to_s[0,
|
|
302
|
+
16]
|
|
303
|
+
end
|
|
304
|
+
per_ip_per_minute[[ip, minute_key]] += 1
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
per_ip_per_minute.each do |(ip, minute_key), count|
|
|
308
|
+
next unless count > threshold
|
|
309
|
+
|
|
310
|
+
items << {
|
|
311
|
+
type: "rapid_requests",
|
|
312
|
+
ip: ip,
|
|
313
|
+
count: count,
|
|
314
|
+
description: "Rapid requests from #{ip} at #{minute_key}"
|
|
315
|
+
}
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def fill_date_gaps(data)
|
|
320
|
+
(@start_date.to_date..@end_date.to_date).each_with_object({}) do |date, acc|
|
|
321
|
+
key = date.strftime("%Y-%m-%d")
|
|
322
|
+
acc[key] = data[key] || 0
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def extra_cards_data
|
|
327
|
+
hook = ruby_cms_config.analytics_extra_cards
|
|
328
|
+
return [] unless hook.respond_to?(:call)
|
|
329
|
+
|
|
330
|
+
cards = hook.call(
|
|
331
|
+
start_date: @start_date.to_date,
|
|
332
|
+
end_date: @end_date.to_date,
|
|
333
|
+
period: @period,
|
|
334
|
+
visits_scope: visits,
|
|
335
|
+
events_scope: page_view_events
|
|
336
|
+
)
|
|
337
|
+
Array(cards)
|
|
338
|
+
rescue StandardError
|
|
339
|
+
[]
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def days_in_range
|
|
343
|
+
(@end_date.to_date - @start_date.to_date + 1).to_i
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def recent_page_views_limit
|
|
347
|
+
RubyCms::Settings.get(:analytics_recent_page_views_limit,
|
|
348
|
+
default: DEFAULT_RECENT_PAGE_VIEWS_LIMIT).to_i
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def page_details_limit
|
|
352
|
+
RubyCms::Settings.get(:analytics_page_details_limit,
|
|
353
|
+
default: DEFAULT_PAGE_DETAILS_LIMIT).to_i
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def visitor_details_limit
|
|
357
|
+
RubyCms::Settings.get(:analytics_visitor_details_limit,
|
|
358
|
+
default: DEFAULT_VISITOR_DETAILS_LIMIT).to_i
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCms
|
|
4
|
+
# Tracks security events to Ahoy::Event for analytics and monitoring.
|
|
5
|
+
# Use from host app controllers: RubyCms::SecurityTracker.track(...)
|
|
6
|
+
#
|
|
7
|
+
# Example:
|
|
8
|
+
# RubyCms::SecurityTracker.track("failed_login", description: "Invalid password", request: request)
|
|
9
|
+
class SecurityTracker
|
|
10
|
+
EVENT_TYPES = %w[
|
|
11
|
+
failed_login
|
|
12
|
+
successful_login
|
|
13
|
+
logout
|
|
14
|
+
admin_access_denied
|
|
15
|
+
suspicious_user_agent
|
|
16
|
+
unusual_request_pattern
|
|
17
|
+
session_hijack_attempt
|
|
18
|
+
rate_limit_exceeded
|
|
19
|
+
csrf_token_mismatch
|
|
20
|
+
unauthorized_admin_attempt
|
|
21
|
+
contact_honeypot_triggered
|
|
22
|
+
contact_blocked_email_attempt
|
|
23
|
+
email_blocklist_error
|
|
24
|
+
ip_blocklist_error
|
|
25
|
+
ip_blocklist_blocked
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
def self.track(event_type, description:, user: nil, request: nil, ip_address: nil,
|
|
29
|
+
user_agent: nil, request_path: nil)
|
|
30
|
+
return nil unless EVENT_TYPES.include?(event_type)
|
|
31
|
+
|
|
32
|
+
attrs = {
|
|
33
|
+
name: event_type,
|
|
34
|
+
ip_address: ip_address || request&.remote_ip,
|
|
35
|
+
request_path: request_path || request&.path,
|
|
36
|
+
user_agent: user_agent || request&.user_agent,
|
|
37
|
+
properties: { description: },
|
|
38
|
+
time: Time.current,
|
|
39
|
+
user: user
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
Ahoy::Event.create!(**attrs)
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
Rails.logger.error "Failed to track security event: #{e.message}"
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.risk_level(event_type)
|
|
49
|
+
case event_type
|
|
50
|
+
when "session_hijack_attempt", "unauthorized_admin_attempt" then "high"
|
|
51
|
+
when "failed_login", "csrf_token_mismatch" then "medium"
|
|
52
|
+
when "successful_login", "logout" then "info"
|
|
53
|
+
else "low"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.risk_color(event_type)
|
|
58
|
+
case risk_level(event_type)
|
|
59
|
+
when "high" then "red"
|
|
60
|
+
when "medium" then "yellow"
|
|
61
|
+
when "info" then "green"
|
|
62
|
+
else "blue"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.formatted_description(event)
|
|
67
|
+
event_type = event.name
|
|
68
|
+
ip = event.ip_address
|
|
69
|
+
user_agent = event.user_agent
|
|
70
|
+
path = event.request_path
|
|
71
|
+
description = event.properties&.dig("description") || event.try(:description)
|
|
72
|
+
|
|
73
|
+
case event_type
|
|
74
|
+
when "failed_login" then "Failed login attempt from #{ip}"
|
|
75
|
+
when "successful_login" then "Successful admin login from #{ip}"
|
|
76
|
+
when "logout" then "Admin logout from #{ip}"
|
|
77
|
+
when "admin_access_denied" then "Unauthorized admin access attempt from #{ip}"
|
|
78
|
+
when "unauthorized_admin_attempt" then "Non-admin user attempted admin login"
|
|
79
|
+
when "suspicious_user_agent" then "Suspicious user agent: #{user_agent&.truncate(50)}"
|
|
80
|
+
when "unusual_request_pattern" then "Unusual request pattern: #{path}"
|
|
81
|
+
when "session_hijack_attempt" then "Potential session hijacking from #{ip}"
|
|
82
|
+
when "rate_limit_exceeded" then "Rate limit exceeded from #{ip}"
|
|
83
|
+
when "csrf_token_mismatch" then "CSRF token mismatch on #{path}"
|
|
84
|
+
when "contact_honeypot_triggered" then "Contact honeypot triggered from #{ip}"
|
|
85
|
+
when "contact_blocked_email_attempt" then "Blocked contact email: #{description}"
|
|
86
|
+
when "email_blocklist_error" then "Error adding email to blocklist: #{description}"
|
|
87
|
+
when "ip_blocklist_blocked" then "Blocked IP #{ip} attempting #{path}"
|
|
88
|
+
else description.presence || event_type.humanize
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|