otto 1.6.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 +1 -1
- 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 +10 -3
- data/Gemfile.lock +23 -28
- data/README.md +2 -0
- data/bin/rspec +4 -4
- 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 +29 -34
- data/examples/mcp_demo/config.ru +9 -60
- 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 +9 -7
- data/lib/otto/mcp/auth/token.rb +10 -9
- data/lib/otto/mcp/protocol.rb +24 -27
- data/lib/otto/mcp/rate_limiting.rb +8 -3
- data/lib/otto/mcp/registry.rb +7 -2
- data/lib/otto/mcp/route_parser.rb +10 -15
- data/lib/otto/mcp/server.rb +21 -11
- data/lib/otto/mcp/validation.rb +14 -10
- 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 +15 -18
- 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 -405
- 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 +14 -12
- 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 +10 -105
- data/lib/otto/security/validator.rb +8 -253
- 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 +142 -498
- data/otto.gemspec +2 -2
- metadata +89 -28
- 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/concurrent_cache_store.rb +0 -68
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,11 +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'
|
23
|
-
require_relative 'otto/security/
|
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'
|
24
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'
|
25
37
|
|
26
38
|
# Otto is a simple Rack router that allows you to define routes in a file
|
27
39
|
# with built-in security features including CSRF protection, input validation,
|
@@ -44,51 +56,78 @@ require_relative 'otto/mcp/server'
|
|
44
56
|
# otto.enable_csp!
|
45
57
|
# otto.enable_frame_protection!
|
46
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
|
+
|
47
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
|
+
|
48
98
|
LIB_HOME = __dir__ unless defined?(Otto::LIB_HOME)
|
49
99
|
|
50
|
-
@debug
|
51
|
-
|
52
|
-
|
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
|
53
108
|
|
54
|
-
# Global configuration for all Otto instances
|
109
|
+
# Global configuration for all Otto instances (Ruby 3.2+ pattern matching)
|
55
110
|
def self.configure
|
56
|
-
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
|
57
119
|
yield config
|
58
120
|
@global_config = config.to_h
|
59
121
|
end
|
60
122
|
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option, :static_route, :security_config, :locale_config, :auth_config, :route_handler_factory, :mcp_server
|
66
|
-
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
|
67
126
|
|
68
127
|
def initialize(path = nil, opts = {})
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
@route_definitions = {}
|
73
|
-
@option = {
|
74
|
-
public: nil,
|
75
|
-
locale: 'en',
|
76
|
-
}.merge(opts)
|
77
|
-
@security_config = Otto::Security::Config.new
|
78
|
-
@middleware_stack = []
|
79
|
-
@route_handler_factory = opts[:route_handler_factory] || Otto::RouteHandlers::HandlerFactory
|
80
|
-
|
81
|
-
# Configure locale support (merge global config with instance options)
|
82
|
-
configure_locale(opts)
|
83
|
-
|
84
|
-
# Configure security based on options
|
85
|
-
configure_security(opts)
|
86
|
-
|
87
|
-
# Configure authentication based on options
|
88
|
-
configure_authentication(opts)
|
89
|
-
|
90
|
-
# Initialize MCP server
|
91
|
-
configure_mcp(opts)
|
128
|
+
initialize_core_state
|
129
|
+
initialize_options(path, opts)
|
130
|
+
initialize_configurations(opts)
|
92
131
|
|
93
132
|
Otto.logger.debug "new Otto: #{opts}" if Otto.debug
|
94
133
|
load(path) unless path.nil?
|
@@ -96,243 +135,44 @@ class Otto
|
|
96
135
|
end
|
97
136
|
alias options option
|
98
137
|
|
99
|
-
|
100
|
-
path = File.expand_path(path)
|
101
|
-
raise ArgumentError, "Bad path: #{path}" unless File.exist?(path)
|
102
|
-
|
103
|
-
raw = File.readlines(path).select { |line| line =~ /^\w/ }.collect { |line| line.strip }
|
104
|
-
raw.each do |entry|
|
105
|
-
# Enhanced parsing: split only on first two whitespace boundaries
|
106
|
-
# This preserves parameters in the definition part
|
107
|
-
parts = entry.split(/\s+/, 3)
|
108
|
-
verb, path, definition = parts[0], parts[1], parts[2]
|
109
|
-
|
110
|
-
# Check for MCP routes
|
111
|
-
if Otto::MCP::RouteParser.is_mcp_route?(definition)
|
112
|
-
handle_mcp_route(verb, path, definition) if @mcp_server
|
113
|
-
next
|
114
|
-
elsif Otto::MCP::RouteParser.is_tool_route?(definition)
|
115
|
-
handle_tool_route(verb, path, definition) if @mcp_server
|
116
|
-
next
|
117
|
-
end
|
118
|
-
|
119
|
-
route = Otto::Route.new verb, path, definition
|
120
|
-
route.otto = self
|
121
|
-
path_clean = path.gsub(%r{/$}, '')
|
122
|
-
@route_definitions[route.definition] = route
|
123
|
-
Otto.logger.debug "route: #{route.pattern}" if Otto.debug
|
124
|
-
@routes[route.verb] ||= []
|
125
|
-
@routes[route.verb] << route
|
126
|
-
@routes_literal[route.verb] ||= {}
|
127
|
-
@routes_literal[route.verb][path_clean] = route
|
128
|
-
rescue StandardError
|
129
|
-
Otto.logger.error "Bad route in #{path}: #{entry}"
|
130
|
-
end
|
131
|
-
self
|
132
|
-
end
|
133
|
-
|
134
|
-
def safe_file?(path)
|
135
|
-
return false if option[:public].nil? || option[:public].empty?
|
136
|
-
return false if path.nil? || path.empty?
|
137
|
-
|
138
|
-
# Normalize and resolve the public directory path
|
139
|
-
public_dir = File.expand_path(option[:public])
|
140
|
-
return false unless File.directory?(public_dir)
|
141
|
-
|
142
|
-
# Clean the requested path - remove null bytes and normalize
|
143
|
-
clean_path = path.delete("\0").strip
|
144
|
-
return false if clean_path.empty?
|
145
|
-
|
146
|
-
# Join and expand to get the full resolved path
|
147
|
-
requested_path = File.expand_path(File.join(public_dir, clean_path))
|
148
|
-
|
149
|
-
# Ensure the resolved path is within the public directory (prevents path traversal)
|
150
|
-
return false unless requested_path.start_with?(public_dir + File::SEPARATOR)
|
151
|
-
|
152
|
-
# Check file exists, is readable, and is not a directory
|
153
|
-
File.exist?(requested_path) &&
|
154
|
-
File.readable?(requested_path) &&
|
155
|
-
!File.directory?(requested_path) &&
|
156
|
-
(File.owned?(requested_path) || File.grpowned?(requested_path))
|
157
|
-
end
|
158
|
-
|
159
|
-
def safe_dir?(path)
|
160
|
-
return false if path.nil? || path.empty?
|
161
|
-
|
162
|
-
# Clean and expand the path
|
163
|
-
clean_path = path.delete("\0").strip
|
164
|
-
return false if clean_path.empty?
|
165
|
-
|
166
|
-
expanded_path = File.expand_path(clean_path)
|
167
|
-
|
168
|
-
# Check directory exists, is readable, and has proper ownership
|
169
|
-
File.directory?(expanded_path) &&
|
170
|
-
File.readable?(expanded_path) &&
|
171
|
-
(File.owned?(expanded_path) || File.grpowned?(expanded_path))
|
172
|
-
end
|
173
|
-
|
174
|
-
def add_static_path(path)
|
175
|
-
return unless safe_file?(path)
|
176
|
-
|
177
|
-
base_path = File.split(path).first
|
178
|
-
# Files in the root directory can refer to themselves
|
179
|
-
base_path = path if base_path == '/'
|
180
|
-
File.join(option[:public], base_path)
|
181
|
-
Otto.logger.debug "new static route: #{base_path} (#{path})" if Otto.debug
|
182
|
-
routes_static[:GET][base_path] = base_path
|
183
|
-
end
|
184
|
-
|
138
|
+
# Main Rack application interface
|
185
139
|
def call(env)
|
186
140
|
# Apply middleware stack
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
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)
|
191
145
|
|
192
146
|
begin
|
193
147
|
app.call(env)
|
194
|
-
rescue StandardError =>
|
195
|
-
handle_error(
|
148
|
+
rescue StandardError => e
|
149
|
+
handle_error(e, env)
|
196
150
|
end
|
197
151
|
end
|
198
152
|
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
env['otto.locale_config'] = @locale_config if @locale_config
|
203
|
-
@static_route ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
|
204
|
-
path_info = Rack::Utils.unescape(env['PATH_INFO'])
|
205
|
-
path_info = '/' if path_info.to_s.empty?
|
206
|
-
|
207
|
-
begin
|
208
|
-
path_info_clean = path_info
|
209
|
-
.encode(
|
210
|
-
'UTF-8', # Target encoding
|
211
|
-
invalid: :replace, # Replace invalid byte sequences
|
212
|
-
undef: :replace, # Replace characters undefined in UTF-8
|
213
|
-
replace: '', # Use empty string for replacement
|
214
|
-
)
|
215
|
-
.gsub(%r{/$}, '') # Remove trailing slash, if present
|
216
|
-
rescue ArgumentError => ex
|
217
|
-
# Log the error but don't expose details
|
218
|
-
Otto.logger.error '[Otto.handle_request] Path encoding error'
|
219
|
-
Otto.logger.debug "[Otto.handle_request] Error details: #{ex.message}" if Otto.debug
|
220
|
-
# Set a default value or use the original path_info
|
221
|
-
path_info_clean = path_info
|
222
|
-
end
|
223
|
-
|
224
|
-
base_path = File.split(path_info).first
|
225
|
-
# Files in the root directory can refer to themselves
|
226
|
-
base_path = path_info if base_path == '/'
|
227
|
-
http_verb = env['REQUEST_METHOD'].upcase.to_sym
|
228
|
-
literal_routes = routes_literal[http_verb] || {}
|
229
|
-
literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
|
230
|
-
if static_route && http_verb == :GET && routes_static[:GET].member?(base_path)
|
231
|
-
# Otto.logger.debug " request: #{path_info} (static)"
|
232
|
-
static_route.call(env)
|
233
|
-
elsif literal_routes.has_key?(path_info_clean)
|
234
|
-
route = literal_routes[path_info_clean]
|
235
|
-
# Otto.logger.debug " request: #{http_verb} #{path_info} (literal route: #{route.verb} #{route.path})"
|
236
|
-
route.call(env)
|
237
|
-
elsif static_route && http_verb == :GET && safe_file?(path_info)
|
238
|
-
Otto.logger.debug " new static route: #{base_path} (#{path_info})" if Otto.debug
|
239
|
-
routes_static[:GET][base_path] = base_path
|
240
|
-
static_route.call(env)
|
241
|
-
else
|
242
|
-
extra_params = {}
|
243
|
-
found_route = nil
|
244
|
-
valid_routes = routes[http_verb] || []
|
245
|
-
valid_routes.push(*routes[:GET]) if http_verb == :HEAD
|
246
|
-
valid_routes.each do |route|
|
247
|
-
# Otto.logger.debug " request: #{http_verb} #{path_info} (trying route: #{route.verb} #{route.pattern})"
|
248
|
-
next unless (match = route.pattern.match(path_info))
|
249
|
-
|
250
|
-
values = match.captures.to_a
|
251
|
-
# The first capture returned is the entire matched string b/c
|
252
|
-
# we wrapped the entire regex in parens. We don't need it to
|
253
|
-
# the full match.
|
254
|
-
values.shift
|
255
|
-
extra_params =
|
256
|
-
if route.keys.any?
|
257
|
-
route.keys.zip(values).each_with_object({}) do |(k, v), hash|
|
258
|
-
if k == 'splat'
|
259
|
-
(hash[k] ||= []) << v
|
260
|
-
else
|
261
|
-
hash[k] = v
|
262
|
-
end
|
263
|
-
end
|
264
|
-
elsif values.any?
|
265
|
-
{ 'captures' => values }
|
266
|
-
else
|
267
|
-
{}
|
268
|
-
end
|
269
|
-
found_route = route
|
270
|
-
break
|
271
|
-
end
|
272
|
-
found_route ||= literal_routes['/404']
|
273
|
-
if found_route
|
274
|
-
found_route.call env, extra_params
|
275
|
-
else
|
276
|
-
@not_found || Otto::Static.not_found
|
277
|
-
end
|
278
|
-
end
|
153
|
+
# Middleware Management
|
154
|
+
def use(middleware, ...)
|
155
|
+
@middleware.add(middleware, ...)
|
279
156
|
end
|
280
157
|
|
281
|
-
#
|
282
|
-
|
283
|
-
|
284
|
-
# Otto.default.path 'YourClass.somemethod' #=> /some/path
|
285
|
-
#
|
286
|
-
def uri(route_definition, params = {})
|
287
|
-
# raise RuntimeError, "Not working"
|
288
|
-
route = @route_definitions[route_definition]
|
289
|
-
return if route.nil?
|
290
|
-
|
291
|
-
local_params = params.clone
|
292
|
-
local_path = route.path.clone
|
293
|
-
|
294
|
-
local_params.each_pair do |k, v|
|
295
|
-
next unless local_path.match(":#{k}")
|
296
|
-
|
297
|
-
local_path.gsub!(":#{k}", v.to_s)
|
298
|
-
local_params.delete(k)
|
299
|
-
end
|
300
|
-
|
301
|
-
uri = URI::HTTP.new(nil, nil, nil, nil, nil, local_path, nil, nil, nil)
|
302
|
-
unless local_params.empty?
|
303
|
-
query_string = local_params.map { |k, v| "#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v)}" }.join('&')
|
304
|
-
uri.query = query_string
|
305
|
-
end
|
306
|
-
uri.to_s
|
158
|
+
# Compatibility method for existing tests
|
159
|
+
def middleware_stack
|
160
|
+
@middleware.middleware_list
|
307
161
|
end
|
308
162
|
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
unless accept_langs.empty?
|
314
|
-
locales = accept_langs.split(',').map do |l|
|
315
|
-
l += ';q=1.0' unless /;q=\d+(?:\.\d+)?$/.match?(l)
|
316
|
-
l.split(';q=')
|
317
|
-
end.sort_by do |_locale, qvalue|
|
318
|
-
qvalue.to_f
|
319
|
-
end.collect do |locale, _qvalue|
|
320
|
-
locale
|
321
|
-
end.reverse
|
322
|
-
end
|
323
|
-
Otto.logger.debug "locale: #{locales} (#{accept_langs})" if Otto.debug
|
324
|
-
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) }
|
325
167
|
end
|
326
168
|
|
327
|
-
#
|
328
|
-
|
329
|
-
|
330
|
-
# @param args [Array] Additional arguments for the middleware
|
331
|
-
# @param block [Proc] Optional block for middleware configuration
|
332
|
-
def use(middleware, *, &)
|
333
|
-
@middleware_stack << middleware
|
169
|
+
# Compatibility method for middleware detection
|
170
|
+
def middleware_enabled?(middleware_class)
|
171
|
+
@middleware.includes?(middleware_class)
|
334
172
|
end
|
335
173
|
|
174
|
+
# Security Configuration Methods
|
175
|
+
|
336
176
|
# Enable CSRF protection for POST, PUT, DELETE, and PATCH requests.
|
337
177
|
# This will automatically add CSRF tokens to HTML forms and validate
|
338
178
|
# them on unsafe HTTP methods.
|
@@ -340,10 +180,10 @@ class Otto
|
|
340
180
|
# @example
|
341
181
|
# otto.enable_csrf_protection!
|
342
182
|
def enable_csrf_protection!
|
343
|
-
return if
|
183
|
+
return if @middleware.includes?(Otto::Security::Middleware::CSRFMiddleware)
|
344
184
|
|
345
185
|
@security_config.enable_csrf_protection!
|
346
|
-
use Otto::Security::CSRFMiddleware
|
186
|
+
use Otto::Security::Middleware::CSRFMiddleware
|
347
187
|
end
|
348
188
|
|
349
189
|
# Enable request validation including input sanitization, size limits,
|
@@ -352,10 +192,10 @@ class Otto
|
|
352
192
|
# @example
|
353
193
|
# otto.enable_request_validation!
|
354
194
|
def enable_request_validation!
|
355
|
-
return if
|
195
|
+
return if @middleware.includes?(Otto::Security::Middleware::ValidationMiddleware)
|
356
196
|
|
357
197
|
@security_config.input_validation = true
|
358
|
-
use Otto::Security::ValidationMiddleware
|
198
|
+
use Otto::Security::Middleware::ValidationMiddleware
|
359
199
|
end
|
360
200
|
|
361
201
|
# Enable rate limiting to protect against abuse and DDoS attacks.
|
@@ -367,27 +207,10 @@ class Otto
|
|
367
207
|
# @example
|
368
208
|
# otto.enable_rate_limiting!(requests_per_minute: 50)
|
369
209
|
def enable_rate_limiting!(options = {})
|
370
|
-
return if
|
371
|
-
|
372
|
-
configure_rate_limiting(options)
|
373
|
-
use Otto::Security::RateLimitMiddleware
|
374
|
-
end
|
210
|
+
return if @middleware.includes?(Otto::Security::Middleware::RateLimitMiddleware)
|
375
211
|
|
376
|
-
|
377
|
-
|
378
|
-
# @param config [Hash] Rate limiting configuration
|
379
|
-
# @option config [Integer] :requests_per_minute Maximum requests per minute per IP
|
380
|
-
# @option config [Hash] :custom_rules Hash of custom rate limiting rules
|
381
|
-
# @option config [Object] :cache_store Custom cache store for rate limiting
|
382
|
-
# @example
|
383
|
-
# otto.configure_rate_limiting({
|
384
|
-
# requests_per_minute: 50,
|
385
|
-
# custom_rules: {
|
386
|
-
# 'api_calls' => { limit: 30, period: 60, condition: ->(req) { req.path.start_with?('/api') }}
|
387
|
-
# }
|
388
|
-
# })
|
389
|
-
def configure_rate_limiting(config)
|
390
|
-
@security_config.rate_limiting_config.merge!(config)
|
212
|
+
@security.configure_rate_limiting(options)
|
213
|
+
use Otto::Security::Middleware::RateLimitMiddleware
|
391
214
|
end
|
392
215
|
|
393
216
|
# Add a custom rate limiting rule.
|
@@ -469,59 +292,27 @@ class Otto
|
|
469
292
|
@security_config.enable_csp_with_nonce!(debug: debug)
|
470
293
|
end
|
471
294
|
|
472
|
-
# Configure locale settings for the application
|
473
|
-
#
|
474
|
-
# @param available_locales [Hash] Hash of available locales (e.g., { 'en' => 'English', 'es' => 'Spanish' })
|
475
|
-
# @param default_locale [String] Default locale to use as fallback
|
476
|
-
# @example
|
477
|
-
# otto.configure(
|
478
|
-
# available_locales: { 'en' => 'English', 'es' => 'Spanish', 'fr' => 'French' },
|
479
|
-
# default_locale: 'en'
|
480
|
-
# )
|
481
|
-
def configure(available_locales: nil, default_locale: nil)
|
482
|
-
@locale_config ||= {}
|
483
|
-
@locale_config[:available_locales] = available_locales if available_locales
|
484
|
-
@locale_config[:default_locale] = default_locale if default_locale
|
485
|
-
end
|
486
|
-
|
487
295
|
# Enable authentication middleware for route-level access control.
|
488
296
|
# This will automatically check route auth parameters and enforce authentication.
|
489
297
|
#
|
490
298
|
# @example
|
491
299
|
# otto.enable_authentication!
|
492
300
|
def enable_authentication!
|
493
|
-
return if
|
494
|
-
|
495
|
-
use Otto::Security::AuthenticationMiddleware, @auth_config
|
496
|
-
end
|
497
|
-
|
498
|
-
# Configure authentication strategies for route-level access control.
|
499
|
-
#
|
500
|
-
# @param strategies [Hash] Hash mapping strategy names to strategy instances
|
501
|
-
# @param default_strategy [String] Default strategy to use when none specified
|
502
|
-
# @example
|
503
|
-
# otto.configure_auth_strategies({
|
504
|
-
# 'publically' => Otto::Security::PublicStrategy.new,
|
505
|
-
# 'authenticated' => Otto::Security::SessionStrategy.new(session_key: 'user_id'),
|
506
|
-
# 'role:admin' => Otto::Security::RoleStrategy.new(['admin']),
|
507
|
-
# 'api_key' => Otto::Security::APIKeyStrategy.new(api_keys: ['secret123'])
|
508
|
-
# })
|
509
|
-
def configure_auth_strategies(strategies, default_strategy: 'publically')
|
510
|
-
@auth_config ||= {}
|
511
|
-
@auth_config[:auth_strategies] = strategies
|
512
|
-
@auth_config[:default_auth_strategy] = default_strategy
|
301
|
+
return if @middleware.includes?(Otto::Security::Authentication::AuthenticationMiddleware)
|
513
302
|
|
514
|
-
|
303
|
+
use Otto::Security::Authentication::AuthenticationMiddleware, @auth_config
|
515
304
|
end
|
516
305
|
|
517
306
|
# Add a single authentication strategy
|
518
307
|
#
|
519
308
|
# @param name [String] Strategy name
|
520
|
-
# @param strategy [Otto::Security::AuthStrategy] Strategy instance
|
309
|
+
# @param strategy [Otto::Security::Authentication::AuthStrategy] Strategy instance
|
521
310
|
# @example
|
522
311
|
# otto.add_auth_strategy('custom', MyCustomStrategy.new)
|
523
312
|
def add_auth_strategy(name, strategy)
|
524
|
-
|
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
|
+
|
525
316
|
@auth_config[:auth_strategies][name] = strategy
|
526
317
|
|
527
318
|
enable_authentication!
|
@@ -536,12 +327,10 @@ class Otto
|
|
536
327
|
# @example
|
537
328
|
# otto.enable_mcp!(http: true, endpoint: '/api/mcp')
|
538
329
|
def enable_mcp!(options = {})
|
539
|
-
|
540
|
-
@mcp_server = Otto::MCP::Server.new(self)
|
541
|
-
end
|
330
|
+
@mcp_server ||= Otto::MCP::Server.new(self)
|
542
331
|
|
543
332
|
@mcp_server.enable!(options)
|
544
|
-
Otto.logger.info
|
333
|
+
Otto.logger.info '[MCP] Enabled MCP server' if Otto.debug
|
545
334
|
end
|
546
335
|
|
547
336
|
# Check if MCP is enabled
|
@@ -552,190 +341,45 @@ class Otto
|
|
552
341
|
|
553
342
|
private
|
554
343
|
|
555
|
-
def
|
556
|
-
|
557
|
-
|
558
|
-
@
|
559
|
-
|
560
|
-
|
561
|
-
|
562
|
-
|
563
|
-
|
564
|
-
|
565
|
-
# Only create locale_config if we have configuration from somewhere
|
566
|
-
if has_global_locale || has_direct_options || has_legacy_config
|
567
|
-
@locale_config = {}
|
568
|
-
|
569
|
-
# Apply global configuration first
|
570
|
-
@locale_config[:available_locales] = global_config[:available_locales] if global_config && global_config[:available_locales]
|
571
|
-
@locale_config[:default_locale] = global_config[:default_locale] if global_config && global_config[:default_locale]
|
572
|
-
|
573
|
-
# Apply direct instance options (these override global config)
|
574
|
-
@locale_config[:available_locales] = opts[:available_locales] if opts[:available_locales]
|
575
|
-
@locale_config[:default_locale] = opts[:default_locale] if opts[:default_locale]
|
576
|
-
|
577
|
-
# Legacy support: Configure locale if provided in initialization options via locale_config hash
|
578
|
-
if opts[:locale_config]
|
579
|
-
locale_opts = opts[:locale_config]
|
580
|
-
@locale_config[:available_locales] = locale_opts[:available_locales] || locale_opts[:available] if locale_opts[:available_locales] || locale_opts[:available]
|
581
|
-
@locale_config[:default_locale] = locale_opts[:default_locale] || locale_opts[:default] if locale_opts[:default_locale] || locale_opts[:default]
|
582
|
-
end
|
583
|
-
end
|
584
|
-
end
|
585
|
-
|
586
|
-
def configure_security(opts)
|
587
|
-
# Enable CSRF protection if requested
|
588
|
-
enable_csrf_protection! if opts[:csrf_protection]
|
589
|
-
|
590
|
-
# Enable request validation if requested
|
591
|
-
enable_request_validation! if opts[:request_validation]
|
592
|
-
|
593
|
-
# Enable rate limiting if requested
|
594
|
-
if opts[:rate_limiting]
|
595
|
-
rate_limiting_opts = opts[:rate_limiting].is_a?(Hash) ? opts[:rate_limiting] : {}
|
596
|
-
enable_rate_limiting!(rate_limiting_opts)
|
597
|
-
end
|
598
|
-
|
599
|
-
# Add trusted proxies if provided
|
600
|
-
if opts[:trusted_proxies]
|
601
|
-
Array(opts[:trusted_proxies]).each { |proxy| add_trusted_proxy(proxy) }
|
602
|
-
end
|
603
|
-
|
604
|
-
# Set custom security headers
|
605
|
-
if opts[:security_headers]
|
606
|
-
set_security_headers(opts[:security_headers])
|
607
|
-
end
|
608
|
-
end
|
609
|
-
|
610
|
-
def middleware_enabled?(middleware_class)
|
611
|
-
@middleware_stack.any? { |m| m == middleware_class }
|
612
|
-
end
|
613
|
-
|
614
|
-
def configure_authentication(opts)
|
615
|
-
# Configure authentication strategies
|
616
|
-
@auth_config = {
|
617
|
-
auth_strategies: opts[:auth_strategies] || {},
|
618
|
-
default_auth_strategy: opts[:default_auth_strategy] || 'publically'
|
619
|
-
}
|
620
|
-
|
621
|
-
# Enable authentication middleware if strategies are configured
|
622
|
-
if opts[:auth_strategies] && !opts[:auth_strategies].empty?
|
623
|
-
enable_authentication!
|
624
|
-
end
|
625
|
-
end
|
626
|
-
|
627
|
-
def configure_mcp(opts)
|
628
|
-
@mcp_server = nil
|
629
|
-
|
630
|
-
# Enable MCP if requested in options
|
631
|
-
if opts[:mcp_enabled] || opts[:mcp_http] || opts[:mcp_stdio]
|
632
|
-
@mcp_server = Otto::MCP::Server.new(self)
|
633
|
-
|
634
|
-
mcp_options = {}
|
635
|
-
mcp_options[:http_endpoint] = opts[:mcp_endpoint] if opts[:mcp_endpoint]
|
636
|
-
|
637
|
-
if opts[:mcp_http] != false # Default to true unless explicitly disabled
|
638
|
-
@mcp_server.enable!(mcp_options)
|
639
|
-
end
|
640
|
-
end
|
641
|
-
end
|
642
|
-
|
643
|
-
def handle_mcp_route(verb, path, definition)
|
644
|
-
begin
|
645
|
-
route_info = Otto::MCP::RouteParser.parse_mcp_route(verb, path, definition)
|
646
|
-
@mcp_server.register_mcp_route(route_info)
|
647
|
-
Otto.logger.debug "[MCP] Registered resource route: #{definition}" if Otto.debug
|
648
|
-
rescue => e
|
649
|
-
Otto.logger.error "[MCP] Failed to parse MCP route: #{definition} - #{e.message}"
|
650
|
-
end
|
651
|
-
end
|
652
|
-
|
653
|
-
def handle_tool_route(verb, path, definition)
|
654
|
-
begin
|
655
|
-
route_info = Otto::MCP::RouteParser.parse_tool_route(verb, path, definition)
|
656
|
-
@mcp_server.register_mcp_route(route_info)
|
657
|
-
Otto.logger.debug "[MCP] Registered tool route: #{definition}" if Otto.debug
|
658
|
-
rescue => e
|
659
|
-
Otto.logger.error "[MCP] Failed to parse TOOL route: #{definition} - #{e.message}"
|
660
|
-
end
|
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)
|
661
354
|
end
|
662
355
|
|
663
|
-
def
|
664
|
-
|
665
|
-
|
666
|
-
|
667
|
-
|
668
|
-
|
669
|
-
# Parse request for content negotiation
|
670
|
-
begin
|
671
|
-
Rack::Request.new(env)
|
672
|
-
rescue StandardError
|
673
|
-
nil
|
674
|
-
end
|
675
|
-
literal_routes = @routes_literal[:GET] || {}
|
676
|
-
|
677
|
-
# Try custom 500 route first
|
678
|
-
if found_route = literal_routes['/500']
|
679
|
-
begin
|
680
|
-
env['otto.error_id'] = error_id
|
681
|
-
return found_route.call(env)
|
682
|
-
rescue StandardError => ex
|
683
|
-
Otto.logger.error "[#{error_id}] Error in custom error handler: #{ex.message}"
|
684
|
-
end
|
685
|
-
end
|
686
|
-
|
687
|
-
# Content negotiation for built-in error response
|
688
|
-
accept_header = env['HTTP_ACCEPT'].to_s
|
689
|
-
if accept_header.include?('application/json')
|
690
|
-
return json_error_response(error_id)
|
691
|
-
end
|
692
|
-
|
693
|
-
# Fallback to built-in error response
|
694
|
-
@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
|
695
362
|
end
|
696
363
|
|
697
|
-
def
|
698
|
-
|
699
|
-
|
700
|
-
else
|
701
|
-
'An error occurred. Please try again later.'
|
702
|
-
end
|
703
|
-
|
704
|
-
headers = {
|
705
|
-
'content-type' => 'text/plain',
|
706
|
-
'content-length' => body.bytesize.to_s,
|
707
|
-
}.merge(@security_config.security_headers)
|
708
|
-
|
709
|
-
[500, headers, [body]]
|
710
|
-
end
|
364
|
+
def initialize_configurations(opts)
|
365
|
+
# Configure locale support (merge global config with instance options)
|
366
|
+
configure_locale(opts)
|
711
367
|
|
712
|
-
|
713
|
-
|
714
|
-
{
|
715
|
-
error: 'Internal Server Error',
|
716
|
-
message: 'Server error occurred. Check logs for details.',
|
717
|
-
error_id: error_id,
|
718
|
-
}
|
719
|
-
else
|
720
|
-
{
|
721
|
-
error: 'Internal Server Error',
|
722
|
-
message: 'An error occurred. Please try again later.',
|
723
|
-
}
|
724
|
-
end
|
368
|
+
# Configure security based on options
|
369
|
+
configure_security(opts)
|
725
370
|
|
726
|
-
|
727
|
-
|
728
|
-
'content-type' => 'application/json',
|
729
|
-
'content-length' => body.bytesize.to_s,
|
730
|
-
}.merge(@security_config.security_headers)
|
371
|
+
# Configure authentication based on options
|
372
|
+
configure_authentication(opts)
|
731
373
|
|
732
|
-
|
374
|
+
# Initialize MCP server
|
375
|
+
configure_mcp(opts)
|
733
376
|
end
|
734
377
|
|
735
378
|
class << self
|
736
|
-
attr_accessor :debug, :logger
|
379
|
+
attr_accessor :debug, :logger, :global_config # rubocop:disable ThreadSafety/ClassAndModuleAttributes
|
737
380
|
end
|
738
381
|
|
382
|
+
# Class methods for Otto framework providing singleton access and configuration
|
739
383
|
module ClassMethods
|
740
384
|
def default
|
741
385
|
@default ||= Otto.new
|