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.
Files changed (131) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/dhh.mdc +698 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +5 -0
  5. data/CODE_OF_CONDUCT.md +10 -0
  6. data/README.md +235 -0
  7. data/Rakefile +30 -0
  8. data/app/components/ruby_cms/admin/admin_page/admin_table_content.rb +32 -0
  9. data/app/components/ruby_cms/admin/admin_page.rb +345 -0
  10. data/app/components/ruby_cms/admin/base_component.rb +78 -0
  11. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table.rb +149 -0
  12. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_actions.rb +127 -0
  13. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_body.rb +15 -0
  14. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_cell.rb +41 -0
  15. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_head.rb +33 -0
  16. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_delete_modal.rb +174 -0
  17. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header.rb +59 -0
  18. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header_bar.rb +159 -0
  19. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_pagination.rb +192 -0
  20. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_row.rb +97 -0
  21. data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +137 -0
  22. data/app/controllers/concerns/ruby_cms/admin_pagination.rb +120 -0
  23. data/app/controllers/concerns/ruby_cms/admin_turbo_table.rb +68 -0
  24. data/app/controllers/concerns/ruby_cms/page_tracking.rb +52 -0
  25. data/app/controllers/concerns/ruby_cms/visitor_error_capture.rb +39 -0
  26. data/app/controllers/ruby_cms/admin/analytics_controller.rb +191 -0
  27. data/app/controllers/ruby_cms/admin/base_controller.rb +105 -0
  28. data/app/controllers/ruby_cms/admin/content_blocks_controller.rb +390 -0
  29. data/app/controllers/ruby_cms/admin/dashboard_controller.rb +50 -0
  30. data/app/controllers/ruby_cms/admin/locale_controller.rb +20 -0
  31. data/app/controllers/ruby_cms/admin/permissions_controller.rb +66 -0
  32. data/app/controllers/ruby_cms/admin/settings_controller.rb +223 -0
  33. data/app/controllers/ruby_cms/admin/user_permissions_controller.rb +59 -0
  34. data/app/controllers/ruby_cms/admin/users_controller.rb +107 -0
  35. data/app/controllers/ruby_cms/admin/visitor_errors_controller.rb +89 -0
  36. data/app/controllers/ruby_cms/admin/visual_editor_controller.rb +322 -0
  37. data/app/controllers/ruby_cms/errors_controller.rb +35 -0
  38. data/app/helpers/ruby_cms/admin/admin_page_helper.rb +21 -0
  39. data/app/helpers/ruby_cms/admin/bulk_action_table_helper.rb +159 -0
  40. data/app/helpers/ruby_cms/application_helper.rb +41 -0
  41. data/app/helpers/ruby_cms/bulk_action_table_helper.rb +151 -0
  42. data/app/helpers/ruby_cms/content_blocks_helper.rb +375 -0
  43. data/app/helpers/ruby_cms/settings_helper.rb +160 -0
  44. data/app/javascript/controllers/ruby_cms/auto_save_preference_controller.js +73 -0
  45. data/app/javascript/controllers/ruby_cms/bulk_action_table_controller.js +553 -0
  46. data/app/javascript/controllers/ruby_cms/clickable_row_controller.js +28 -0
  47. data/app/javascript/controllers/ruby_cms/flash_messages_controller.js +29 -0
  48. data/app/javascript/controllers/ruby_cms/index.js +104 -0
  49. data/app/javascript/controllers/ruby_cms/locale_tabs_controller.js +34 -0
  50. data/app/javascript/controllers/ruby_cms/mobile_menu_controller.js +55 -0
  51. data/app/javascript/controllers/ruby_cms/nav_order_sortable_controller.js +192 -0
  52. data/app/javascript/controllers/ruby_cms/page_preview_controller.js +135 -0
  53. data/app/javascript/controllers/ruby_cms/toggle_controller.js +39 -0
  54. data/app/javascript/controllers/ruby_cms/visual_editor_controller.js +321 -0
  55. data/app/models/concerns/content_block/publishable.rb +54 -0
  56. data/app/models/concerns/content_block/searchable.rb +22 -0
  57. data/app/models/content_block.rb +155 -0
  58. data/app/models/ruby_cms/content_block.rb +8 -0
  59. data/app/models/ruby_cms/permission.rb +28 -0
  60. data/app/models/ruby_cms/permittable.rb +39 -0
  61. data/app/models/ruby_cms/preference.rb +111 -0
  62. data/app/models/ruby_cms/user_permission.rb +12 -0
  63. data/app/models/ruby_cms/visitor_error.rb +109 -0
  64. data/app/services/ruby_cms/analytics/report.rb +362 -0
  65. data/app/services/ruby_cms/security_tracker.rb +92 -0
  66. data/app/views/layouts/ruby_cms/_admin_flash_messages.html.erb +37 -0
  67. data/app/views/layouts/ruby_cms/_admin_sidebar.html.erb +121 -0
  68. data/app/views/layouts/ruby_cms/admin.html.erb +81 -0
  69. data/app/views/layouts/ruby_cms/minimal.html.erb +181 -0
  70. data/app/views/ruby_cms/admin/analytics/index.html.erb +160 -0
  71. data/app/views/ruby_cms/admin/analytics/page_details.html.erb +84 -0
  72. data/app/views/ruby_cms/admin/analytics/partials/_back_button.html.erb +3 -0
  73. data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +40 -0
  74. data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +58 -0
  75. data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +51 -0
  76. data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +31 -0
  77. data/app/views/ruby_cms/admin/analytics/partials/_security_alert.html.erb +4 -0
  78. data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +21 -0
  79. data/app/views/ruby_cms/admin/analytics/visitor_details.html.erb +125 -0
  80. data/app/views/ruby_cms/admin/content_blocks/_form.html.erb +161 -0
  81. data/app/views/ruby_cms/admin/content_blocks/_row.html.erb +25 -0
  82. data/app/views/ruby_cms/admin/content_blocks/edit.html.erb +17 -0
  83. data/app/views/ruby_cms/admin/content_blocks/index.html.erb +66 -0
  84. data/app/views/ruby_cms/admin/content_blocks/new.html.erb +5 -0
  85. data/app/views/ruby_cms/admin/content_blocks/show.html.erb +110 -0
  86. data/app/views/ruby_cms/admin/dashboard/index.html.erb +198 -0
  87. data/app/views/ruby_cms/admin/permissions/_row.html.erb +11 -0
  88. data/app/views/ruby_cms/admin/permissions/index.html.erb +62 -0
  89. data/app/views/ruby_cms/admin/settings/index.html.erb +220 -0
  90. data/app/views/ruby_cms/admin/shared/_bulk_action_table_index.html.erb +56 -0
  91. data/app/views/ruby_cms/admin/user_permissions/index.html.erb +55 -0
  92. data/app/views/ruby_cms/admin/users/_row.html.erb +14 -0
  93. data/app/views/ruby_cms/admin/users/index.html.erb +70 -0
  94. data/app/views/ruby_cms/admin/visitor_errors/_row.html.erb +35 -0
  95. data/app/views/ruby_cms/admin/visitor_errors/index.html.erb +57 -0
  96. data/app/views/ruby_cms/admin/visitor_errors/show.html.erb +147 -0
  97. data/app/views/ruby_cms/admin/visual_editor/index.html.erb +144 -0
  98. data/app/views/ruby_cms/errors/not_found.html.erb +92 -0
  99. data/config/database.yml +6 -0
  100. data/config/importmap.rb +36 -0
  101. data/config/locales/en.yml +101 -0
  102. data/config/routes.rb +65 -0
  103. data/db/migrate/20260125000001_create_ruby_cms_permissions.rb +14 -0
  104. data/db/migrate/20260125000002_create_ruby_cms_user_permissions.rb +14 -0
  105. data/db/migrate/20260125000003_create_ruby_cms_content_blocks.rb +19 -0
  106. data/db/migrate/20260125000010_add_indexes_to_ruby_cms_tables.rb +9 -0
  107. data/db/migrate/20260127000001_add_locale_to_ruby_cms_content_blocks.rb +34 -0
  108. data/db/migrate/20260129000001_create_ruby_cms_visitor_errors.rb +24 -0
  109. data/db/migrate/20260130000001_add_referer_and_query_to_ruby_cms_visitor_errors.rb +8 -0
  110. data/db/migrate/20260130000002_create_ruby_cms_preferences.rb +16 -0
  111. data/db/migrate/20260130000003_add_category_to_ruby_cms_preferences.rb +8 -0
  112. data/db/migrate/20260211000001_add_ruby_cms_analytics_fields_to_ahoy_events.rb +19 -0
  113. data/db/migrate/20260212000001_use_unprefixed_cms_tables.rb +146 -0
  114. data/exe/ruby_cms +25 -0
  115. data/lib/generators/ruby_cms/install_generator.rb +1062 -0
  116. data/lib/generators/ruby_cms/templates/admin.html.erb +82 -0
  117. data/lib/generators/ruby_cms/templates/ruby_cms.rb +86 -0
  118. data/lib/ruby_cms/app_integration.rb +82 -0
  119. data/lib/ruby_cms/cli.rb +169 -0
  120. data/lib/ruby_cms/content_blocks_grouping.rb +41 -0
  121. data/lib/ruby_cms/content_blocks_sync.rb +329 -0
  122. data/lib/ruby_cms/css_compiler.rb +35 -0
  123. data/lib/ruby_cms/engine.rb +498 -0
  124. data/lib/ruby_cms/settings.rb +145 -0
  125. data/lib/ruby_cms/settings_registry.rb +289 -0
  126. data/lib/ruby_cms/version.rb +5 -0
  127. data/lib/ruby_cms.rb +195 -0
  128. data/lib/tasks/ruby_cms.rake +27 -0
  129. data/log/test.log +17875 -0
  130. data/sig/ruby_cms.rbs +4 -0
  131. 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