otto 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/otto/version.rb CHANGED
@@ -1,29 +1,5 @@
1
1
  # lib/otto/version.rb
2
2
 
3
3
  class Otto
4
- # Otto::VERSION
5
- #
6
- module VERSION
7
- def self.to_a
8
- load_config
9
- version = [@version[:MAJOR], @version[:MINOR], @version[:PATCH]]
10
- version << @version[:PRE] unless @version.fetch(:PRE, nil).to_s.empty?
11
- version
12
- end
13
-
14
- def self.to_s
15
- to_a.join('.')
16
- end
17
-
18
- def self.inspect
19
- to_s
20
- end
21
-
22
- def self.load_config
23
- return if @version
24
-
25
- require 'yaml'
26
- @version = YAML.load_file(File.join(__dir__, '..', '..', 'VERSION.yml'))
27
- end
28
- end
4
+ VERSION = '1.4.0'.freeze
29
5
  end
data/lib/otto.rb CHANGED
@@ -1,10 +1,12 @@
1
+ require 'json'
1
2
  require 'logger'
3
+ require 'ostruct'
2
4
  require 'securerandom'
5
+ require 'uri'
3
6
 
4
7
  require 'rack/request'
5
8
  require 'rack/response'
6
9
  require 'rack/utils'
7
- require 'addressable/uri'
8
10
 
9
11
  require_relative 'otto/route'
10
12
  require_relative 'otto/static'
@@ -39,23 +41,38 @@ require_relative 'otto/security/validator'
39
41
  class Otto
40
42
  LIB_HOME = __dir__ unless defined?(Otto::LIB_HOME)
41
43
 
42
- @debug = ENV['OTTO_DEBUG'] == 'true'
44
+ @debug = ENV['OTTO_DEBUG'] == 'true'
43
45
  @logger = Logger.new($stdout, Logger::INFO)
46
+ @global_config = {}
44
47
 
45
- attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option, :static_route, :security_config
48
+ # Global configuration for all Otto instances
49
+ def self.configure
50
+ config = OpenStruct.new(@global_config)
51
+ yield config
52
+ @global_config = config.to_h
53
+ end
54
+
55
+ def self.global_config
56
+ @global_config
57
+ end
58
+
59
+ attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option, :static_route, :security_config, :locale_config
46
60
  attr_accessor :not_found, :server_error, :middleware_stack
47
61
 
48
62
  def initialize(path = nil, opts = {})
49
- @routes_static = { GET: {} }
50
- @routes = { GET: [] }
51
- @routes_literal = { GET: {} }
63
+ @routes_static = { GET: {} }
64
+ @routes = { GET: [] }
65
+ @routes_literal = { GET: {} }
52
66
  @route_definitions = {}
53
- @option = {
67
+ @option = {
54
68
  public: nil,
55
- locale: 'en'
69
+ locale: 'en',
56
70
  }.merge(opts)
57
- @security_config = Otto::Security::Config.new
58
- @middleware_stack = []
71
+ @security_config = Otto::Security::Config.new
72
+ @middleware_stack = []
73
+
74
+ # Configure locale support (merge global config with instance options)
75
+ configure_locale(opts)
59
76
 
60
77
  # Configure security based on options
61
78
  configure_security(opts)
@@ -72,15 +89,15 @@ class Otto
72
89
 
73
90
  raw = File.readlines(path).select { |line| line =~ /^\w/ }.collect { |line| line.strip.split(/\s+/) }
74
91
  raw.each do |entry|
75
- verb, path, definition = *entry
76
- route = Otto::Route.new verb, path, definition
77
- route.otto = self
78
- path_clean = path.gsub(%r{/$}, '')
79
- @route_definitions[route.definition] = route
92
+ verb, path, definition = *entry
93
+ route = Otto::Route.new verb, path, definition
94
+ route.otto = self
95
+ path_clean = path.gsub(%r{/$}, '')
96
+ @route_definitions[route.definition] = route
80
97
  Otto.logger.debug "route: #{route.pattern}" if Otto.debug
81
- @routes[route.verb] ||= []
98
+ @routes[route.verb] ||= []
82
99
  @routes[route.verb] << route
83
- @routes_literal[route.verb] ||= {}
100
+ @routes_literal[route.verb] ||= {}
84
101
  @routes_literal[route.verb][path_clean] = route
85
102
  rescue StandardError
86
103
  Otto.logger.error "Bad route in #{path}: #{entry}"
@@ -97,7 +114,7 @@ class Otto
97
114
  return false unless File.directory?(public_dir)
98
115
 
99
116
  # Clean the requested path - remove null bytes and normalize
100
- clean_path = path.gsub("\0", "").strip
117
+ clean_path = path.delete("\0").strip
101
118
  return false if clean_path.empty?
102
119
 
103
120
  # Join and expand to get the full resolved path
@@ -111,13 +128,13 @@ class Otto
111
128
  File.readable?(requested_path) &&
112
129
  !File.directory?(requested_path) &&
113
130
  (File.owned?(requested_path) || File.grpowned?(requested_path))
114
- end
131
+ end
115
132
 
116
133
  def safe_dir?(path)
117
134
  return false if path.nil? || path.empty?
118
135
 
119
136
  # Clean and expand the path
120
- clean_path = path.gsub("\0", "").strip
137
+ clean_path = path.delete("\0").strip
121
138
  return false if clean_path.empty?
122
139
 
123
140
  expanded_path = File.expand_path(clean_path)
@@ -131,9 +148,9 @@ end
131
148
  def add_static_path(path)
132
149
  return unless safe_file?(path)
133
150
 
134
- base_path = File.split(path).first
151
+ base_path = File.split(path).first
135
152
  # Files in the root directory can refer to themselves
136
- base_path = path if base_path == '/'
153
+ base_path = path if base_path == '/'
137
154
  File.join(option[:public], base_path)
138
155
  Otto.logger.debug "new static route: #{base_path} (#{path})" if Otto.debug
139
156
  routes_static[:GET][base_path] = base_path
@@ -141,46 +158,47 @@ end
141
158
 
142
159
  def call(env)
143
160
  # Apply middleware stack
144
- app = lambda { |e| handle_request(e) }
145
- @middleware_stack.reverse.each do |middleware|
161
+ app = ->(e) { handle_request(e) }
162
+ @middleware_stack.reverse_each do |middleware|
146
163
  app = middleware.new(app, @security_config)
147
164
  end
148
165
 
149
166
  begin
150
167
  app.call(env)
151
- rescue StandardError => e
152
- handle_error(e, env)
168
+ rescue StandardError => ex
169
+ handle_error(ex, env)
153
170
  end
154
171
  end
155
172
 
156
173
  def handle_request(env)
157
- locale = determine_locale env
174
+ locale = determine_locale env
158
175
  env['rack.locale'] = locale
159
- @static_route ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
160
- path_info = Rack::Utils.unescape(env['PATH_INFO'])
161
- path_info = '/' if path_info.to_s.empty?
176
+ env['otto.locale_config'] = @locale_config if @locale_config
177
+ @static_route ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
178
+ path_info = Rack::Utils.unescape(env['PATH_INFO'])
179
+ path_info = '/' if path_info.to_s.empty?
162
180
 
163
181
  begin
164
182
  path_info_clean = path_info
165
- .encode(
166
- 'UTF-8', # Target encoding
167
- invalid: :replace, # Replace invalid byte sequences
168
- undef: :replace, # Replace characters undefined in UTF-8
169
- replace: '' # Use empty string for replacement
170
- )
171
- .gsub(%r{/$}, '') # Remove trailing slash, if present
172
- rescue ArgumentError => e
183
+ .encode(
184
+ 'UTF-8', # Target encoding
185
+ invalid: :replace, # Replace invalid byte sequences
186
+ undef: :replace, # Replace characters undefined in UTF-8
187
+ replace: '', # Use empty string for replacement
188
+ )
189
+ .gsub(%r{/$}, '') # Remove trailing slash, if present
190
+ rescue ArgumentError => ex
173
191
  # Log the error but don't expose details
174
- Otto.logger.error "[Otto.handle_request] Path encoding error"
175
- Otto.logger.debug "[Otto.handle_request] Error details: #{e.message}" if Otto.debug
192
+ Otto.logger.error '[Otto.handle_request] Path encoding error'
193
+ Otto.logger.debug "[Otto.handle_request] Error details: #{ex.message}" if Otto.debug
176
194
  # Set a default value or use the original path_info
177
195
  path_info_clean = path_info
178
196
  end
179
197
 
180
- base_path = File.split(path_info).first
198
+ base_path = File.split(path_info).first
181
199
  # Files in the root directory can refer to themselves
182
- base_path = path_info if base_path == '/'
183
- http_verb = env['REQUEST_METHOD'].upcase.to_sym
200
+ base_path = path_info if base_path == '/'
201
+ http_verb = env['REQUEST_METHOD'].upcase.to_sym
184
202
  literal_routes = routes_literal[http_verb] || {}
185
203
  literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
186
204
  if static_route && http_verb == :GET && routes_static[:GET].member?(base_path)
@@ -195,15 +213,15 @@ end
195
213
  routes_static[:GET][base_path] = base_path
196
214
  static_route.call(env)
197
215
  else
198
- extra_params = {}
199
- found_route = nil
200
- valid_routes = routes[http_verb] || []
216
+ extra_params = {}
217
+ found_route = nil
218
+ valid_routes = routes[http_verb] || []
201
219
  valid_routes.push(*routes[:GET]) if http_verb == :HEAD
202
220
  valid_routes.each do |route|
203
221
  # Otto.logger.debug " request: #{http_verb} #{path_info} (trying route: #{route.verb} #{route.pattern})"
204
222
  next unless (match = route.pattern.match(path_info))
205
223
 
206
- values = match.captures.to_a
224
+ values = match.captures.to_a
207
225
  # The first capture returned is the entire matched string b/c
208
226
  # we wrapped the entire regex in parens. We don't need it to
209
227
  # the full match.
@@ -222,7 +240,7 @@ end
222
240
  else
223
241
  {}
224
242
  end
225
- found_route = route
243
+ found_route = route
226
244
  break
227
245
  end
228
246
  found_route ||= literal_routes['/404']
@@ -245,7 +263,7 @@ end
245
263
  return if route.nil?
246
264
 
247
265
  local_params = params.clone
248
- local_path = route.path.clone
266
+ local_path = route.path.clone
249
267
 
250
268
  local_params.each_pair do |k, v|
251
269
  next unless local_path.match(":#{k}")
@@ -253,16 +271,19 @@ end
253
271
  local_path.gsub!(":#{k}", v.to_s)
254
272
  local_params.delete(k)
255
273
  end
256
- uri = Addressable::URI.new
257
- uri.path = local_path
258
- uri.query_values = local_params unless local_params.empty?
274
+
275
+ uri = URI::HTTP.new(nil, nil, nil, nil, nil, local_path, nil, nil, nil)
276
+ unless local_params.empty?
277
+ query_string = local_params.map { |k, v| "#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v)}" }.join('&')
278
+ uri.query = query_string
279
+ end
259
280
  uri.to_s
260
281
  end
261
282
 
262
283
  def determine_locale(env)
263
284
  accept_langs = env['HTTP_ACCEPT_LANGUAGE']
264
285
  accept_langs = option[:locale] if accept_langs.to_s.empty?
265
- locales = []
286
+ locales = []
266
287
  unless accept_langs.empty?
267
288
  locales = accept_langs.split(',').map do |l|
268
289
  l += ';q=1.0' unless /;q=\d+(?:\.\d+)?$/.match?(l)
@@ -282,7 +303,7 @@ end
282
303
  # @param middleware [Class] The middleware class to add
283
304
  # @param args [Array] Additional arguments for the middleware
284
305
  # @param block [Proc] Optional block for middleware configuration
285
- def use(middleware, *args, &block)
306
+ def use(middleware, *, &)
286
307
  @middleware_stack << middleware
287
308
  end
288
309
 
@@ -343,7 +364,7 @@ end
343
364
  # @param include_subdomains [Boolean] Apply to all subdomains (default: true)
344
365
  # @example
345
366
  # otto.enable_hsts!(max_age: 86400, include_subdomains: false)
346
- def enable_hsts!(max_age: 31536000, include_subdomains: true)
367
+ def enable_hsts!(max_age: 31_536_000, include_subdomains: true)
347
368
  @security_config.enable_hsts!(max_age: max_age, include_subdomains: include_subdomains)
348
369
  end
349
370
 
@@ -366,8 +387,64 @@ end
366
387
  @security_config.enable_frame_protection!(option)
367
388
  end
368
389
 
390
+ # Enable Content Security Policy (CSP) with nonce support for dynamic header generation.
391
+ # This enables the res.send_csp_headers response helper method.
392
+ #
393
+ # @param debug [Boolean] Enable debug logging for CSP headers (default: false)
394
+ # @example
395
+ # otto.enable_csp_with_nonce!(debug: true)
396
+ def enable_csp_with_nonce!(debug: false)
397
+ @security_config.enable_csp_with_nonce!(debug: debug)
398
+ end
399
+
400
+ # Configure locale settings for the application
401
+ #
402
+ # @param available_locales [Hash] Hash of available locales (e.g., { 'en' => 'English', 'es' => 'Spanish' })
403
+ # @param default_locale [String] Default locale to use as fallback
404
+ # @example
405
+ # otto.configure(
406
+ # available_locales: { 'en' => 'English', 'es' => 'Spanish', 'fr' => 'French' },
407
+ # default_locale: 'en'
408
+ # )
409
+ def configure(available_locales: nil, default_locale: nil)
410
+ @locale_config ||= {}
411
+ @locale_config[:available_locales] = available_locales if available_locales
412
+ @locale_config[:default_locale] = default_locale if default_locale
413
+ end
414
+
369
415
  private
370
416
 
417
+ def configure_locale(opts)
418
+ # Start with global configuration
419
+ global_config = self.class.global_config
420
+ @locale_config = nil
421
+
422
+ # Check if we have any locale configuration from any source
423
+ has_global_locale = global_config && (global_config[:available_locales] || global_config[:default_locale])
424
+ has_direct_options = opts[:available_locales] || opts[:default_locale]
425
+ has_legacy_config = opts[:locale_config]
426
+
427
+ # Only create locale_config if we have configuration from somewhere
428
+ if has_global_locale || has_direct_options || has_legacy_config
429
+ @locale_config = {}
430
+
431
+ # Apply global configuration first
432
+ @locale_config[:available_locales] = global_config[:available_locales] if global_config && global_config[:available_locales]
433
+ @locale_config[:default_locale] = global_config[:default_locale] if global_config && global_config[:default_locale]
434
+
435
+ # Apply direct instance options (these override global config)
436
+ @locale_config[:available_locales] = opts[:available_locales] if opts[:available_locales]
437
+ @locale_config[:default_locale] = opts[:default_locale] if opts[:default_locale]
438
+
439
+ # Legacy support: Configure locale if provided in initialization options via locale_config hash
440
+ if opts[:locale_config]
441
+ locale_opts = opts[:locale_config]
442
+ @locale_config[:available_locales] = locale_opts[:available_locales] || locale_opts[:available] if locale_opts[:available_locales] || locale_opts[:available]
443
+ @locale_config[:default_locale] = locale_opts[:default_locale] || locale_opts[:default] if locale_opts[:default_locale] || locale_opts[:default]
444
+ end
445
+ end
446
+ end
447
+
371
448
  def configure_security(opts)
372
449
  # Enable CSRF protection if requested
373
450
  enable_csrf_protection! if opts[:csrf_protection]
@@ -396,8 +473,12 @@ end
396
473
  Otto.logger.error "[#{error_id}] #{error.class}: #{error.message}"
397
474
  Otto.logger.debug "[#{error_id}] Backtrace: #{error.backtrace.join("\n")}" if Otto.debug
398
475
 
399
- # Check for custom error routes
400
- request = Rack::Request.new(env) rescue nil
476
+ # Parse request for content negotiation
477
+ begin
478
+ Rack::Request.new(env)
479
+ rescue StandardError
480
+ nil
481
+ end
401
482
  literal_routes = @routes_literal[:GET] || {}
402
483
 
403
484
  # Try custom 500 route first
@@ -405,11 +486,17 @@ end
405
486
  begin
406
487
  env['otto.error_id'] = error_id
407
488
  return found_route.call(env)
408
- rescue StandardError => route_error
409
- Otto.logger.error "[#{error_id}] Error in custom error handler: #{route_error.message}"
489
+ rescue StandardError => ex
490
+ Otto.logger.error "[#{error_id}] Error in custom error handler: #{ex.message}"
410
491
  end
411
492
  end
412
493
 
494
+ # Content negotiation for built-in error response
495
+ accept_header = env['HTTP_ACCEPT'].to_s
496
+ if accept_header.include?('application/json')
497
+ return json_error_response(error_id)
498
+ end
499
+
413
500
  # Fallback to built-in error response
414
501
  @server_error || secure_error_response(error_id)
415
502
  end
@@ -418,12 +505,35 @@ end
418
505
  body = if Otto.env?(:dev, :development)
419
506
  "Server error (ID: #{error_id}). Check logs for details."
420
507
  else
421
- "An error occurred. Please try again later."
508
+ 'An error occurred. Please try again later.'
422
509
  end
423
510
 
424
511
  headers = {
425
512
  'content-type' => 'text/plain',
426
- 'content-length' => body.bytesize.to_s
513
+ 'content-length' => body.bytesize.to_s,
514
+ }.merge(@security_config.security_headers)
515
+
516
+ [500, headers, [body]]
517
+ end
518
+
519
+ def json_error_response(error_id)
520
+ error_data = if Otto.env?(:dev, :development)
521
+ {
522
+ error: 'Internal Server Error',
523
+ message: 'Server error occurred. Check logs for details.',
524
+ error_id: error_id,
525
+ }
526
+ else
527
+ {
528
+ error: 'Internal Server Error',
529
+ message: 'An error occurred. Please try again later.',
530
+ }
531
+ end
532
+
533
+ body = JSON.generate(error_data)
534
+ headers = {
535
+ 'content-type' => 'application/json',
536
+ 'content-length' => body.bytesize.to_s,
427
537
  }.merge(@security_config.security_headers)
428
538
 
429
539
  [500, headers, [body]]
data/otto.gemspec CHANGED
@@ -3,25 +3,24 @@
3
3
  require_relative 'lib/otto/version'
4
4
 
5
5
  Gem::Specification.new do |spec|
6
- spec.name = 'otto'
7
- spec.version = Otto::VERSION.to_s
8
- spec.summary = 'Auto-define your rack-apps in plaintext.'
9
- spec.description = "Otto: #{spec.summary}"
10
- spec.email = 'gems@solutious.com'
11
- spec.authors = ['Delano Mandelbaum']
12
- spec.license = 'MIT'
13
- spec.files = Dir.chdir(File.expand_path(__dir__)) do
6
+ spec.name = 'otto'
7
+ spec.version = Otto::VERSION.to_s
8
+ spec.summary = 'Auto-define your rack-apps in plaintext.'
9
+ spec.description = "Otto: #{spec.summary}"
10
+ spec.email = 'gems@solutious.com'
11
+ spec.authors = ['Delano Mandelbaum']
12
+ spec.license = 'MIT'
13
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
14
14
  `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
15
15
  end
16
- spec.homepage = 'https://github.com/delano/otto'
16
+ spec.homepage = 'https://github.com/delano/otto'
17
17
  spec.require_paths = ['lib']
18
18
 
19
- spec.required_ruby_version = ['>= 3.4', '< 4.0']
19
+ spec.required_ruby_version = ['>= 3.2', '< 4.0']
20
20
 
21
21
  # https://github.com/delano/otto/security/dependabot/5
22
22
  spec.add_dependency 'rexml', '>= 3.3.6'
23
-
24
- spec.add_dependency 'addressable', '~> 2.2', '< 3'
23
+ spec.add_dependency 'ostruct'
25
24
  spec.add_dependency 'rack', '~> 3.1', '< 4.0'
26
25
  spec.add_dependency 'rack-parser', '~> 0.7'
27
26
  spec.metadata['rubygems_mfa_required'] = 'true'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: otto
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
@@ -24,25 +24,19 @@ dependencies:
24
24
  - !ruby/object:Gem::Version
25
25
  version: 3.3.6
26
26
  - !ruby/object:Gem::Dependency
27
- name: addressable
27
+ name: ostruct
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
- - - "~>"
31
- - !ruby/object:Gem::Version
32
- version: '2.2'
33
- - - "<"
30
+ - - ">="
34
31
  - !ruby/object:Gem::Version
35
- version: '3'
32
+ version: '0'
36
33
  type: :runtime
37
34
  prerelease: false
38
35
  version_requirements: !ruby/object:Gem::Requirement
39
36
  requirements:
40
- - - "~>"
41
- - !ruby/object:Gem::Version
42
- version: '2.2'
43
- - - "<"
37
+ - - ">="
44
38
  - !ruby/object:Gem::Version
45
- version: '3'
39
+ version: '0'
46
40
  - !ruby/object:Gem::Dependency
47
41
  name: rack
48
42
  requirement: !ruby/object:Gem::Requirement
@@ -83,26 +77,32 @@ executables: []
83
77
  extensions: []
84
78
  extra_rdoc_files: []
85
79
  files:
80
+ - ".github/dependabot.yml"
81
+ - ".github/workflows/ci.yml"
86
82
  - ".gitignore"
83
+ - ".pre-commit-config.yaml"
84
+ - ".pre-push-config.yaml"
87
85
  - ".rspec"
88
86
  - ".rubocop.yml"
89
- - ".rubocop_todo.yml"
90
87
  - Gemfile
91
88
  - Gemfile.lock
92
89
  - LICENSE.txt
93
90
  - README.md
94
- - VERSION.yml
95
91
  - examples/basic/app.rb
96
92
  - examples/basic/config.ru
97
93
  - examples/basic/routes
98
94
  - examples/dynamic_pages/app.rb
99
95
  - examples/dynamic_pages/config.ru
100
96
  - examples/dynamic_pages/routes
97
+ - examples/helpers_demo/app.rb
98
+ - examples/helpers_demo/config.ru
99
+ - examples/helpers_demo/routes
101
100
  - examples/security_features/app.rb
102
101
  - examples/security_features/config.ru
103
102
  - examples/security_features/routes
104
103
  - lib/otto.rb
105
104
  - lib/otto/design_system.rb
105
+ - lib/otto/helpers/base.rb
106
106
  - lib/otto/helpers/request.rb
107
107
  - lib/otto/helpers/response.rb
108
108
  - lib/otto/route.rb
@@ -126,7 +126,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
126
126
  requirements:
127
127
  - - ">="
128
128
  - !ruby/object:Gem::Version
129
- version: '3.4'
129
+ version: '3.2'
130
130
  - - "<"
131
131
  - !ruby/object:Gem::Version
132
132
  version: '4.0'