otto 1.6.0 → 2.0.0.pre2
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 +3 -2
- data/.github/workflows/claude-code-review.yml +53 -0
- data/.github/workflows/claude.yml +49 -0
- data/.gitignore +3 -0
- data/.rubocop.yml +26 -344
- data/CHANGELOG.rst +131 -0
- data/CLAUDE.md +56 -0
- data/Gemfile +11 -4
- data/Gemfile.lock +38 -42
- data/README.md +2 -0
- data/bin/rspec +4 -4
- data/changelog.d/README.md +120 -0
- data/changelog.d/scriv.ini +5 -0
- data/docs/.gitignore +2 -0
- data/docs/migrating/v2.0.0-pre1.md +276 -0
- data/docs/migrating/v2.0.0-pre2.md +345 -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 +237 -0
- data/lib/otto/core/router.rb +184 -0
- data/lib/otto/core/uri_generator.rb +44 -0
- data/lib/otto/design_system.rb +7 -5
- data/lib/otto/env_keys.rb +114 -0
- data/lib/otto/helpers/base.rb +5 -21
- data/lib/otto/helpers/request.rb +10 -8
- data/lib/otto/helpers/response.rb +27 -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/{validation.rb → schema_validation.rb} +16 -11
- data/lib/otto/mcp/server.rb +45 -22
- 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 +34 -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 +51 -55
- 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 +42 -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 +140 -0
- data/lib/otto/security/authentication/failure_result.rb +44 -0
- data/lib/otto/security/authentication/route_auth_wrapper.rb +149 -0
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +19 -0
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -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 +337 -0
- data/lib/otto/security/authentication.rb +28 -282
- data/lib/otto/security/config.rb +14 -23
- 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 +54 -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 +141 -498
- data/otto.gemspec +4 -2
- metadata +99 -18
- 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
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/otto/core/error_handler.rb
|
|
4
|
+
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'rack/request'
|
|
8
|
+
|
|
9
|
+
class Otto
|
|
10
|
+
module Core
|
|
11
|
+
# Error handling module providing secure error reporting and logging functionality
|
|
12
|
+
module ErrorHandler
|
|
13
|
+
def handle_error(error, env)
|
|
14
|
+
# Log error details internally but don't expose them
|
|
15
|
+
error_id = SecureRandom.hex(8)
|
|
16
|
+
Otto.logger.error "[#{error_id}] #{error.class}: #{error.message}"
|
|
17
|
+
Otto.logger.debug "[#{error_id}] Backtrace: #{error.backtrace.join("\n")}" if Otto.debug
|
|
18
|
+
|
|
19
|
+
# Parse request for content negotiation
|
|
20
|
+
begin
|
|
21
|
+
Rack::Request.new(env)
|
|
22
|
+
rescue StandardError
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
literal_routes = @routes_literal[:GET] || {}
|
|
26
|
+
|
|
27
|
+
# Try custom 500 route first
|
|
28
|
+
if found_route = literal_routes['/500']
|
|
29
|
+
begin
|
|
30
|
+
env['otto.error_id'] = error_id
|
|
31
|
+
return found_route.call(env)
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
Otto.logger.error "[#{error_id}] Error in custom error handler: #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Content negotiation for built-in error response
|
|
38
|
+
accept_header = env['HTTP_ACCEPT'].to_s
|
|
39
|
+
return json_error_response(error_id) if accept_header.include?('application/json')
|
|
40
|
+
|
|
41
|
+
# Fallback to built-in error response
|
|
42
|
+
@server_error || secure_error_response(error_id)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def secure_error_response(error_id)
|
|
48
|
+
body = if Otto.env?(:dev, :development)
|
|
49
|
+
"Server error (ID: #{error_id}). Check logs for details."
|
|
50
|
+
else
|
|
51
|
+
'An error occurred. Please try again later.'
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
headers = {
|
|
55
|
+
'content-type' => 'text/plain',
|
|
56
|
+
'content-length' => body.bytesize.to_s,
|
|
57
|
+
}.merge(@security_config.security_headers)
|
|
58
|
+
|
|
59
|
+
[500, headers, [body]]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def json_error_response(error_id)
|
|
63
|
+
error_data = if Otto.env?(:dev, :development)
|
|
64
|
+
{
|
|
65
|
+
error: 'Internal Server Error',
|
|
66
|
+
message: 'Server error occurred. Check logs for details.',
|
|
67
|
+
error_id: error_id,
|
|
68
|
+
}
|
|
69
|
+
else
|
|
70
|
+
{
|
|
71
|
+
error: 'Internal Server Error',
|
|
72
|
+
message: 'An error occurred. Please try again later.',
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
body = JSON.generate(error_data)
|
|
77
|
+
headers = {
|
|
78
|
+
'content-type' => 'application/json',
|
|
79
|
+
'content-length' => body.bytesize.to_s,
|
|
80
|
+
}.merge(@security_config.security_headers)
|
|
81
|
+
|
|
82
|
+
[500, headers, [body]]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/otto/core/file_safety.rb
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Core
|
|
7
|
+
# File safety module providing secure file access validation and path traversal protection
|
|
8
|
+
module FileSafety
|
|
9
|
+
def safe_file?(path)
|
|
10
|
+
return false if option[:public].nil? || option[:public].empty?
|
|
11
|
+
return false if path.nil? || path.empty?
|
|
12
|
+
|
|
13
|
+
# Normalize and resolve the public directory path
|
|
14
|
+
public_dir = File.expand_path(option[:public])
|
|
15
|
+
return false unless File.directory?(public_dir)
|
|
16
|
+
|
|
17
|
+
# Clean the requested path - remove null bytes and normalize
|
|
18
|
+
clean_path = path.delete("\0").strip
|
|
19
|
+
return false if clean_path.empty?
|
|
20
|
+
|
|
21
|
+
# Join and expand to get the full resolved path
|
|
22
|
+
requested_path = File.expand_path(File.join(public_dir, clean_path))
|
|
23
|
+
|
|
24
|
+
# Ensure the resolved path is within the public directory (prevents path traversal)
|
|
25
|
+
return false unless requested_path.start_with?(public_dir + File::SEPARATOR)
|
|
26
|
+
|
|
27
|
+
# Check file exists, is readable, and is not a directory
|
|
28
|
+
File.exist?(requested_path) &&
|
|
29
|
+
File.readable?(requested_path) &&
|
|
30
|
+
!File.directory?(requested_path) &&
|
|
31
|
+
(File.owned?(requested_path) || File.grpowned?(requested_path))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def safe_dir?(path)
|
|
35
|
+
return false if path.nil? || path.empty?
|
|
36
|
+
|
|
37
|
+
# Clean and expand the path
|
|
38
|
+
clean_path = path.delete("\0").strip
|
|
39
|
+
return false if clean_path.empty?
|
|
40
|
+
|
|
41
|
+
expanded_path = File.expand_path(clean_path)
|
|
42
|
+
|
|
43
|
+
# Check directory exists, is readable, and has proper ownership
|
|
44
|
+
File.directory?(expanded_path) &&
|
|
45
|
+
File.readable?(expanded_path) &&
|
|
46
|
+
(File.owned?(expanded_path) || File.grpowned?(expanded_path))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def add_static_path(path)
|
|
50
|
+
return unless safe_file?(path)
|
|
51
|
+
|
|
52
|
+
base_path = File.split(path).first
|
|
53
|
+
# Files in the root directory can refer to themselves
|
|
54
|
+
base_path = path if base_path == '/'
|
|
55
|
+
File.join(option[:public], base_path)
|
|
56
|
+
Otto.logger.debug "new static route: #{base_path} (#{path})" if Otto.debug
|
|
57
|
+
routes_static[:GET][base_path] = base_path
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/otto/core/middleware_stack.rb
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Core
|
|
7
|
+
# Enhanced middleware stack management for Otto framework.
|
|
8
|
+
# Provides better middleware registration, introspection capabilities,
|
|
9
|
+
# and improved execution chain management.
|
|
10
|
+
class MiddlewareStack
|
|
11
|
+
include Enumerable
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@stack = []
|
|
15
|
+
@middleware_set = Set.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Enhanced middleware registration with argument uniqueness and immutability check
|
|
19
|
+
def add(middleware_class, *args, **options)
|
|
20
|
+
# Prevent modifications to frozen configurations
|
|
21
|
+
raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?
|
|
22
|
+
|
|
23
|
+
# Check if an identical middleware configuration already exists
|
|
24
|
+
existing_entry = @stack.find do |entry|
|
|
25
|
+
entry[:middleware] == middleware_class &&
|
|
26
|
+
entry[:args] == args &&
|
|
27
|
+
entry[:options] == options
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Only add if no identical middleware configuration exists
|
|
31
|
+
return if existing_entry
|
|
32
|
+
|
|
33
|
+
entry = { middleware: middleware_class, args: args, options: options }
|
|
34
|
+
@stack << entry
|
|
35
|
+
@middleware_set.add(middleware_class)
|
|
36
|
+
# Invalidate memoized middleware list
|
|
37
|
+
@memoized_middleware_list = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Add middleware with position hint for optimal ordering
|
|
41
|
+
#
|
|
42
|
+
# @param middleware_class [Class] Middleware class
|
|
43
|
+
# @param args [Array] Middleware arguments
|
|
44
|
+
# @param position [Symbol, nil] Position hint (:first, :last, or nil for append)
|
|
45
|
+
# @option options [Symbol] :position Position hint (:first or :last)
|
|
46
|
+
def add_with_position(middleware_class, *args, position: nil, **options)
|
|
47
|
+
raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?
|
|
48
|
+
|
|
49
|
+
# Check for identical configuration
|
|
50
|
+
existing_entry = @stack.find do |entry|
|
|
51
|
+
entry[:middleware] == middleware_class &&
|
|
52
|
+
entry[:args] == args &&
|
|
53
|
+
entry[:options] == options
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
return if existing_entry
|
|
57
|
+
|
|
58
|
+
entry = { middleware: middleware_class, args: args, options: options }
|
|
59
|
+
|
|
60
|
+
case position
|
|
61
|
+
when :first
|
|
62
|
+
@stack.unshift(entry)
|
|
63
|
+
when :last
|
|
64
|
+
@stack << entry
|
|
65
|
+
else
|
|
66
|
+
@stack << entry # Default append
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
@middleware_set.add(middleware_class)
|
|
70
|
+
@memoized_middleware_list = nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Validate MCP middleware ordering
|
|
74
|
+
#
|
|
75
|
+
# MCP middleware must be in security-optimal order:
|
|
76
|
+
# 1. RateLimitMiddleware (reject excessive requests early)
|
|
77
|
+
# 2. Auth middleware (validate credentials before parsing)
|
|
78
|
+
# 3. SchemaValidationMiddleware (expensive JSON schema validation last)
|
|
79
|
+
#
|
|
80
|
+
# @return [Array<String>] Warning messages if order is suboptimal
|
|
81
|
+
def validate_mcp_middleware_order
|
|
82
|
+
warnings = []
|
|
83
|
+
|
|
84
|
+
# PERFORMANCE NOTE: This implementation intentionally uses select + find_index
|
|
85
|
+
# rather than a single-pass approach. The filtered mcp_middlewares array is
|
|
86
|
+
# typically 0-3 items, making the performance difference unmeasurable.
|
|
87
|
+
# The current approach prioritizes readability over micro-optimization.
|
|
88
|
+
# Single-pass alternatives were considered but rejected as premature optimization.
|
|
89
|
+
mcp_middlewares = @stack.select do |entry|
|
|
90
|
+
[
|
|
91
|
+
Otto::MCP::RateLimitMiddleware,
|
|
92
|
+
Otto::MCP::Auth::TokenMiddleware,
|
|
93
|
+
Otto::MCP::SchemaValidationMiddleware,
|
|
94
|
+
].include?(entry[:middleware])
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
return warnings if mcp_middlewares.size < 2
|
|
98
|
+
|
|
99
|
+
# Find positions
|
|
100
|
+
rate_limit_pos = mcp_middlewares.find_index { |e| e[:middleware] == Otto::MCP::RateLimitMiddleware }
|
|
101
|
+
auth_pos = mcp_middlewares.find_index { |e| e[:middleware] == Otto::MCP::Auth::TokenMiddleware }
|
|
102
|
+
validation_pos = mcp_middlewares.find_index { |e| e[:middleware] == Otto::MCP::SchemaValidationMiddleware }
|
|
103
|
+
|
|
104
|
+
# Check optimal order: rate_limit < auth < validation
|
|
105
|
+
if rate_limit_pos && auth_pos && rate_limit_pos > auth_pos
|
|
106
|
+
warnings << '[MCP Middleware] RateLimitMiddleware should come before TokenMiddleware for optimal performance'
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
if auth_pos && validation_pos && auth_pos > validation_pos
|
|
110
|
+
warnings << '[MCP Middleware] TokenMiddleware should come before SchemaValidationMiddleware for optimal performance'
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
if rate_limit_pos && validation_pos && rate_limit_pos > validation_pos
|
|
114
|
+
warnings << '[MCP Middleware] RateLimitMiddleware should come before SchemaValidationMiddleware for optimal performance'
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
warnings
|
|
118
|
+
end
|
|
119
|
+
alias use add
|
|
120
|
+
alias << add
|
|
121
|
+
|
|
122
|
+
# Remove middleware
|
|
123
|
+
def remove(middleware_class)
|
|
124
|
+
# Prevent modifications to frozen configurations
|
|
125
|
+
raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?
|
|
126
|
+
|
|
127
|
+
matches = @stack.reject! { |entry| entry[:middleware] == middleware_class }
|
|
128
|
+
|
|
129
|
+
# Update middleware set if any matching entries were found
|
|
130
|
+
return unless matches
|
|
131
|
+
|
|
132
|
+
# Rebuild the set of unique middleware classes
|
|
133
|
+
@middleware_set = Set.new(@stack.map { |entry| entry[:middleware] })
|
|
134
|
+
# Invalidate memoized middleware list
|
|
135
|
+
@memoized_middleware_list = nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Check if middleware is registered - now O(1) using Set
|
|
139
|
+
def includes?(middleware_class)
|
|
140
|
+
@middleware_set.include?(middleware_class)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Clear all middleware
|
|
144
|
+
def clear!
|
|
145
|
+
# Prevent modifications to frozen configurations
|
|
146
|
+
raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?
|
|
147
|
+
|
|
148
|
+
@stack.clear
|
|
149
|
+
@middleware_set.clear
|
|
150
|
+
# Invalidate memoized middleware list
|
|
151
|
+
@memoized_middleware_list = nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Enumerable support
|
|
155
|
+
def each(&)
|
|
156
|
+
@stack.each(&)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Build Rack application with middleware chain
|
|
160
|
+
def build_app(base_app, security_config = nil)
|
|
161
|
+
@stack.reduce(base_app) do |app, entry|
|
|
162
|
+
middleware = entry[:middleware]
|
|
163
|
+
args = entry[:args]
|
|
164
|
+
options = entry[:options]
|
|
165
|
+
|
|
166
|
+
if middleware.respond_to?(:new)
|
|
167
|
+
# Inject security_config for security middleware, placing it before custom args
|
|
168
|
+
if security_config && middleware_needs_config?(middleware)
|
|
169
|
+
middleware.new(app, security_config, *args, **options)
|
|
170
|
+
else
|
|
171
|
+
middleware.new(app, *args, **options)
|
|
172
|
+
end
|
|
173
|
+
else
|
|
174
|
+
# Proc-based middleware
|
|
175
|
+
middleware.call(app)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Cached middleware list to reduce array creation
|
|
181
|
+
def middleware_list
|
|
182
|
+
# Memoize the result to avoid repeated array creation
|
|
183
|
+
@memoized_middleware_list ||= @stack.map { |entry| entry[:middleware] }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Detailed introspection
|
|
187
|
+
def middleware_details
|
|
188
|
+
@stack.map do |entry|
|
|
189
|
+
{
|
|
190
|
+
middleware: entry[:middleware],
|
|
191
|
+
args: entry[:args],
|
|
192
|
+
options: entry[:options],
|
|
193
|
+
}
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Statistics
|
|
198
|
+
def size
|
|
199
|
+
@stack.size
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def empty?
|
|
203
|
+
@stack.empty?
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Count occurrences of a specific middleware class
|
|
207
|
+
def count(middleware_class)
|
|
208
|
+
@stack.count { |entry| entry[:middleware] == middleware_class }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# NOTE: The includes? method is defined earlier for O(1) lookup using a Set
|
|
212
|
+
|
|
213
|
+
# Legacy compatibility methods for existing Otto interface
|
|
214
|
+
def reverse_each(&)
|
|
215
|
+
@stack.reverse_each(&)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
private
|
|
219
|
+
|
|
220
|
+
def middleware_needs_config?(middleware_class)
|
|
221
|
+
# Include all Otto security middleware that can accept security_config
|
|
222
|
+
# Support both new namespaced classes and backward compatibility aliases
|
|
223
|
+
[
|
|
224
|
+
Otto::Security::Middleware::CSRFMiddleware,
|
|
225
|
+
Otto::Security::Middleware::ValidationMiddleware,
|
|
226
|
+
Otto::Security::Middleware::RateLimitMiddleware,
|
|
227
|
+
Otto::Security::Authentication::AuthenticationMiddleware,
|
|
228
|
+
# Backward compatibility aliases
|
|
229
|
+
Otto::Security::CSRFMiddleware,
|
|
230
|
+
Otto::Security::ValidationMiddleware,
|
|
231
|
+
Otto::Security::RateLimitMiddleware,
|
|
232
|
+
Otto::Security::AuthenticationMiddleware,
|
|
233
|
+
].include?(middleware_class)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/otto/core/router.rb
|
|
4
|
+
|
|
5
|
+
require_relative '../mcp/route_parser'
|
|
6
|
+
|
|
7
|
+
class Otto
|
|
8
|
+
module Core
|
|
9
|
+
# Router module providing route loading and request dispatching functionality
|
|
10
|
+
module Router
|
|
11
|
+
def load(path)
|
|
12
|
+
path = File.expand_path(path)
|
|
13
|
+
raise ArgumentError, "Bad path: #{path}" unless File.exist?(path)
|
|
14
|
+
|
|
15
|
+
raw = File.readlines(path).grep(/^\w/).collect(&:strip)
|
|
16
|
+
raw.each do |entry|
|
|
17
|
+
# Enhanced parsing: split only on first two whitespace boundaries
|
|
18
|
+
# This preserves parameters in the definition part
|
|
19
|
+
parts = entry.split(/\s+/, 3)
|
|
20
|
+
next if parts.size < 3 # Skip malformed entries
|
|
21
|
+
|
|
22
|
+
verb = parts[0]
|
|
23
|
+
path = parts[1]
|
|
24
|
+
definition = parts[2]
|
|
25
|
+
|
|
26
|
+
# Check for MCP routes
|
|
27
|
+
if Otto::MCP::RouteParser.is_mcp_route?(definition)
|
|
28
|
+
handle_mcp_route(verb, path, definition)
|
|
29
|
+
next
|
|
30
|
+
elsif Otto::MCP::RouteParser.is_tool_route?(definition)
|
|
31
|
+
handle_tool_route(verb, path, definition)
|
|
32
|
+
next
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
route = Otto::Route.new verb, path, definition
|
|
36
|
+
route.otto = self
|
|
37
|
+
path_clean = path.gsub(%r{/$}, '')
|
|
38
|
+
@route_definitions[route.definition] = route
|
|
39
|
+
Otto.logger.debug "route: #{route.pattern}" if Otto.debug
|
|
40
|
+
@routes[route.verb] ||= []
|
|
41
|
+
@routes[route.verb] << route
|
|
42
|
+
@routes_literal[route.verb] ||= {}
|
|
43
|
+
@routes_literal[route.verb][path_clean] = route
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
Otto.logger.error "Error for route #{path}: #{e.message}"
|
|
46
|
+
Otto.logger.debug e.backtrace.join("\n") if Otto.debug
|
|
47
|
+
end
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def handle_request(env)
|
|
52
|
+
locale = determine_locale env
|
|
53
|
+
env['rack.locale'] = locale
|
|
54
|
+
env['otto.locale_config'] = @locale_config if @locale_config
|
|
55
|
+
@static_route ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
|
|
56
|
+
path_info = Rack::Utils.unescape(env['PATH_INFO'])
|
|
57
|
+
path_info = '/' if path_info.to_s.empty?
|
|
58
|
+
|
|
59
|
+
begin
|
|
60
|
+
path_info_clean = path_info
|
|
61
|
+
.encode(
|
|
62
|
+
'UTF-8', # Target encoding
|
|
63
|
+
invalid: :replace, # Replace invalid byte sequences
|
|
64
|
+
undef: :replace, # Replace characters undefined in UTF-8
|
|
65
|
+
replace: '' # Use empty string for replacement
|
|
66
|
+
)
|
|
67
|
+
.gsub(%r{/$}, '') # Remove trailing slash, if present
|
|
68
|
+
rescue ArgumentError => e
|
|
69
|
+
# Log the error but don't expose details
|
|
70
|
+
Otto.logger.error '[Otto.handle_request] Path encoding error'
|
|
71
|
+
Otto.logger.debug "[Otto.handle_request] Error details: #{e.message}" if Otto.debug
|
|
72
|
+
# Set a default value or use the original path_info
|
|
73
|
+
path_info_clean = path_info
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
base_path = File.split(path_info).first
|
|
77
|
+
# Files in the root directory can refer to themselves
|
|
78
|
+
base_path = path_info if base_path == '/'
|
|
79
|
+
http_verb = env['REQUEST_METHOD'].upcase.to_sym
|
|
80
|
+
literal_routes = routes_literal[http_verb] || {}
|
|
81
|
+
literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
|
|
82
|
+
|
|
83
|
+
if static_route && http_verb == :GET && routes_static[:GET].member?(base_path)
|
|
84
|
+
# Otto.logger.debug " request: #{path_info} (static)"
|
|
85
|
+
static_route.call(env)
|
|
86
|
+
elsif literal_routes.has_key?(path_info_clean)
|
|
87
|
+
route = literal_routes[path_info_clean]
|
|
88
|
+
# Otto.logger.debug " request: #{http_verb} #{path_info} (literal route: #{route.verb} #{route.path})"
|
|
89
|
+
route.call(env)
|
|
90
|
+
elsif static_route && http_verb == :GET && safe_file?(path_info)
|
|
91
|
+
Otto.logger.debug " new static route: #{base_path} (#{path_info})" if Otto.debug
|
|
92
|
+
routes_static[:GET][base_path] = base_path
|
|
93
|
+
static_route.call(env)
|
|
94
|
+
else
|
|
95
|
+
match_dynamic_route(env, path_info, http_verb, literal_routes)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def determine_locale(env)
|
|
100
|
+
accept_langs = env['HTTP_ACCEPT_LANGUAGE']
|
|
101
|
+
accept_langs = option[:locale] if accept_langs.to_s.empty?
|
|
102
|
+
locales = []
|
|
103
|
+
unless accept_langs.empty?
|
|
104
|
+
locales = accept_langs.split(',').map do |l|
|
|
105
|
+
l += ';q=1.0' unless /;q=\d+(?:\.\d+)?$/.match?(l)
|
|
106
|
+
l.split(';q=')
|
|
107
|
+
end.sort_by do |_locale, qvalue|
|
|
108
|
+
qvalue.to_f
|
|
109
|
+
end.collect do |locale, _qvalue|
|
|
110
|
+
locale
|
|
111
|
+
end.reverse
|
|
112
|
+
end
|
|
113
|
+
Otto.logger.debug "locale: #{locales} (#{accept_langs})" if Otto.debug
|
|
114
|
+
locales.empty? ? nil : locales
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def match_dynamic_route(env, path_info, http_verb, literal_routes)
|
|
120
|
+
extra_params = {}
|
|
121
|
+
found_route = nil
|
|
122
|
+
valid_routes = routes[http_verb] || []
|
|
123
|
+
valid_routes.push(*routes[:GET]) if http_verb == :HEAD
|
|
124
|
+
|
|
125
|
+
valid_routes.each do |route|
|
|
126
|
+
# Otto.logger.debug " request: #{http_verb} #{path_info} (trying route: #{route.verb} #{route.pattern})"
|
|
127
|
+
next unless (match = route.pattern.match(path_info))
|
|
128
|
+
|
|
129
|
+
values = match.captures.to_a
|
|
130
|
+
# The first capture returned is the entire matched string b/c
|
|
131
|
+
# we wrapped the entire regex in parens. We don't need it to
|
|
132
|
+
# the full match.
|
|
133
|
+
values.shift
|
|
134
|
+
extra_params = build_route_params(route, values)
|
|
135
|
+
found_route = route
|
|
136
|
+
break
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
found_route ||= literal_routes['/404']
|
|
140
|
+
if found_route
|
|
141
|
+
found_route.call env, extra_params
|
|
142
|
+
else
|
|
143
|
+
@not_found || Otto::Static.not_found
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def build_route_params(route, values)
|
|
148
|
+
if route.keys.any?
|
|
149
|
+
route.keys.zip(values).each_with_object({}) do |(k, v), hash|
|
|
150
|
+
if k == 'splat'
|
|
151
|
+
(hash[k] ||= []) << v
|
|
152
|
+
else
|
|
153
|
+
hash[k] = v
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
elsif values.any?
|
|
157
|
+
{ 'captures' => values }
|
|
158
|
+
else
|
|
159
|
+
{}
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def handle_mcp_route(verb, path, definition)
|
|
164
|
+
raise '[MCP] MCP server not enabled' unless @mcp_server
|
|
165
|
+
|
|
166
|
+
route_info = Otto::MCP::RouteParser.parse_mcp_route(verb, path, definition)
|
|
167
|
+
@mcp_server.register_mcp_route(route_info)
|
|
168
|
+
Otto.logger.debug "[MCP] Registered resource route: #{definition}" if Otto.debug
|
|
169
|
+
rescue StandardError => e
|
|
170
|
+
Otto.logger.error "[MCP] Failed to parse MCP route: #{definition} - #{e.message}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def handle_tool_route(verb, path, definition)
|
|
174
|
+
raise '[MCP] MCP server not enabled' unless @mcp_server
|
|
175
|
+
|
|
176
|
+
route_info = Otto::MCP::RouteParser.parse_tool_route(verb, path, definition)
|
|
177
|
+
@mcp_server.register_mcp_route(route_info)
|
|
178
|
+
Otto.logger.debug "[MCP] Registered tool route: #{definition}" if Otto.debug
|
|
179
|
+
rescue StandardError => e
|
|
180
|
+
Otto.logger.error "[MCP] Failed to parse TOOL route: #{definition} - #{e.message}"
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/otto/core/uri_generator.rb
|
|
4
|
+
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
class Otto
|
|
8
|
+
module Core
|
|
9
|
+
# URI generation module providing path and URL generation for route definitions
|
|
10
|
+
module UriGenerator
|
|
11
|
+
# Return the URI path for the given +route_definition+
|
|
12
|
+
# e.g.
|
|
13
|
+
#
|
|
14
|
+
# Otto.default.path 'YourClass.somemethod' #=> /some/path
|
|
15
|
+
#
|
|
16
|
+
def uri(route_definition, params = {})
|
|
17
|
+
# raise RuntimeError, "Not working"
|
|
18
|
+
route = @route_definitions[route_definition]
|
|
19
|
+
return if route.nil?
|
|
20
|
+
|
|
21
|
+
local_params = params.clone
|
|
22
|
+
local_path = route.path.clone
|
|
23
|
+
|
|
24
|
+
keys_to_remove = []
|
|
25
|
+
local_params.each_pair do |k, v|
|
|
26
|
+
next unless local_path.match(":#{k}")
|
|
27
|
+
|
|
28
|
+
local_path.gsub!(":#{k}", v.to_s)
|
|
29
|
+
keys_to_remove << k
|
|
30
|
+
end
|
|
31
|
+
keys_to_remove.each { |k| local_params.delete(k) }
|
|
32
|
+
|
|
33
|
+
uri = URI::HTTP.new(nil, nil, nil, nil, nil, local_path, nil, nil, nil)
|
|
34
|
+
unless local_params.empty?
|
|
35
|
+
query_string = local_params.map do |k, v|
|
|
36
|
+
"#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v)}"
|
|
37
|
+
end.join('&')
|
|
38
|
+
uri.query = query_string
|
|
39
|
+
end
|
|
40
|
+
uri.to_s
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/otto/design_system.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
# lib/otto/design_system.rb
|
|
2
4
|
|
|
3
5
|
class Otto
|
|
@@ -112,11 +114,11 @@ class Otto
|
|
|
112
114
|
return '' if text.nil?
|
|
113
115
|
|
|
114
116
|
text.to_s
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
.gsub('&', '&')
|
|
118
|
+
.gsub('<', '<')
|
|
119
|
+
.gsub('>', '>')
|
|
120
|
+
.gsub('"', '"')
|
|
121
|
+
.gsub("'", ''')
|
|
120
122
|
end
|
|
121
123
|
|
|
122
124
|
def otto_styles
|