otto 1.5.0 → 2.0.0.pre1
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/.github/workflows/ci.yml +44 -5
- data/.github/workflows/claude-code-review.yml +53 -0
- data/.github/workflows/claude.yml +49 -0
- data/.gitignore +3 -0
- data/.rubocop.yml +24 -345
- data/CHANGELOG.rst +83 -0
- data/CLAUDE.md +56 -0
- data/Gemfile +21 -5
- data/Gemfile.lock +69 -31
- data/README.md +2 -0
- data/bin/rspec +16 -0
- data/changelog.d/20250911_235619_delano_next.rst +28 -0
- data/changelog.d/20250912_123055_delano_remove_ostruct.rst +21 -0
- data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +21 -0
- data/changelog.d/README.md +120 -0
- data/changelog.d/scriv.ini +5 -0
- data/docs/.gitignore +1 -0
- data/docs/migrating/v2.0.0-pre1.md +276 -0
- data/examples/.gitignore +1 -0
- data/examples/advanced_routes/README.md +33 -0
- data/examples/advanced_routes/app/controllers/handlers/async.rb +9 -0
- data/examples/advanced_routes/app/controllers/handlers/dynamic.rb +9 -0
- data/examples/advanced_routes/app/controllers/handlers/static.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/auth.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/transformer.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/validator.rb +9 -0
- data/examples/advanced_routes/app/controllers/routes_app.rb +232 -0
- data/examples/advanced_routes/app/controllers/v2/admin.rb +9 -0
- data/examples/advanced_routes/app/controllers/v2/config.rb +9 -0
- data/examples/advanced_routes/app/controllers/v2/settings.rb +9 -0
- data/examples/advanced_routes/app/logic/admin/logic/manager.rb +27 -0
- data/examples/advanced_routes/app/logic/admin/panel.rb +27 -0
- data/examples/advanced_routes/app/logic/analytics_processor.rb +25 -0
- data/examples/advanced_routes/app/logic/complex/business/handler.rb +27 -0
- data/examples/advanced_routes/app/logic/data_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/data_processor.rb +25 -0
- data/examples/advanced_routes/app/logic/input_validator.rb +24 -0
- data/examples/advanced_routes/app/logic/nested/feature/logic.rb +27 -0
- data/examples/advanced_routes/app/logic/reports_generator.rb +27 -0
- data/examples/advanced_routes/app/logic/simple_logic.rb +25 -0
- data/examples/advanced_routes/app/logic/system/config/manager.rb +27 -0
- data/examples/advanced_routes/app/logic/test_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/transform_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/upload_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/v2/logic/dashboard.rb +27 -0
- data/examples/advanced_routes/app/logic/v2/logic/processor.rb +27 -0
- data/examples/advanced_routes/app.rb +33 -0
- data/examples/advanced_routes/config.rb +23 -0
- data/examples/advanced_routes/config.ru +7 -0
- data/examples/advanced_routes/puma.rb +20 -0
- data/examples/advanced_routes/routes +167 -0
- data/examples/advanced_routes/run.rb +39 -0
- data/examples/advanced_routes/test.rb +58 -0
- data/examples/authentication_strategies/README.md +32 -0
- data/examples/authentication_strategies/app/auth.rb +68 -0
- data/examples/authentication_strategies/app/controllers/auth_controller.rb +29 -0
- data/examples/authentication_strategies/app/controllers/main_controller.rb +28 -0
- data/examples/authentication_strategies/config.ru +24 -0
- data/examples/authentication_strategies/routes +37 -0
- data/examples/basic/README.md +29 -0
- data/examples/basic/app.rb +7 -35
- data/examples/basic/routes +0 -9
- data/examples/mcp_demo/README.md +87 -0
- data/examples/mcp_demo/app.rb +51 -0
- data/examples/mcp_demo/config.ru +17 -0
- data/examples/mcp_demo/routes +9 -0
- data/examples/security_features/README.md +46 -0
- data/examples/security_features/app.rb +23 -24
- data/examples/security_features/config.ru +8 -10
- data/lib/otto/core/configuration.rb +167 -0
- data/lib/otto/core/error_handler.rb +86 -0
- data/lib/otto/core/file_safety.rb +61 -0
- data/lib/otto/core/middleware_stack.rb +157 -0
- data/lib/otto/core/router.rb +183 -0
- data/lib/otto/core/uri_generator.rb +44 -0
- data/lib/otto/design_system.rb +7 -5
- data/lib/otto/helpers/base.rb +3 -0
- data/lib/otto/helpers/request.rb +10 -8
- data/lib/otto/helpers/response.rb +5 -4
- data/lib/otto/helpers/validation.rb +85 -0
- data/lib/otto/mcp/auth/token.rb +77 -0
- data/lib/otto/mcp/protocol.rb +164 -0
- data/lib/otto/mcp/rate_limiting.rb +155 -0
- data/lib/otto/mcp/registry.rb +100 -0
- data/lib/otto/mcp/route_parser.rb +77 -0
- data/lib/otto/mcp/server.rb +206 -0
- data/lib/otto/mcp/validation.rb +123 -0
- data/lib/otto/response_handlers/auto.rb +39 -0
- data/lib/otto/response_handlers/base.rb +16 -0
- data/lib/otto/response_handlers/default.rb +16 -0
- data/lib/otto/response_handlers/factory.rb +39 -0
- data/lib/otto/response_handlers/json.rb +28 -0
- data/lib/otto/response_handlers/redirect.rb +25 -0
- data/lib/otto/response_handlers/view.rb +24 -0
- data/lib/otto/response_handlers.rb +9 -135
- data/lib/otto/route.rb +9 -9
- data/lib/otto/route_definition.rb +30 -33
- data/lib/otto/route_handlers/base.rb +121 -0
- data/lib/otto/route_handlers/class_method.rb +89 -0
- data/lib/otto/route_handlers/factory.rb +29 -0
- data/lib/otto/route_handlers/instance_method.rb +69 -0
- data/lib/otto/route_handlers/lambda.rb +59 -0
- data/lib/otto/route_handlers/logic_class.rb +93 -0
- data/lib/otto/route_handlers.rb +10 -376
- data/lib/otto/security/authentication/auth_strategy.rb +44 -0
- data/lib/otto/security/authentication/authentication_middleware.rb +123 -0
- data/lib/otto/security/authentication/failure_result.rb +36 -0
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
- data/lib/otto/security/authentication/strategies/public_strategy.rb +19 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +57 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +41 -0
- data/lib/otto/security/authentication/strategy_result.rb +223 -0
- data/lib/otto/security/authentication.rb +28 -282
- data/lib/otto/security/config.rb +15 -11
- data/lib/otto/security/configurator.rb +219 -0
- data/lib/otto/security/csrf.rb +8 -143
- data/lib/otto/security/middleware/csrf_middleware.rb +151 -0
- data/lib/otto/security/middleware/rate_limit_middleware.rb +38 -0
- data/lib/otto/security/middleware/validation_middleware.rb +252 -0
- data/lib/otto/security/rate_limiter.rb +86 -0
- data/lib/otto/security/rate_limiting.rb +16 -0
- data/lib/otto/security/validator.rb +8 -292
- data/lib/otto/static.rb +3 -0
- data/lib/otto/utils.rb +14 -0
- data/lib/otto/version.rb +3 -1
- data/lib/otto.rb +184 -414
- data/otto.gemspec +11 -6
- metadata +134 -25
- data/examples/dynamic_pages/app.rb +0 -115
- data/examples/dynamic_pages/config.ru +0 -30
- data/examples/dynamic_pages/routes +0 -21
- data/examples/helpers_demo/app.rb +0 -244
- data/examples/helpers_demo/config.ru +0 -26
- data/examples/helpers_demo/routes +0 -7
data/lib/otto.rb
CHANGED
@@ -1,6 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/otto.rb
|
4
|
+
|
1
5
|
require 'json'
|
2
6
|
require 'logger'
|
3
|
-
require 'ostruct'
|
4
7
|
require 'securerandom'
|
5
8
|
require 'uri'
|
6
9
|
|
@@ -8,6 +11,7 @@ require 'rack/request'
|
|
8
11
|
require 'rack/response'
|
9
12
|
require 'rack/utils'
|
10
13
|
|
14
|
+
require_relative 'otto/security/authentication/strategy_result'
|
11
15
|
require_relative 'otto/route_definition'
|
12
16
|
require_relative 'otto/route'
|
13
17
|
require_relative 'otto/static'
|
@@ -17,9 +21,19 @@ require_relative 'otto/response_handlers'
|
|
17
21
|
require_relative 'otto/route_handlers'
|
18
22
|
require_relative 'otto/version'
|
19
23
|
require_relative 'otto/security/config'
|
20
|
-
require_relative 'otto/security/
|
21
|
-
require_relative 'otto/security/
|
22
|
-
require_relative 'otto/security/authentication'
|
24
|
+
require_relative 'otto/security/middleware/csrf_middleware'
|
25
|
+
require_relative 'otto/security/middleware/validation_middleware'
|
26
|
+
require_relative 'otto/security/authentication/authentication_middleware'
|
27
|
+
require_relative 'otto/security/middleware/rate_limit_middleware'
|
28
|
+
require_relative 'otto/mcp/server'
|
29
|
+
require_relative 'otto/core/router'
|
30
|
+
require_relative 'otto/core/file_safety'
|
31
|
+
require_relative 'otto/core/configuration'
|
32
|
+
require_relative 'otto/core/error_handler'
|
33
|
+
require_relative 'otto/core/uri_generator'
|
34
|
+
require_relative 'otto/core/middleware_stack'
|
35
|
+
require_relative 'otto/security/configurator'
|
36
|
+
require_relative 'otto/utils'
|
23
37
|
|
24
38
|
# Otto is a simple Rack router that allows you to define routes in a file
|
25
39
|
# with built-in security features including CSRF protection, input validation,
|
@@ -42,48 +56,78 @@ require_relative 'otto/security/authentication'
|
|
42
56
|
# otto.enable_csp!
|
43
57
|
# otto.enable_frame_protection!
|
44
58
|
#
|
59
|
+
# Configuration Data class to replace OpenStruct
|
60
|
+
# Configuration Data class to replace OpenStruct
|
61
|
+
# Configuration class to replace OpenStruct
|
62
|
+
class ConfigData
|
63
|
+
def initialize(**kwargs)
|
64
|
+
@data = kwargs
|
65
|
+
end
|
66
|
+
|
67
|
+
# Dynamic attribute accessors
|
68
|
+
def method_missing(method_name, *args)
|
69
|
+
if method_name.to_s.end_with?('=')
|
70
|
+
# Setter
|
71
|
+
attr_name = method_name.to_s.chomp('=').to_sym
|
72
|
+
@data[attr_name] = args.first
|
73
|
+
elsif @data.key?(method_name)
|
74
|
+
# Getter
|
75
|
+
@data[method_name]
|
76
|
+
else
|
77
|
+
super
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def respond_to_missing?(method_name, include_private = false)
|
82
|
+
method_name.to_s.end_with?('=') || @data.key?(method_name) || super
|
83
|
+
end
|
84
|
+
|
85
|
+
# Convert to hash for compatibility
|
86
|
+
def to_h
|
87
|
+
@data.dup
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
45
91
|
class Otto
|
92
|
+
include Otto::Core::Router
|
93
|
+
include Otto::Core::FileSafety
|
94
|
+
include Otto::Core::Configuration
|
95
|
+
include Otto::Core::ErrorHandler
|
96
|
+
include Otto::Core::UriGenerator
|
97
|
+
|
46
98
|
LIB_HOME = __dir__ unless defined?(Otto::LIB_HOME)
|
47
99
|
|
48
|
-
@debug
|
49
|
-
|
50
|
-
|
100
|
+
@debug = case ENV.fetch('OTTO_DEBUG', nil)
|
101
|
+
in 'true' | '1' | 'yes' | 'on'
|
102
|
+
true
|
103
|
+
else
|
104
|
+
defined?(Otto::Utils) ? Otto::Utils.yes?(ENV.fetch('OTTO_DEBUG', nil)) : false
|
105
|
+
end
|
106
|
+
@logger = Logger.new($stdout, Logger::INFO)
|
107
|
+
@global_config = nil
|
51
108
|
|
52
|
-
# Global configuration for all Otto instances
|
109
|
+
# Global configuration for all Otto instances (Ruby 3.2+ pattern matching)
|
53
110
|
def self.configure
|
54
|
-
config =
|
111
|
+
config = case @global_config
|
112
|
+
in Hash => h
|
113
|
+
# Transform string keys to symbol keys for ConfigData compatibility
|
114
|
+
symbol_hash = h.transform_keys(&:to_sym)
|
115
|
+
ConfigData.new(**symbol_hash)
|
116
|
+
else
|
117
|
+
ConfigData.new
|
118
|
+
end
|
55
119
|
yield config
|
56
120
|
@global_config = config.to_h
|
57
121
|
end
|
58
122
|
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option, :static_route, :security_config, :locale_config, :auth_config, :route_handler_factory
|
64
|
-
attr_accessor :not_found, :server_error, :middleware_stack
|
123
|
+
attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option, :static_route,
|
124
|
+
:security_config, :locale_config, :auth_config, :route_handler_factory, :mcp_server, :security, :middleware
|
125
|
+
attr_accessor :not_found, :server_error
|
65
126
|
|
66
127
|
def initialize(path = nil, opts = {})
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
@route_definitions = {}
|
71
|
-
@option = {
|
72
|
-
public: nil,
|
73
|
-
locale: 'en',
|
74
|
-
}.merge(opts)
|
75
|
-
@security_config = Otto::Security::Config.new
|
76
|
-
@middleware_stack = []
|
77
|
-
@route_handler_factory = opts[:route_handler_factory] || Otto::RouteHandlers::HandlerFactory
|
78
|
-
|
79
|
-
# Configure locale support (merge global config with instance options)
|
80
|
-
configure_locale(opts)
|
81
|
-
|
82
|
-
# Configure security based on options
|
83
|
-
configure_security(opts)
|
84
|
-
|
85
|
-
# Configure authentication based on options
|
86
|
-
configure_authentication(opts)
|
128
|
+
initialize_core_state
|
129
|
+
initialize_options(path, opts)
|
130
|
+
initialize_configurations(opts)
|
87
131
|
|
88
132
|
Otto.logger.debug "new Otto: #{opts}" if Otto.debug
|
89
133
|
load(path) unless path.nil?
|
@@ -91,233 +135,44 @@ class Otto
|
|
91
135
|
end
|
92
136
|
alias options option
|
93
137
|
|
94
|
-
|
95
|
-
path = File.expand_path(path)
|
96
|
-
raise ArgumentError, "Bad path: #{path}" unless File.exist?(path)
|
97
|
-
|
98
|
-
raw = File.readlines(path).select { |line| line =~ /^\w/ }.collect { |line| line.strip }
|
99
|
-
raw.each do |entry|
|
100
|
-
# Enhanced parsing: split only on first two whitespace boundaries
|
101
|
-
# This preserves parameters in the definition part
|
102
|
-
parts = entry.split(/\s+/, 3)
|
103
|
-
verb, path, definition = parts[0], parts[1], parts[2]
|
104
|
-
route = Otto::Route.new verb, path, definition
|
105
|
-
route.otto = self
|
106
|
-
path_clean = path.gsub(%r{/$}, '')
|
107
|
-
@route_definitions[route.definition] = route
|
108
|
-
Otto.logger.debug "route: #{route.pattern}" if Otto.debug
|
109
|
-
@routes[route.verb] ||= []
|
110
|
-
@routes[route.verb] << route
|
111
|
-
@routes_literal[route.verb] ||= {}
|
112
|
-
@routes_literal[route.verb][path_clean] = route
|
113
|
-
rescue StandardError
|
114
|
-
Otto.logger.error "Bad route in #{path}: #{entry}"
|
115
|
-
end
|
116
|
-
self
|
117
|
-
end
|
118
|
-
|
119
|
-
def safe_file?(path)
|
120
|
-
return false if option[:public].nil? || option[:public].empty?
|
121
|
-
return false if path.nil? || path.empty?
|
122
|
-
|
123
|
-
# Normalize and resolve the public directory path
|
124
|
-
public_dir = File.expand_path(option[:public])
|
125
|
-
return false unless File.directory?(public_dir)
|
126
|
-
|
127
|
-
# Clean the requested path - remove null bytes and normalize
|
128
|
-
clean_path = path.delete("\0").strip
|
129
|
-
return false if clean_path.empty?
|
130
|
-
|
131
|
-
# Join and expand to get the full resolved path
|
132
|
-
requested_path = File.expand_path(File.join(public_dir, clean_path))
|
133
|
-
|
134
|
-
# Ensure the resolved path is within the public directory (prevents path traversal)
|
135
|
-
return false unless requested_path.start_with?(public_dir + File::SEPARATOR)
|
136
|
-
|
137
|
-
# Check file exists, is readable, and is not a directory
|
138
|
-
File.exist?(requested_path) &&
|
139
|
-
File.readable?(requested_path) &&
|
140
|
-
!File.directory?(requested_path) &&
|
141
|
-
(File.owned?(requested_path) || File.grpowned?(requested_path))
|
142
|
-
end
|
143
|
-
|
144
|
-
def safe_dir?(path)
|
145
|
-
return false if path.nil? || path.empty?
|
146
|
-
|
147
|
-
# Clean and expand the path
|
148
|
-
clean_path = path.delete("\0").strip
|
149
|
-
return false if clean_path.empty?
|
150
|
-
|
151
|
-
expanded_path = File.expand_path(clean_path)
|
152
|
-
|
153
|
-
# Check directory exists, is readable, and has proper ownership
|
154
|
-
File.directory?(expanded_path) &&
|
155
|
-
File.readable?(expanded_path) &&
|
156
|
-
(File.owned?(expanded_path) || File.grpowned?(expanded_path))
|
157
|
-
end
|
158
|
-
|
159
|
-
def add_static_path(path)
|
160
|
-
return unless safe_file?(path)
|
161
|
-
|
162
|
-
base_path = File.split(path).first
|
163
|
-
# Files in the root directory can refer to themselves
|
164
|
-
base_path = path if base_path == '/'
|
165
|
-
File.join(option[:public], base_path)
|
166
|
-
Otto.logger.debug "new static route: #{base_path} (#{path})" if Otto.debug
|
167
|
-
routes_static[:GET][base_path] = base_path
|
168
|
-
end
|
169
|
-
|
138
|
+
# Main Rack application interface
|
170
139
|
def call(env)
|
171
140
|
# Apply middleware stack
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
141
|
+
base_app = ->(e) { handle_request(e) }
|
142
|
+
|
143
|
+
# Use the middleware stack as the source of truth
|
144
|
+
app = @middleware.build_app(base_app, @security_config)
|
176
145
|
|
177
146
|
begin
|
178
147
|
app.call(env)
|
179
|
-
rescue StandardError =>
|
180
|
-
handle_error(
|
148
|
+
rescue StandardError => e
|
149
|
+
handle_error(e, env)
|
181
150
|
end
|
182
151
|
end
|
183
152
|
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
env['otto.locale_config'] = @locale_config if @locale_config
|
188
|
-
@static_route ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
|
189
|
-
path_info = Rack::Utils.unescape(env['PATH_INFO'])
|
190
|
-
path_info = '/' if path_info.to_s.empty?
|
191
|
-
|
192
|
-
begin
|
193
|
-
path_info_clean = path_info
|
194
|
-
.encode(
|
195
|
-
'UTF-8', # Target encoding
|
196
|
-
invalid: :replace, # Replace invalid byte sequences
|
197
|
-
undef: :replace, # Replace characters undefined in UTF-8
|
198
|
-
replace: '', # Use empty string for replacement
|
199
|
-
)
|
200
|
-
.gsub(%r{/$}, '') # Remove trailing slash, if present
|
201
|
-
rescue ArgumentError => ex
|
202
|
-
# Log the error but don't expose details
|
203
|
-
Otto.logger.error '[Otto.handle_request] Path encoding error'
|
204
|
-
Otto.logger.debug "[Otto.handle_request] Error details: #{ex.message}" if Otto.debug
|
205
|
-
# Set a default value or use the original path_info
|
206
|
-
path_info_clean = path_info
|
207
|
-
end
|
208
|
-
|
209
|
-
base_path = File.split(path_info).first
|
210
|
-
# Files in the root directory can refer to themselves
|
211
|
-
base_path = path_info if base_path == '/'
|
212
|
-
http_verb = env['REQUEST_METHOD'].upcase.to_sym
|
213
|
-
literal_routes = routes_literal[http_verb] || {}
|
214
|
-
literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
|
215
|
-
if static_route && http_verb == :GET && routes_static[:GET].member?(base_path)
|
216
|
-
# Otto.logger.debug " request: #{path_info} (static)"
|
217
|
-
static_route.call(env)
|
218
|
-
elsif literal_routes.has_key?(path_info_clean)
|
219
|
-
route = literal_routes[path_info_clean]
|
220
|
-
# Otto.logger.debug " request: #{http_verb} #{path_info} (literal route: #{route.verb} #{route.path})"
|
221
|
-
route.call(env)
|
222
|
-
elsif static_route && http_verb == :GET && safe_file?(path_info)
|
223
|
-
Otto.logger.debug " new static route: #{base_path} (#{path_info})" if Otto.debug
|
224
|
-
routes_static[:GET][base_path] = base_path
|
225
|
-
static_route.call(env)
|
226
|
-
else
|
227
|
-
extra_params = {}
|
228
|
-
found_route = nil
|
229
|
-
valid_routes = routes[http_verb] || []
|
230
|
-
valid_routes.push(*routes[:GET]) if http_verb == :HEAD
|
231
|
-
valid_routes.each do |route|
|
232
|
-
# Otto.logger.debug " request: #{http_verb} #{path_info} (trying route: #{route.verb} #{route.pattern})"
|
233
|
-
next unless (match = route.pattern.match(path_info))
|
234
|
-
|
235
|
-
values = match.captures.to_a
|
236
|
-
# The first capture returned is the entire matched string b/c
|
237
|
-
# we wrapped the entire regex in parens. We don't need it to
|
238
|
-
# the full match.
|
239
|
-
values.shift
|
240
|
-
extra_params =
|
241
|
-
if route.keys.any?
|
242
|
-
route.keys.zip(values).each_with_object({}) do |(k, v), hash|
|
243
|
-
if k == 'splat'
|
244
|
-
(hash[k] ||= []) << v
|
245
|
-
else
|
246
|
-
hash[k] = v
|
247
|
-
end
|
248
|
-
end
|
249
|
-
elsif values.any?
|
250
|
-
{ 'captures' => values }
|
251
|
-
else
|
252
|
-
{}
|
253
|
-
end
|
254
|
-
found_route = route
|
255
|
-
break
|
256
|
-
end
|
257
|
-
found_route ||= literal_routes['/404']
|
258
|
-
if found_route
|
259
|
-
found_route.call env, extra_params
|
260
|
-
else
|
261
|
-
@not_found || Otto::Static.not_found
|
262
|
-
end
|
263
|
-
end
|
153
|
+
# Middleware Management
|
154
|
+
def use(middleware, ...)
|
155
|
+
@middleware.add(middleware, ...)
|
264
156
|
end
|
265
157
|
|
266
|
-
#
|
267
|
-
|
268
|
-
|
269
|
-
# Otto.default.path 'YourClass.somemethod' #=> /some/path
|
270
|
-
#
|
271
|
-
def uri(route_definition, params = {})
|
272
|
-
# raise RuntimeError, "Not working"
|
273
|
-
route = @route_definitions[route_definition]
|
274
|
-
return if route.nil?
|
275
|
-
|
276
|
-
local_params = params.clone
|
277
|
-
local_path = route.path.clone
|
278
|
-
|
279
|
-
local_params.each_pair do |k, v|
|
280
|
-
next unless local_path.match(":#{k}")
|
281
|
-
|
282
|
-
local_path.gsub!(":#{k}", v.to_s)
|
283
|
-
local_params.delete(k)
|
284
|
-
end
|
285
|
-
|
286
|
-
uri = URI::HTTP.new(nil, nil, nil, nil, nil, local_path, nil, nil, nil)
|
287
|
-
unless local_params.empty?
|
288
|
-
query_string = local_params.map { |k, v| "#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v)}" }.join('&')
|
289
|
-
uri.query = query_string
|
290
|
-
end
|
291
|
-
uri.to_s
|
158
|
+
# Compatibility method for existing tests
|
159
|
+
def middleware_stack
|
160
|
+
@middleware.middleware_list
|
292
161
|
end
|
293
162
|
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
unless accept_langs.empty?
|
299
|
-
locales = accept_langs.split(',').map do |l|
|
300
|
-
l += ';q=1.0' unless /;q=\d+(?:\.\d+)?$/.match?(l)
|
301
|
-
l.split(';q=')
|
302
|
-
end.sort_by do |_locale, qvalue|
|
303
|
-
qvalue.to_f
|
304
|
-
end.collect do |locale, _qvalue|
|
305
|
-
locale
|
306
|
-
end.reverse
|
307
|
-
end
|
308
|
-
Otto.logger.debug "locale: #{locales} (#{accept_langs})" if Otto.debug
|
309
|
-
locales.empty? ? nil : locales
|
163
|
+
# Compatibility method for existing tests
|
164
|
+
def middleware_stack=(stack)
|
165
|
+
@middleware.clear!
|
166
|
+
Array(stack).each { |middleware| @middleware.add(middleware) }
|
310
167
|
end
|
311
168
|
|
312
|
-
#
|
313
|
-
|
314
|
-
|
315
|
-
# @param args [Array] Additional arguments for the middleware
|
316
|
-
# @param block [Proc] Optional block for middleware configuration
|
317
|
-
def use(middleware, *, &)
|
318
|
-
@middleware_stack << middleware
|
169
|
+
# Compatibility method for middleware detection
|
170
|
+
def middleware_enabled?(middleware_class)
|
171
|
+
@middleware.includes?(middleware_class)
|
319
172
|
end
|
320
173
|
|
174
|
+
# Security Configuration Methods
|
175
|
+
|
321
176
|
# Enable CSRF protection for POST, PUT, DELETE, and PATCH requests.
|
322
177
|
# This will automatically add CSRF tokens to HTML forms and validate
|
323
178
|
# them on unsafe HTTP methods.
|
@@ -325,10 +180,10 @@ class Otto
|
|
325
180
|
# @example
|
326
181
|
# otto.enable_csrf_protection!
|
327
182
|
def enable_csrf_protection!
|
328
|
-
return if
|
183
|
+
return if @middleware.includes?(Otto::Security::Middleware::CSRFMiddleware)
|
329
184
|
|
330
185
|
@security_config.enable_csrf_protection!
|
331
|
-
use Otto::Security::CSRFMiddleware
|
186
|
+
use Otto::Security::Middleware::CSRFMiddleware
|
332
187
|
end
|
333
188
|
|
334
189
|
# Enable request validation including input sanitization, size limits,
|
@@ -337,10 +192,39 @@ class Otto
|
|
337
192
|
# @example
|
338
193
|
# otto.enable_request_validation!
|
339
194
|
def enable_request_validation!
|
340
|
-
return if
|
195
|
+
return if @middleware.includes?(Otto::Security::Middleware::ValidationMiddleware)
|
341
196
|
|
342
197
|
@security_config.input_validation = true
|
343
|
-
use Otto::Security::ValidationMiddleware
|
198
|
+
use Otto::Security::Middleware::ValidationMiddleware
|
199
|
+
end
|
200
|
+
|
201
|
+
# Enable rate limiting to protect against abuse and DDoS attacks.
|
202
|
+
# This will automatically add rate limiting rules based on client IP.
|
203
|
+
#
|
204
|
+
# @param options [Hash] Rate limiting configuration options
|
205
|
+
# @option options [Integer] :requests_per_minute Maximum requests per minute per IP (default: 100)
|
206
|
+
# @option options [Hash] :custom_rules Custom rate limiting rules
|
207
|
+
# @example
|
208
|
+
# otto.enable_rate_limiting!(requests_per_minute: 50)
|
209
|
+
def enable_rate_limiting!(options = {})
|
210
|
+
return if @middleware.includes?(Otto::Security::Middleware::RateLimitMiddleware)
|
211
|
+
|
212
|
+
@security.configure_rate_limiting(options)
|
213
|
+
use Otto::Security::Middleware::RateLimitMiddleware
|
214
|
+
end
|
215
|
+
|
216
|
+
# Add a custom rate limiting rule.
|
217
|
+
#
|
218
|
+
# @param name [String, Symbol] Rule name
|
219
|
+
# @param options [Hash] Rule configuration
|
220
|
+
# @option options [Integer] :limit Maximum requests
|
221
|
+
# @option options [Integer] :period Time period in seconds (default: 60)
|
222
|
+
# @option options [Proc] :condition Optional condition proc that receives request
|
223
|
+
# @example
|
224
|
+
# otto.add_rate_limit_rule('uploads', limit: 5, period: 300, condition: ->(req) { req.post? && req.path.include?('upload') })
|
225
|
+
def add_rate_limit_rule(name, options)
|
226
|
+
@security_config.rate_limiting_config[:custom_rules] ||= {}
|
227
|
+
@security_config.rate_limiting_config[:custom_rules][name.to_s] = options
|
344
228
|
end
|
345
229
|
|
346
230
|
# Add a trusted proxy server for accurate client IP detection.
|
@@ -408,208 +292,94 @@ class Otto
|
|
408
292
|
@security_config.enable_csp_with_nonce!(debug: debug)
|
409
293
|
end
|
410
294
|
|
411
|
-
# Configure locale settings for the application
|
412
|
-
#
|
413
|
-
# @param available_locales [Hash] Hash of available locales (e.g., { 'en' => 'English', 'es' => 'Spanish' })
|
414
|
-
# @param default_locale [String] Default locale to use as fallback
|
415
|
-
# @example
|
416
|
-
# otto.configure(
|
417
|
-
# available_locales: { 'en' => 'English', 'es' => 'Spanish', 'fr' => 'French' },
|
418
|
-
# default_locale: 'en'
|
419
|
-
# )
|
420
|
-
def configure(available_locales: nil, default_locale: nil)
|
421
|
-
@locale_config ||= {}
|
422
|
-
@locale_config[:available_locales] = available_locales if available_locales
|
423
|
-
@locale_config[:default_locale] = default_locale if default_locale
|
424
|
-
end
|
425
|
-
|
426
295
|
# Enable authentication middleware for route-level access control.
|
427
296
|
# This will automatically check route auth parameters and enforce authentication.
|
428
297
|
#
|
429
298
|
# @example
|
430
299
|
# otto.enable_authentication!
|
431
300
|
def enable_authentication!
|
432
|
-
return if
|
301
|
+
return if @middleware.includes?(Otto::Security::Authentication::AuthenticationMiddleware)
|
433
302
|
|
434
|
-
use Otto::Security::AuthenticationMiddleware, @auth_config
|
435
|
-
end
|
436
|
-
|
437
|
-
# Configure authentication strategies for route-level access control.
|
438
|
-
#
|
439
|
-
# @param strategies [Hash] Hash mapping strategy names to strategy instances
|
440
|
-
# @param default_strategy [String] Default strategy to use when none specified
|
441
|
-
# @example
|
442
|
-
# otto.configure_auth_strategies({
|
443
|
-
# 'publically' => Otto::Security::PublicStrategy.new,
|
444
|
-
# 'authenticated' => Otto::Security::SessionStrategy.new(session_key: 'user_id'),
|
445
|
-
# 'role:admin' => Otto::Security::RoleStrategy.new(['admin']),
|
446
|
-
# 'api_key' => Otto::Security::APIKeyStrategy.new(api_keys: ['secret123'])
|
447
|
-
# })
|
448
|
-
def configure_auth_strategies(strategies, default_strategy: 'publically')
|
449
|
-
@auth_config ||= {}
|
450
|
-
@auth_config[:auth_strategies] = strategies
|
451
|
-
@auth_config[:default_auth_strategy] = default_strategy
|
452
|
-
|
453
|
-
enable_authentication! unless strategies.empty?
|
303
|
+
use Otto::Security::Authentication::AuthenticationMiddleware, @auth_config
|
454
304
|
end
|
455
305
|
|
456
306
|
# Add a single authentication strategy
|
457
307
|
#
|
458
308
|
# @param name [String] Strategy name
|
459
|
-
# @param strategy [Otto::Security::AuthStrategy] Strategy instance
|
309
|
+
# @param strategy [Otto::Security::Authentication::AuthStrategy] Strategy instance
|
460
310
|
# @example
|
461
311
|
# otto.add_auth_strategy('custom', MyCustomStrategy.new)
|
462
312
|
def add_auth_strategy(name, strategy)
|
463
|
-
|
313
|
+
# Ensure auth_config is initialized (handles edge case where it might be nil)
|
314
|
+
@auth_config = { auth_strategies: {}, default_auth_strategy: 'publicly' } if @auth_config.nil?
|
315
|
+
|
464
316
|
@auth_config[:auth_strategies][name] = strategy
|
465
317
|
|
466
318
|
enable_authentication!
|
467
319
|
end
|
468
320
|
|
469
|
-
|
321
|
+
# Enable MCP (Model Context Protocol) server support
|
322
|
+
#
|
323
|
+
# @param options [Hash] MCP configuration options
|
324
|
+
# @option options [Boolean] :http Enable HTTP endpoint (default: true)
|
325
|
+
# @option options [Boolean] :stdio Enable STDIO communication (default: false)
|
326
|
+
# @option options [String] :endpoint HTTP endpoint path (default: '/_mcp')
|
327
|
+
# @example
|
328
|
+
# otto.enable_mcp!(http: true, endpoint: '/api/mcp')
|
329
|
+
def enable_mcp!(options = {})
|
330
|
+
@mcp_server ||= Otto::MCP::Server.new(self)
|
470
331
|
|
471
|
-
|
472
|
-
|
473
|
-
global_config = self.class.global_config
|
474
|
-
@locale_config = nil
|
475
|
-
|
476
|
-
# Check if we have any locale configuration from any source
|
477
|
-
has_global_locale = global_config && (global_config[:available_locales] || global_config[:default_locale])
|
478
|
-
has_direct_options = opts[:available_locales] || opts[:default_locale]
|
479
|
-
has_legacy_config = opts[:locale_config]
|
480
|
-
|
481
|
-
# Only create locale_config if we have configuration from somewhere
|
482
|
-
if has_global_locale || has_direct_options || has_legacy_config
|
483
|
-
@locale_config = {}
|
484
|
-
|
485
|
-
# Apply global configuration first
|
486
|
-
@locale_config[:available_locales] = global_config[:available_locales] if global_config && global_config[:available_locales]
|
487
|
-
@locale_config[:default_locale] = global_config[:default_locale] if global_config && global_config[:default_locale]
|
488
|
-
|
489
|
-
# Apply direct instance options (these override global config)
|
490
|
-
@locale_config[:available_locales] = opts[:available_locales] if opts[:available_locales]
|
491
|
-
@locale_config[:default_locale] = opts[:default_locale] if opts[:default_locale]
|
492
|
-
|
493
|
-
# Legacy support: Configure locale if provided in initialization options via locale_config hash
|
494
|
-
if opts[:locale_config]
|
495
|
-
locale_opts = opts[:locale_config]
|
496
|
-
@locale_config[:available_locales] = locale_opts[:available_locales] || locale_opts[:available] if locale_opts[:available_locales] || locale_opts[:available]
|
497
|
-
@locale_config[:default_locale] = locale_opts[:default_locale] || locale_opts[:default] if locale_opts[:default_locale] || locale_opts[:default]
|
498
|
-
end
|
499
|
-
end
|
332
|
+
@mcp_server.enable!(options)
|
333
|
+
Otto.logger.info '[MCP] Enabled MCP server' if Otto.debug
|
500
334
|
end
|
501
335
|
|
502
|
-
|
503
|
-
|
504
|
-
|
505
|
-
|
506
|
-
# Enable request validation if requested
|
507
|
-
enable_request_validation! if opts[:request_validation]
|
508
|
-
|
509
|
-
# Add trusted proxies if provided
|
510
|
-
if opts[:trusted_proxies]
|
511
|
-
Array(opts[:trusted_proxies]).each { |proxy| add_trusted_proxy(proxy) }
|
512
|
-
end
|
513
|
-
|
514
|
-
# Set custom security headers
|
515
|
-
if opts[:security_headers]
|
516
|
-
set_security_headers(opts[:security_headers])
|
517
|
-
end
|
336
|
+
# Check if MCP is enabled
|
337
|
+
# @return [Boolean]
|
338
|
+
def mcp_enabled?
|
339
|
+
@mcp_server&.enabled?
|
518
340
|
end
|
519
341
|
|
520
|
-
|
521
|
-
@middleware_stack.any? { |m| m == middleware_class }
|
522
|
-
end
|
523
|
-
|
524
|
-
def configure_authentication(opts)
|
525
|
-
# Configure authentication strategies
|
526
|
-
@auth_config = {
|
527
|
-
auth_strategies: opts[:auth_strategies] || {},
|
528
|
-
default_auth_strategy: opts[:default_auth_strategy] || 'publically'
|
529
|
-
}
|
342
|
+
private
|
530
343
|
|
531
|
-
|
532
|
-
|
533
|
-
|
534
|
-
|
344
|
+
def initialize_core_state
|
345
|
+
@routes_static = { GET: {} }
|
346
|
+
@routes = { GET: [] }
|
347
|
+
@routes_literal = { GET: {} }
|
348
|
+
@route_definitions = {}
|
349
|
+
@security_config = Otto::Security::Config.new
|
350
|
+
@middleware = Otto::Core::MiddlewareStack.new
|
351
|
+
# Initialize @auth_config first so it can be shared with the configurator
|
352
|
+
@auth_config = { auth_strategies: {}, default_auth_strategy: 'publicly' }
|
353
|
+
@security = Otto::Security::Configurator.new(@security_config, @middleware, @auth_config)
|
535
354
|
end
|
536
355
|
|
537
|
-
def
|
538
|
-
|
539
|
-
|
540
|
-
|
541
|
-
|
542
|
-
|
543
|
-
# Parse request for content negotiation
|
544
|
-
begin
|
545
|
-
Rack::Request.new(env)
|
546
|
-
rescue StandardError
|
547
|
-
nil
|
548
|
-
end
|
549
|
-
literal_routes = @routes_literal[:GET] || {}
|
550
|
-
|
551
|
-
# Try custom 500 route first
|
552
|
-
if found_route = literal_routes['/500']
|
553
|
-
begin
|
554
|
-
env['otto.error_id'] = error_id
|
555
|
-
return found_route.call(env)
|
556
|
-
rescue StandardError => ex
|
557
|
-
Otto.logger.error "[#{error_id}] Error in custom error handler: #{ex.message}"
|
558
|
-
end
|
559
|
-
end
|
560
|
-
|
561
|
-
# Content negotiation for built-in error response
|
562
|
-
accept_header = env['HTTP_ACCEPT'].to_s
|
563
|
-
if accept_header.include?('application/json')
|
564
|
-
return json_error_response(error_id)
|
565
|
-
end
|
566
|
-
|
567
|
-
# Fallback to built-in error response
|
568
|
-
@server_error || secure_error_response(error_id)
|
356
|
+
def initialize_options(_path, opts)
|
357
|
+
@option = {
|
358
|
+
public: nil,
|
359
|
+
locale: 'en',
|
360
|
+
}.merge(opts)
|
361
|
+
@route_handler_factory = opts[:route_handler_factory] || Otto::RouteHandlers::HandlerFactory
|
569
362
|
end
|
570
363
|
|
571
|
-
def
|
572
|
-
|
573
|
-
|
574
|
-
else
|
575
|
-
'An error occurred. Please try again later.'
|
576
|
-
end
|
577
|
-
|
578
|
-
headers = {
|
579
|
-
'content-type' => 'text/plain',
|
580
|
-
'content-length' => body.bytesize.to_s,
|
581
|
-
}.merge(@security_config.security_headers)
|
582
|
-
|
583
|
-
[500, headers, [body]]
|
584
|
-
end
|
364
|
+
def initialize_configurations(opts)
|
365
|
+
# Configure locale support (merge global config with instance options)
|
366
|
+
configure_locale(opts)
|
585
367
|
|
586
|
-
|
587
|
-
|
588
|
-
{
|
589
|
-
error: 'Internal Server Error',
|
590
|
-
message: 'Server error occurred. Check logs for details.',
|
591
|
-
error_id: error_id,
|
592
|
-
}
|
593
|
-
else
|
594
|
-
{
|
595
|
-
error: 'Internal Server Error',
|
596
|
-
message: 'An error occurred. Please try again later.',
|
597
|
-
}
|
598
|
-
end
|
368
|
+
# Configure security based on options
|
369
|
+
configure_security(opts)
|
599
370
|
|
600
|
-
|
601
|
-
|
602
|
-
'content-type' => 'application/json',
|
603
|
-
'content-length' => body.bytesize.to_s,
|
604
|
-
}.merge(@security_config.security_headers)
|
371
|
+
# Configure authentication based on options
|
372
|
+
configure_authentication(opts)
|
605
373
|
|
606
|
-
|
374
|
+
# Initialize MCP server
|
375
|
+
configure_mcp(opts)
|
607
376
|
end
|
608
377
|
|
609
378
|
class << self
|
610
|
-
attr_accessor :debug, :logger
|
379
|
+
attr_accessor :debug, :logger, :global_config # rubocop:disable ThreadSafety/ClassAndModuleAttributes
|
611
380
|
end
|
612
381
|
|
382
|
+
# Class methods for Otto framework providing singleton access and configuration
|
613
383
|
module ClassMethods
|
614
384
|
def default
|
615
385
|
@default ||= Otto.new
|