reactive-actions 0.1.0.pre.alpha.2 → 0.1.0.pre.alpha.3
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 +4 -4
- data/README.md +884 -152
- data/app/controllers/reactive_actions/reactive_actions_controller.rb +5 -1
- data/lib/generators/reactive_actions/install/install_generator.rb +122 -52
- data/lib/generators/reactive_actions/install/templates/initializer.rb +46 -1
- data/lib/reactive_actions/concerns/rate_limiter.rb +174 -0
- data/lib/reactive_actions/concerns/security_checks.rb +108 -0
- data/lib/reactive_actions/configuration.rb +23 -3
- data/lib/reactive_actions/controller/rate_limiter.rb +187 -0
- data/lib/reactive_actions/errors.rb +17 -0
- data/lib/reactive_actions/rate_limiter.rb +165 -0
- data/lib/reactive_actions/reactive_action.rb +5 -0
- data/lib/reactive_actions/version.rb +1 -1
- data/lib/reactive_actions.rb +5 -1
- metadata +6 -2
@@ -4,6 +4,9 @@ module ReactiveActions
|
|
4
4
|
# Main controller for handling reactive action requests
|
5
5
|
# Processes action execution and manages error handling for the ReactiveActions engine
|
6
6
|
class ReactiveActionsController < ApplicationController
|
7
|
+
# Include rate limiting functionality
|
8
|
+
include ReactiveActions::Controller::RateLimiter
|
9
|
+
|
7
10
|
# Allow this action to handle any HTTP method
|
8
11
|
def execute
|
9
12
|
ReactiveActions.logger.info "ReactiveActionsController#execute[#{request.method}]: #{params.inspect}"
|
@@ -118,7 +121,8 @@ module ReactiveActions
|
|
118
121
|
'MissingParameterError' => [:bad_request, 'MISSING_PARAMETER'],
|
119
122
|
'InvalidParametersError' => [:bad_request, 'INVALID_PARAMETERS'],
|
120
123
|
'UnauthorizedError' => [:forbidden, 'UNAUTHORIZED'],
|
121
|
-
'ActionExecutionError' => [:unprocessable_entity, 'EXECUTION_ERROR']
|
124
|
+
'ActionExecutionError' => [:unprocessable_entity, 'EXECUTION_ERROR'],
|
125
|
+
'RateLimitExceededError' => [:too_many_requests, 'RATE_LIMIT_EXCEEDED']
|
122
126
|
}
|
123
127
|
|
124
128
|
error_type = exception.class.name.demodulize
|
@@ -31,6 +31,16 @@ module ReactiveActions
|
|
31
31
|
class_option :quiet, type: :boolean, default: false,
|
32
32
|
desc: 'Run with minimal output'
|
33
33
|
|
34
|
+
# Rate limiting options
|
35
|
+
class_option :enable_rate_limiting, type: :boolean, default: false,
|
36
|
+
desc: 'Enable rate limiting features'
|
37
|
+
class_option :enable_global_rate_limiting, type: :boolean, default: false,
|
38
|
+
desc: 'Enable global controller-level rate limiting'
|
39
|
+
class_option :global_rate_limit, type: :numeric, default: 600,
|
40
|
+
desc: 'Global rate limit (requests per window)'
|
41
|
+
class_option :global_rate_limit_window, type: :string, default: '1.minute',
|
42
|
+
desc: 'Global rate limit window (e.g., "1.minute", "5.minutes")'
|
43
|
+
|
34
44
|
def welcome_message
|
35
45
|
return if options[:quiet]
|
36
46
|
|
@@ -39,24 +49,25 @@ module ReactiveActions
|
|
39
49
|
say ''
|
40
50
|
end
|
41
51
|
|
52
|
+
def gather_configuration
|
53
|
+
# Gather all configuration before creating files
|
54
|
+
setup_rate_limiting_configuration_for_initializer
|
55
|
+
end
|
56
|
+
|
42
57
|
def create_initializer
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
say '✗ Skipped initializer creation', :yellow
|
48
|
-
end
|
58
|
+
return unless options[:quiet] || yes?('Create ReactiveActions initializer? (recommended)', :green)
|
59
|
+
|
60
|
+
template 'initializer.rb', 'config/initializers/reactive_actions.rb'
|
61
|
+
say '✓ Created initializer', :green unless options[:quiet]
|
49
62
|
end
|
50
63
|
|
51
64
|
def create_actions_directory
|
52
|
-
|
53
|
-
empty_directory 'app/reactive_actions'
|
54
|
-
say '✓ Created actions directory', :green unless options[:quiet]
|
65
|
+
return unless options[:quiet] || yes?('Create app/reactive_actions directory?', :green)
|
55
66
|
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
67
|
+
empty_directory 'app/reactive_actions'
|
68
|
+
say '✓ Created actions directory', :green unless options[:quiet]
|
69
|
+
|
70
|
+
ask_for_example_action unless options[:skip_example]
|
60
71
|
end
|
61
72
|
|
62
73
|
def configure_routes
|
@@ -72,14 +83,11 @@ module ReactiveActions
|
|
72
83
|
def configure_javascript
|
73
84
|
return if options[:skip_javascript]
|
74
85
|
return unless javascript_needed?
|
86
|
+
return unless options[:quiet] || yes?('Add ReactiveActions JavaScript client?', :green)
|
75
87
|
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
say '✓ Added JavaScript client with initialization', :green unless options[:quiet]
|
80
|
-
else
|
81
|
-
say '✗ Skipped JavaScript configuration', :yellow
|
82
|
-
end
|
88
|
+
add_javascript_to_importmap
|
89
|
+
add_javascript_initialization
|
90
|
+
say '✓ Added JavaScript client with initialization', :green unless options[:quiet]
|
83
91
|
end
|
84
92
|
|
85
93
|
def javascript_configuration_options
|
@@ -89,6 +97,19 @@ module ReactiveActions
|
|
89
97
|
configure_javascript_options
|
90
98
|
end
|
91
99
|
|
100
|
+
def rate_limiting_configuration
|
101
|
+
# Show rate limiting configuration summary if it was configured
|
102
|
+
return unless @rate_limiting_config && @rate_limiting_config[:rate_limiting_enabled] && !options[:quiet]
|
103
|
+
|
104
|
+
say '', :green
|
105
|
+
say '✓ Rate limiting configured:', :green
|
106
|
+
say " - Rate limiting: #{@rate_limiting_config[:rate_limiting_enabled] ? 'ENABLED' : 'DISABLED'}", :blue
|
107
|
+
say " - Global rate limiting: #{@rate_limiting_config[:global_rate_limiting_enabled] ? 'ENABLED' : 'DISABLED'}", :blue
|
108
|
+
return unless @rate_limiting_config[:global_rate_limiting_enabled]
|
109
|
+
|
110
|
+
say " - Global limit: #{@rate_limiting_config[:global_rate_limit]} requests per #{@rate_limiting_config[:global_rate_limit_window]}", :blue
|
111
|
+
end
|
112
|
+
|
92
113
|
def configuration_options
|
93
114
|
return if options[:quiet]
|
94
115
|
return unless yes?('Configure advanced options?', :green)
|
@@ -110,6 +131,62 @@ module ReactiveActions
|
|
110
131
|
|
111
132
|
private
|
112
133
|
|
134
|
+
def setup_rate_limiting_configuration_for_initializer
|
135
|
+
if rate_limiting_options_provided?
|
136
|
+
configure_rate_limiting_from_options_quietly
|
137
|
+
elsif !options[:quiet] && ask_about_rate_limiting?
|
138
|
+
configure_rate_limiting_interactively
|
139
|
+
else
|
140
|
+
@rate_limiting_config = {}
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def rate_limiting_options_provided?
|
145
|
+
options[:enable_rate_limiting] || options[:enable_global_rate_limiting]
|
146
|
+
end
|
147
|
+
|
148
|
+
def ask_about_rate_limiting?
|
149
|
+
yes?('Configure rate limiting? (optional but recommended for production)', :green)
|
150
|
+
end
|
151
|
+
|
152
|
+
def configure_rate_limiting_from_options_quietly
|
153
|
+
@rate_limiting_config = {
|
154
|
+
rate_limiting_enabled: options[:enable_rate_limiting] || options[:enable_global_rate_limiting],
|
155
|
+
global_rate_limiting_enabled: options[:enable_global_rate_limiting]
|
156
|
+
}
|
157
|
+
|
158
|
+
return unless options[:enable_global_rate_limiting]
|
159
|
+
|
160
|
+
@rate_limiting_config[:global_rate_limit] = options[:global_rate_limit]
|
161
|
+
@rate_limiting_config[:global_rate_limit_window] = options[:global_rate_limit_window]
|
162
|
+
end
|
163
|
+
|
164
|
+
def configure_rate_limiting_interactively
|
165
|
+
@rate_limiting_config = {}
|
166
|
+
|
167
|
+
enable_rate_limiting = yes?('Enable rate limiting features?', :green)
|
168
|
+
@rate_limiting_config[:rate_limiting_enabled] = enable_rate_limiting
|
169
|
+
return unless enable_rate_limiting
|
170
|
+
|
171
|
+
enable_global = yes?('Enable global controller-level rate limiting? (recommended)', :green)
|
172
|
+
@rate_limiting_config[:global_rate_limiting_enabled] = enable_global
|
173
|
+
|
174
|
+
return unless enable_global
|
175
|
+
|
176
|
+
limit = ask('Global rate limit (requests per window):', default: 600)
|
177
|
+
@rate_limiting_config[:global_rate_limit] = limit.to_i
|
178
|
+
|
179
|
+
window = ask('Global rate limit window:',
|
180
|
+
limited_to: %w[30.seconds 1.minute 5.minutes 15.minutes 1.hour],
|
181
|
+
default: '1.minute')
|
182
|
+
@rate_limiting_config[:global_rate_limit_window] = window
|
183
|
+
|
184
|
+
return unless yes?('Configure custom rate limit key generator? (advanced)', :green)
|
185
|
+
|
186
|
+
say 'You can customize how rate limit keys are generated in the initializer.'
|
187
|
+
@rate_limiting_config[:custom_key_generator] = true
|
188
|
+
end
|
189
|
+
|
113
190
|
def should_skip_example_action?
|
114
191
|
return true if options[:skip_example]
|
115
192
|
return true if !options[:quiet] && !yes?('Generate example action file?', :green)
|
@@ -192,26 +269,11 @@ module ReactiveActions
|
|
192
269
|
app_js_path = app_js_paths.find { |path| File.exist?(path) }
|
193
270
|
|
194
271
|
if app_js_path
|
195
|
-
# Check if ReactiveActions is already configured
|
196
272
|
app_js_content = File.read(app_js_path)
|
197
273
|
return if app_js_content.include?('ReactiveActions') || app_js_content.include?('reactive_actions')
|
198
274
|
|
199
|
-
# Generate JavaScript initialization code
|
200
275
|
js_config = generate_javascript_config
|
201
|
-
|
202
|
-
js_code = <<~JAVASCRIPT
|
203
|
-
|
204
|
-
// Import and initialize ReactiveActions
|
205
|
-
import ReactiveActionsClient from "reactive_actions"
|
206
|
-
|
207
|
-
// Create and configure ReactiveActions instance
|
208
|
-
const reactiveActions = new ReactiveActionsClient(#{js_config});
|
209
|
-
|
210
|
-
#{generate_initialization_code}
|
211
|
-
|
212
|
-
// Make ReactiveActions globally available
|
213
|
-
window.ReactiveActions = reactiveActions;
|
214
|
-
JAVASCRIPT
|
276
|
+
js_code = build_javascript_code(js_config)
|
215
277
|
|
216
278
|
append_to_file app_js_path, js_code
|
217
279
|
say "✓ Added ReactiveActions initialization to #{app_js_path}", :green unless options[:quiet]
|
@@ -222,12 +284,9 @@ module ReactiveActions
|
|
222
284
|
|
223
285
|
def generate_javascript_config
|
224
286
|
config = {}
|
225
|
-
|
226
|
-
# Add mount path if different from default
|
227
287
|
mount_path = determine_mount_path || options[:mount_path]
|
228
288
|
config[:baseUrl] = "#{mount_path}/execute" if mount_path != '/reactive_actions'
|
229
289
|
|
230
|
-
# Add configuration options
|
231
290
|
config[:enableAutoBinding] = javascript_option_value(:enable_dom_binding)
|
232
291
|
config[:enableMutationObserver] = javascript_option_value(:enable_mutation_observer)
|
233
292
|
config[:defaultHttpMethod] = javascript_option_value(:default_http_method, 'POST')
|
@@ -240,6 +299,22 @@ module ReactiveActions
|
|
240
299
|
config.empty? ? '{}' : JSON.pretty_generate(config)
|
241
300
|
end
|
242
301
|
|
302
|
+
def build_javascript_code(js_config)
|
303
|
+
<<~JAVASCRIPT
|
304
|
+
|
305
|
+
// Import and initialize ReactiveActions
|
306
|
+
import ReactiveActionsClient from "reactive_actions"
|
307
|
+
|
308
|
+
// Create and configure ReactiveActions instance
|
309
|
+
const reactiveActions = new ReactiveActionsClient(#{js_config});
|
310
|
+
|
311
|
+
#{generate_initialization_code}
|
312
|
+
|
313
|
+
// Make ReactiveActions globally available
|
314
|
+
window.ReactiveActions = reactiveActions;
|
315
|
+
JAVASCRIPT
|
316
|
+
end
|
317
|
+
|
243
318
|
def generate_initialization_code
|
244
319
|
if javascript_option_value(:auto_initialize)
|
245
320
|
<<~JAVASCRIPT.strip
|
@@ -249,10 +324,7 @@ module ReactiveActions
|
|
249
324
|
document.addEventListener('turbo:frame-load', () => reactiveActions.initialize());
|
250
325
|
JAVASCRIPT
|
251
326
|
else
|
252
|
-
|
253
|
-
// Manual initialization - call reactiveActions.initialize() when ready
|
254
|
-
// Example: reactiveActions.initialize();
|
255
|
-
JAVASCRIPT
|
327
|
+
'// Manual initialization - call reactiveActions.initialize() when ready'
|
256
328
|
end
|
257
329
|
end
|
258
330
|
|
@@ -271,22 +343,17 @@ module ReactiveActions
|
|
271
343
|
def configure_javascript_options
|
272
344
|
return unless yes?('Configure JavaScript client options?', :green)
|
273
345
|
|
274
|
-
# Auto-initialize option
|
275
|
-
auto_init = yes?('Auto-initialize ReactiveActions on page load? (recommended)', :green)
|
276
346
|
@javascript_options ||= {}
|
277
|
-
@javascript_options[:auto_initialize] =
|
347
|
+
@javascript_options[:auto_initialize] = yes?('Auto-initialize ReactiveActions on page load? (recommended)', :green)
|
278
348
|
|
279
|
-
# DOM binding option
|
280
349
|
enable_dom = yes?('Enable automatic DOM binding? (recommended)', :green)
|
281
350
|
@javascript_options[:enable_dom_binding] = enable_dom
|
282
351
|
|
283
|
-
# Mutation observer option
|
284
352
|
if enable_dom
|
285
353
|
enable_observer = yes?('Enable mutation observer for dynamic content? (recommended)', :green)
|
286
354
|
@javascript_options[:enable_mutation_observer] = enable_observer
|
287
355
|
end
|
288
356
|
|
289
|
-
# Default HTTP method
|
290
357
|
http_methods = %w[POST GET PUT PATCH DELETE]
|
291
358
|
default_method = ask('Default HTTP method:', limited_to: http_methods, default: 'POST')
|
292
359
|
@javascript_options[:default_http_method] = default_method unless default_method == 'POST'
|
@@ -295,9 +362,7 @@ module ReactiveActions
|
|
295
362
|
def add_to_sprockets_manifest
|
296
363
|
return unless File.exist?('app/assets/config/manifest.js')
|
297
364
|
|
298
|
-
append_to_file 'app/assets/config/manifest.js'
|
299
|
-
"\n//= link reactive_actions.js\n"
|
300
|
-
end
|
365
|
+
append_to_file 'app/assets/config/manifest.js', "\n//= link reactive_actions.js\n"
|
301
366
|
end
|
302
367
|
|
303
368
|
def configure_delegated_methods
|
@@ -352,6 +417,11 @@ module ReactiveActions
|
|
352
417
|
template 'example_action.rb', "app/reactive_actions/#{sanitized_name}.rb"
|
353
418
|
say "✓ Created #{sanitized_name}.rb", :green unless options[:quiet]
|
354
419
|
end
|
420
|
+
|
421
|
+
# Template method to access rate limiting config in templates
|
422
|
+
def rate_limiting_config
|
423
|
+
@rate_limiting_config || {}
|
424
|
+
end
|
355
425
|
end
|
356
426
|
end
|
357
427
|
end
|
@@ -7,6 +7,51 @@ ReactiveActions.configure do |config|
|
|
7
7
|
|
8
8
|
# Configure instance variables to delegate from the controller to action classes
|
9
9
|
# config.delegated_instance_variables += [:custom_variable]
|
10
|
+
|
11
|
+
# Rate Limiting Configuration
|
12
|
+
# ============================
|
13
|
+
|
14
|
+
<% if rate_limiting_config[:rate_limiting_enabled] -%>
|
15
|
+
# Rate limiting is enabled
|
16
|
+
config.rate_limiting_enabled = true
|
17
|
+
|
18
|
+
<% if rate_limiting_config[:global_rate_limiting_enabled] -%>
|
19
|
+
# Global controller-level rate limiting is enabled
|
20
|
+
config.global_rate_limiting_enabled = true
|
21
|
+
config.global_rate_limit = <%= rate_limiting_config[:global_rate_limit] || 600 %>
|
22
|
+
config.global_rate_limit_window = <%= rate_limiting_config[:global_rate_limit_window] || '1.minute' %>
|
23
|
+
|
24
|
+
<% else -%>
|
25
|
+
# Global rate limiting is disabled
|
26
|
+
config.global_rate_limiting_enabled = false
|
27
|
+
|
28
|
+
<% end -%>
|
29
|
+
<% if rate_limiting_config[:custom_key_generator] -%>
|
30
|
+
# Custom rate limit key generator
|
31
|
+
# Uncomment and customize as needed:
|
32
|
+
# config.rate_limit_key_generator = ->(request, action_name) do
|
33
|
+
# # Example: user-based key with action scope
|
34
|
+
# user_id = request.headers['X-User-ID'] || 'anonymous'
|
35
|
+
# "#{action_name}:user:#{user_id}"
|
36
|
+
# end
|
37
|
+
|
38
|
+
<% end -%>
|
39
|
+
<% else -%>
|
40
|
+
# Rate limiting is disabled by default
|
41
|
+
# To enable rate limiting, uncomment and configure:
|
42
|
+
# config.rate_limiting_enabled = true
|
43
|
+
# config.global_rate_limiting_enabled = true
|
44
|
+
# config.global_rate_limit = 600
|
45
|
+
# config.global_rate_limit_window = 1.minute
|
46
|
+
|
47
|
+
# Custom rate limit key generator (optional)
|
48
|
+
# config.rate_limit_key_generator = ->(request, action_name) do
|
49
|
+
# # Example: user-based key with action scope
|
50
|
+
# user_id = request.headers['X-User-ID'] || 'anonymous'
|
51
|
+
# "#{action_name}:user:#{user_id}"
|
52
|
+
# end
|
53
|
+
|
54
|
+
<% end -%>
|
10
55
|
end
|
11
56
|
|
12
57
|
# Set the logger for ReactiveActions
|
@@ -24,4 +69,4 @@ ReactiveActions.logger = Rails.logger
|
|
24
69
|
# defaultHttpMethod: 'POST'
|
25
70
|
# }).reinitialize();
|
26
71
|
#
|
27
|
-
# Available globally as window.ReactiveActions
|
72
|
+
# Available globally as window.ReactiveActions
|
@@ -0,0 +1,174 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ReactiveActions
|
4
|
+
module Concerns
|
5
|
+
# Rate limiting module for ReactiveActions that can be included in actions or other classes
|
6
|
+
# Provides convenient methods for checking and managing rate limits
|
7
|
+
module RateLimiter
|
8
|
+
# Helper method for rate limiting in security checks or actions
|
9
|
+
# Automatically respects the global rate_limiting_enabled setting
|
10
|
+
# @param key [String, nil] Custom cache key, uses default if nil
|
11
|
+
# @param limit [Integer] Maximum number of requests allowed
|
12
|
+
# @param window [ActiveSupport::Duration] Time window for the limit
|
13
|
+
# @param cost [Integer] Cost of this request (default: 1)
|
14
|
+
# @raise [RateLimitExceededError] When rate limit is exceeded
|
15
|
+
def rate_limit!(key: nil, limit: 100, window: 1.hour, cost: 1)
|
16
|
+
# Early return if rate limiting is disabled
|
17
|
+
return unless ReactiveActions.configuration.rate_limiting_enabled
|
18
|
+
|
19
|
+
effective_key = key || default_rate_limit_key
|
20
|
+
|
21
|
+
ReactiveActions::RateLimiter.check!(
|
22
|
+
key: effective_key,
|
23
|
+
limit: limit,
|
24
|
+
window: window,
|
25
|
+
cost: cost
|
26
|
+
)
|
27
|
+
end
|
28
|
+
|
29
|
+
# Check rate limit without raising an error (also respects configuration)
|
30
|
+
# @param key [String, nil] Custom cache key, uses default if nil
|
31
|
+
# @param limit [Integer] Maximum number of requests allowed
|
32
|
+
# @param window [ActiveSupport::Duration] Time window for the limit
|
33
|
+
# @return [Hash] Rate limit status information
|
34
|
+
def rate_limit_status(key: nil, limit: 100, window: 1.hour)
|
35
|
+
# Return "unlimited" status if rate limiting is disabled
|
36
|
+
unless ReactiveActions.configuration.rate_limiting_enabled
|
37
|
+
return {
|
38
|
+
limit: Float::INFINITY,
|
39
|
+
remaining: Float::INFINITY,
|
40
|
+
current: 0,
|
41
|
+
window: window,
|
42
|
+
exceeded: false,
|
43
|
+
enabled: false
|
44
|
+
}
|
45
|
+
end
|
46
|
+
|
47
|
+
effective_key = key || default_rate_limit_key
|
48
|
+
|
49
|
+
ReactiveActions::RateLimiter.check(
|
50
|
+
key: effective_key,
|
51
|
+
limit: limit,
|
52
|
+
window: window
|
53
|
+
).merge(enabled: true)
|
54
|
+
end
|
55
|
+
|
56
|
+
# Reset rate limit for a specific key
|
57
|
+
# @param key [String, nil] Custom cache key, uses default if nil
|
58
|
+
# @param window [ActiveSupport::Duration] Time window for the limit
|
59
|
+
def reset_rate_limit!(key: nil, window: 1.hour)
|
60
|
+
return unless ReactiveActions.configuration.rate_limiting_enabled
|
61
|
+
|
62
|
+
effective_key = key || default_rate_limit_key
|
63
|
+
|
64
|
+
ReactiveActions::RateLimiter.reset!(
|
65
|
+
key: effective_key,
|
66
|
+
window: window
|
67
|
+
)
|
68
|
+
end
|
69
|
+
|
70
|
+
# Check if rate limiting is currently enabled
|
71
|
+
# @return [Boolean] True if rate limiting is enabled
|
72
|
+
def rate_limiting_enabled?
|
73
|
+
ReactiveActions.configuration.rate_limiting_enabled
|
74
|
+
end
|
75
|
+
|
76
|
+
# Get remaining requests for a specific key without consuming any
|
77
|
+
# @param key [String, nil] Custom cache key, uses default if nil
|
78
|
+
# @param limit [Integer] Maximum number of requests allowed
|
79
|
+
# @param window [ActiveSupport::Duration] Time window for the limit
|
80
|
+
# @return [Integer, Float] Number of remaining requests, or Float::INFINITY if disabled
|
81
|
+
def rate_limit_remaining(key: nil, limit: 100, window: 1.hour)
|
82
|
+
status = rate_limit_status(key: key, limit: limit, window: window)
|
83
|
+
status[:remaining]
|
84
|
+
end
|
85
|
+
|
86
|
+
# Check if a key would exceed rate limit for a given cost
|
87
|
+
# @param key [String, nil] Custom cache key, uses default if nil
|
88
|
+
# @param limit [Integer] Maximum number of requests allowed
|
89
|
+
# @param window [ActiveSupport::Duration] Time window for the limit
|
90
|
+
# @param cost [Integer] Cost to check (default: 1)
|
91
|
+
# @return [Boolean] True if the cost would exceed the limit
|
92
|
+
def rate_limit_would_exceed?(key: nil, limit: 100, window: 1.hour, cost: 1)
|
93
|
+
return false unless ReactiveActions.configuration.rate_limiting_enabled
|
94
|
+
|
95
|
+
status = rate_limit_status(key: key, limit: limit, window: window)
|
96
|
+
status[:remaining] < cost
|
97
|
+
end
|
98
|
+
|
99
|
+
# Create a rate limit key for a specific scope
|
100
|
+
# @param scope [String, Symbol] The scope (e.g., 'api', 'search', 'upload')
|
101
|
+
# @param identifier [String, nil] Custom identifier, uses default if nil
|
102
|
+
# @return [String] Formatted rate limit key
|
103
|
+
def rate_limit_key_for(scope, identifier: nil)
|
104
|
+
base_key = identifier || extract_identifier_from_context
|
105
|
+
"#{scope}:#{base_key}"
|
106
|
+
end
|
107
|
+
|
108
|
+
# Log rate limiting events for debugging/monitoring
|
109
|
+
# @param event [String] Event type (e.g., 'exceeded', 'checked', 'reset')
|
110
|
+
# @param details [Hash] Additional details to log
|
111
|
+
def log_rate_limit_event(event, details = {})
|
112
|
+
return unless ReactiveActions.logger
|
113
|
+
|
114
|
+
ReactiveActions.logger.info(
|
115
|
+
"Rate Limit #{event.capitalize}: #{details.merge(key: default_rate_limit_key)}"
|
116
|
+
)
|
117
|
+
end
|
118
|
+
|
119
|
+
private
|
120
|
+
|
121
|
+
# Default key generation for rate limiting
|
122
|
+
# This method tries different approaches to generate a meaningful key
|
123
|
+
# @return [String] A rate limit key
|
124
|
+
def default_rate_limit_key
|
125
|
+
# Try different methods to get a meaningful identifier
|
126
|
+
if respond_to?(:current_user) && current_user
|
127
|
+
"user:#{current_user.id}"
|
128
|
+
elsif respond_to?(:controller) && controller.respond_to?(:request)
|
129
|
+
extract_key_from_request(controller.request)
|
130
|
+
elsif respond_to?(:request) && request
|
131
|
+
extract_key_from_request(request)
|
132
|
+
else
|
133
|
+
"anonymous:#{SecureRandom.hex(8)}"
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
# Extract identifier from current context (user, request, etc.)
|
138
|
+
# @return [String] An identifier for the current context
|
139
|
+
def extract_identifier_from_context
|
140
|
+
if respond_to?(:current_user) && current_user
|
141
|
+
current_user.id.to_s
|
142
|
+
elsif respond_to?(:controller) && controller.respond_to?(:request)
|
143
|
+
controller.request.remote_ip
|
144
|
+
elsif respond_to?(:request) && request
|
145
|
+
request.remote_ip
|
146
|
+
else
|
147
|
+
'unknown'
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
# Extract a rate limiting key from a request object
|
152
|
+
# @param request [ActionDispatch::Request] The request object
|
153
|
+
# @return [String] A rate limit key based on the request
|
154
|
+
def extract_key_from_request(request)
|
155
|
+
# Try to get user info from headers first
|
156
|
+
if request.headers['X-User-ID'].present?
|
157
|
+
"user:#{request.headers['X-User-ID']}"
|
158
|
+
elsif request.headers['Authorization'].present?
|
159
|
+
# Extract from API token/key
|
160
|
+
auth_header = request.headers['Authorization']
|
161
|
+
if auth_header.start_with?('Bearer ')
|
162
|
+
token = auth_header.gsub('Bearer ', '')
|
163
|
+
"token:#{Digest::SHA256.hexdigest(token)[0..8]}" # Hash for privacy
|
164
|
+
else
|
165
|
+
"auth:#{request.remote_ip}"
|
166
|
+
end
|
167
|
+
else
|
168
|
+
# Fallback to IP address
|
169
|
+
"ip:#{request.remote_ip}"
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
@@ -0,0 +1,108 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ReactiveActions
|
4
|
+
module Concerns
|
5
|
+
# Security checks module that provides simple security filtering with function names or lambdas
|
6
|
+
module SecurityChecks
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
included do
|
10
|
+
class_attribute :security_filters, default: []
|
11
|
+
end
|
12
|
+
|
13
|
+
class_methods do
|
14
|
+
# Add a security check that runs before the action
|
15
|
+
#
|
16
|
+
# @param check [Symbol, Proc] The method name or lambda to execute
|
17
|
+
# @param options [Hash] Conditions for when to run the check
|
18
|
+
# @option options [Symbol, Array<Symbol>] :only Run only for these actions
|
19
|
+
# @option options [Symbol, Array<Symbol>] :except Skip for these actions
|
20
|
+
# @option options [Symbol, Proc] :if Run only if condition is true
|
21
|
+
# @option options [Symbol, Proc] :unless Skip if condition is true
|
22
|
+
def security_check(check, **options)
|
23
|
+
self.security_filters = security_filters + [{ check: check, **options }]
|
24
|
+
end
|
25
|
+
|
26
|
+
# Skip all security checks for this action
|
27
|
+
def skip_security_checks
|
28
|
+
self.security_filters = []
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
# Run all configured security checks
|
35
|
+
def run_security_checks
|
36
|
+
self.class.security_filters.each do |filter_config|
|
37
|
+
next unless should_run_security_check?(filter_config)
|
38
|
+
|
39
|
+
execute_security_check(filter_config[:check])
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Determine if a security check should run based on conditions
|
44
|
+
def should_run_security_check?(filter_config)
|
45
|
+
return false unless check_only_condition(filter_config[:only])
|
46
|
+
return false if check_except_condition(filter_config[:except])
|
47
|
+
return false unless check_if_condition(filter_config[:if])
|
48
|
+
return false if check_unless_condition(filter_config[:unless])
|
49
|
+
|
50
|
+
true
|
51
|
+
end
|
52
|
+
|
53
|
+
def check_only_condition(only_condition)
|
54
|
+
return true unless only_condition
|
55
|
+
|
56
|
+
only_actions = Array(only_condition).map(&:to_s)
|
57
|
+
only_actions.include?('action')
|
58
|
+
end
|
59
|
+
|
60
|
+
def check_except_condition(except_condition)
|
61
|
+
return false unless except_condition
|
62
|
+
|
63
|
+
except_actions = Array(except_condition).map(&:to_s)
|
64
|
+
except_actions.include?('action')
|
65
|
+
end
|
66
|
+
|
67
|
+
def check_if_condition(if_condition)
|
68
|
+
return true unless if_condition
|
69
|
+
|
70
|
+
evaluate_condition(if_condition)
|
71
|
+
end
|
72
|
+
|
73
|
+
def check_unless_condition(unless_condition)
|
74
|
+
return false unless unless_condition
|
75
|
+
|
76
|
+
evaluate_condition(unless_condition)
|
77
|
+
end
|
78
|
+
|
79
|
+
def evaluate_condition(condition)
|
80
|
+
if condition.is_a?(Proc)
|
81
|
+
instance_exec(&condition)
|
82
|
+
elsif condition.is_a?(Symbol)
|
83
|
+
send(condition)
|
84
|
+
else
|
85
|
+
false
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# Execute a single security check
|
90
|
+
def execute_security_check(check)
|
91
|
+
if check.is_a?(Proc)
|
92
|
+
instance_exec(&check)
|
93
|
+
elsif check.is_a?(Symbol)
|
94
|
+
send(check)
|
95
|
+
else
|
96
|
+
raise ReactiveActions::SecurityCheckError, "Invalid security check: #{check.inspect}"
|
97
|
+
end
|
98
|
+
rescue ReactiveActions::Error
|
99
|
+
# Re-raise ReactiveActions errors as-is
|
100
|
+
raise
|
101
|
+
rescue StandardError => e
|
102
|
+
# Convert other errors to security errors
|
103
|
+
ReactiveActions.logger.error "Security check failed in #{self.class.name}: #{e.message}"
|
104
|
+
raise ReactiveActions::SecurityCheckError, "Security check failed: #{e.message}"
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
@@ -1,11 +1,14 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
# ReactiveActions
|
3
|
+
# ReactiveActions provides functionality for creating and executing reactive actions in Rails applications
|
4
4
|
module ReactiveActions
|
5
5
|
# Configuration class for ReactiveActions
|
6
|
-
# Manages settings for controller method delegation
|
6
|
+
# Manages settings for controller method delegation, instance variable delegation, and rate limiting
|
7
7
|
class Configuration
|
8
|
-
attr_accessor :delegated_controller_methods, :delegated_instance_variables
|
8
|
+
attr_accessor :delegated_controller_methods, :delegated_instance_variables, :global_rate_limit, :global_rate_limit_window, :rate_limit_key_generator, :rate_limit_cost_calculator
|
9
|
+
|
10
|
+
# Rate limiting configuration
|
11
|
+
attr_accessor :rate_limiting_enabled, :global_rate_limiting_enabled
|
9
12
|
|
10
13
|
def initialize
|
11
14
|
# Default methods to delegate
|
@@ -16,6 +19,23 @@ module ReactiveActions
|
|
16
19
|
|
17
20
|
# Default instance variables to delegate
|
18
21
|
@delegated_instance_variables = []
|
22
|
+
|
23
|
+
# Rate limiting defaults - DISABLED by default
|
24
|
+
@rate_limiting_enabled = false # Master switch for all rate limiting
|
25
|
+
@global_rate_limiting_enabled = false # Global controller-level rate limiting
|
26
|
+
@global_rate_limit = 600 # 600 requests per minute (10/second)
|
27
|
+
@global_rate_limit_window = 1.minute # per minute for responsive rate limiting
|
28
|
+
@rate_limit_key_generator = nil # Use default logic if nil
|
29
|
+
@rate_limit_cost_calculator = nil # Use default cost of 1 if nil
|
30
|
+
end
|
31
|
+
|
32
|
+
# Helper methods for checking rate limiting status
|
33
|
+
def rate_limiting_available?
|
34
|
+
@rate_limiting_enabled
|
35
|
+
end
|
36
|
+
|
37
|
+
def global_rate_limiting_active?
|
38
|
+
@rate_limiting_enabled && @global_rate_limiting_enabled
|
19
39
|
end
|
20
40
|
end
|
21
41
|
|