otto 1.3.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.
@@ -1,7 +1,11 @@
1
1
  # lib/otto/helpers/request.rb
2
2
 
3
+ require_relative 'base'
4
+
3
5
  class Otto
4
6
  module RequestHelpers
7
+ include Otto::BaseHelpers
8
+
5
9
  def user_agent
6
10
  env['HTTP_USER_AGENT']
7
11
  end
@@ -70,7 +74,11 @@ class Otto
70
74
  ip = client_ipaddress
71
75
  return false unless ip
72
76
 
73
- local_or_private_ip?(ip)
77
+ # Check both IP and server name for comprehensive localhost detection
78
+ server_name = env['SERVER_NAME']
79
+ local_server_names = ['localhost', '127.0.0.1', '0.0.0.0']
80
+
81
+ local_or_private_ip?(ip) && local_server_names.include?(server_name)
74
82
  end
75
83
 
76
84
  def secure?
@@ -107,8 +115,6 @@ class Otto
107
115
  [prefix, http_host, request_path].join
108
116
  end
109
117
 
110
- private
111
-
112
118
  def otto_security_config
113
119
  # Try to get security config from various sources
114
120
  if respond_to?(:otto) && otto.respond_to?(:security_config)
@@ -136,7 +142,7 @@ class Otto
136
142
 
137
143
  # Validate each octet
138
144
  octets = clean_ip.split('.')
139
- return nil unless octets.all? { |octet| (0..255).include?(octet.to_i) }
145
+ return nil unless octets.all? { |octet| (0..255).cover?(octet.to_i) }
140
146
 
141
147
  clean_ip
142
148
  end
@@ -166,5 +172,218 @@ class Otto
166
172
  # Check for private IP ranges
167
173
  private_ip?(ip)
168
174
  end
175
+
176
+ # Collect and format HTTP header details from the request environment
177
+ #
178
+ # This method extracts and formats specific HTTP headers, including
179
+ # Cloudflare and proxy-related headers, for logging and debugging purposes.
180
+ #
181
+ # @param header_prefix [String, nil] Custom header prefix to include (e.g. 'X_SECRET_')
182
+ # @param additional_keys [Array<String>] Additional header keys to collect
183
+ # @return [String] Formatted header details as "key: value" pairs
184
+ #
185
+ # @example Basic usage
186
+ # collect_proxy_headers
187
+ # # => "X-Forwarded-For: 203.0.113.195 Remote-Addr: 192.0.2.1"
188
+ #
189
+ #
190
+ # @example With custom prefix
191
+ # collect_proxy_headers(header_prefix: 'X_CUSTOM_')
192
+ # # => "X-Forwarded-For: 203.0.113.195 X-Custom-Token: abc123"
193
+ def collect_proxy_headers(header_prefix: nil, additional_keys: [])
194
+ keys = %w[
195
+ HTTP_FLY_REQUEST_ID
196
+ HTTP_VIA
197
+ HTTP_X_FORWARDED_PROTO
198
+ HTTP_X_FORWARDED_FOR
199
+ HTTP_X_FORWARDED_HOST
200
+ HTTP_X_FORWARDED_PORT
201
+ HTTP_X_SCHEME
202
+ HTTP_X_REAL_IP
203
+ HTTP_CF_IPCOUNTRY
204
+ HTTP_CF_RAY
205
+ REMOTE_ADDR
206
+ ]
207
+
208
+ # Add any header that begins with the specified prefix
209
+ if header_prefix
210
+ prefix_keys = env.keys.select { |key| key.upcase.start_with?("HTTP_#{header_prefix.upcase}") }
211
+ keys.concat(prefix_keys)
212
+ end
213
+
214
+ # Add any additional keys requested
215
+ keys.concat(additional_keys) if additional_keys.any?
216
+
217
+ keys.sort.filter_map do |key|
218
+ value = env[key]
219
+ next unless value
220
+
221
+ # Normalize the header name to look like browser dev console
222
+ # e.g. Content-Type instead of HTTP_CONTENT_TYPE
223
+ pretty_name = key.sub(/^HTTP_/, '').split('_').map(&:capitalize).join('-')
224
+ "#{pretty_name}: #{value}"
225
+ end.join(' ')
226
+ end
227
+
228
+ # Format request details as a single string for logging
229
+ #
230
+ # This method combines IP address, HTTP method, path, query parameters,
231
+ # and proxy header details into a single formatted string suitable for logging.
232
+ #
233
+ # @param header_prefix [String, nil] Custom header prefix for proxy headers
234
+ # @return [String] Formatted request details
235
+ #
236
+ # @example
237
+ # format_request_details
238
+ # # => "192.0.2.1; GET /path?query=string; Proxy[X-Forwarded-For: 203.0.113.195 Remote-Addr: 192.0.2.1]"
239
+ #
240
+ def format_request_details(header_prefix: nil)
241
+ header_details = collect_proxy_headers(header_prefix: header_prefix)
242
+
243
+ details = [
244
+ client_ipaddress,
245
+ "#{request_method} #{env['PATH_INFO']}?#{env['QUERY_STRING']}",
246
+ "Proxy[#{header_details}]",
247
+ ]
248
+
249
+ details.join('; ')
250
+ end
251
+
252
+ # Check if user agent matches blocked patterns
253
+ #
254
+ # This method checks if the current request's user agent string
255
+ # matches any of the provided blocked agent patterns.
256
+ #
257
+ # @param blocked_agents [Array<String, Symbol, Regexp>] Patterns to check against
258
+ # @return [Boolean] true if user agent is allowed, false if blocked
259
+ #
260
+ # @example
261
+ # blocked_user_agent?([:bot, :crawler, 'BadAgent'])
262
+ # # => false if user agent contains 'bot', 'crawler', or 'BadAgent'
263
+ def blocked_user_agent?(blocked_agents: [])
264
+ return true if blocked_agents.empty?
265
+
266
+ user_agent_string = user_agent.to_s.downcase
267
+ return true if user_agent_string.empty?
268
+
269
+ blocked_agents.flatten.any? do |agent|
270
+ case agent
271
+ when Regexp
272
+ user_agent_string.match?(agent)
273
+ else
274
+ user_agent_string.include?(agent.to_s.downcase)
275
+ end
276
+ end
277
+ end
278
+
279
+ # Build application path by joining path segments
280
+ #
281
+ # This method safely joins multiple path segments, handling
282
+ # duplicate slashes and ensuring proper path formatting.
283
+ # Includes the script name (mount point) as the first segment.
284
+ #
285
+ # @param paths [Array<String>] Path segments to join
286
+ # @return [String] Properly formatted path
287
+ #
288
+ # @example
289
+ # app_path('api', 'v1', 'users')
290
+ # # => "/myapp/api/v1/users"
291
+ #
292
+ # @example
293
+ # app_path(['admin', 'settings'])
294
+ # # => "/myapp/admin/settings"
295
+ def app_path(*paths)
296
+ paths = paths.flatten.compact
297
+ paths.unshift(env['SCRIPT_NAME']) if env['SCRIPT_NAME']
298
+ paths.join('/').gsub('//', '/')
299
+ end
300
+
301
+ # Set the locale for the request based on multiple sources
302
+ #
303
+ # This method determines the locale to be used for the request by checking
304
+ # the following sources in order of precedence:
305
+ # 1. The locale parameter passed to the method
306
+ # 2. The locale query parameter in the request
307
+ # 3. The user's saved locale preference (if provided)
308
+ # 4. The rack.locale environment variable
309
+ #
310
+ # If a valid locale is found, it's stored in the request environment.
311
+ # If no valid locale is found, the default locale is used.
312
+ #
313
+ # @param locale [String, nil] The locale to use, if specified
314
+ # @param opts [Hash] Configuration options
315
+ # @option opts [Hash] :available_locales Hash of available locales to validate against (required unless configured at Otto level)
316
+ # @option opts [String] :default_locale Default locale to use as fallback (required unless configured at Otto level)
317
+ # @option opts [String, nil] :preferred_locale User's saved locale preference
318
+ # @option opts [String] :locale_env_key Environment key to store the locale (default: 'locale')
319
+ # @option opts [Boolean] :debug Enable debug logging for locale selection
320
+ # @return [String] The selected locale
321
+ #
322
+ # @example Basic usage
323
+ # check_locale!(
324
+ # available_locales: { 'en' => 'English', 'es' => 'Spanish' },
325
+ # default_locale: 'en'
326
+ # )
327
+ # # => 'en'
328
+ #
329
+ # @example With user preference
330
+ # check_locale!(nil, {
331
+ # available_locales: { 'en' => 'English', 'es' => 'Spanish' },
332
+ # default_locale: 'en',
333
+ # preferred_locale: 'es'
334
+ # })
335
+ # # => 'es'
336
+ #
337
+ # @example Using Otto-level configuration
338
+ # # Otto configured with: Otto.new(routes, { locale_config: { available: {...}, default: 'en' } })
339
+ # check_locale!('es') # Uses Otto's config automatically
340
+ # # => 'es'
341
+ #
342
+ def check_locale!(locale = nil, opts = {})
343
+ # Get configuration from options, Otto config, or environment (in that order)
344
+ otto_config = env['otto.locale_config']
345
+
346
+ available_locales = opts[:available_locales] ||
347
+ otto_config&.dig(:available_locales) ||
348
+ env['otto.available_locales']
349
+ default_locale = opts[:default_locale] ||
350
+ otto_config&.dig(:default_locale) ||
351
+ env['otto.default_locale']
352
+ preferred_locale = opts[:preferred_locale]
353
+ locale_env_key = opts[:locale_env_key] || 'locale'
354
+ debug_enabled = opts[:debug] || false
355
+
356
+ # Guard clause - required configuration must be present
357
+ unless available_locales && default_locale
358
+ raise ArgumentError, 'available_locales and default_locale are required (provide via opts or Otto configuration)'
359
+ end
360
+
361
+ # Check sources in order of precedence
362
+ locale ||= env['rack.request.query_hash'] && env['rack.request.query_hash']['locale']
363
+ locale ||= preferred_locale if preferred_locale
364
+ locale ||= (env['rack.locale'] || []).first
365
+
366
+ # Validate locale against available translations
367
+ have_translations = locale && available_locales.key?(locale.to_s)
368
+
369
+ # Debug logging if enabled
370
+ if debug_enabled && defined?(Otto.logger)
371
+ message = format(
372
+ '[check_locale!] sources[param=%s query=%s user=%s rack=%s] valid=%s',
373
+ locale,
374
+ env.dig('rack.request.query_hash', 'locale'),
375
+ preferred_locale,
376
+ (env['rack.locale'] || []).first,
377
+ have_translations
378
+ )
379
+ Otto.logger.debug message
380
+ end
381
+
382
+ # Set the locale in request environment
383
+ selected_locale = have_translations ? locale : default_locale
384
+ env[locale_env_key] = selected_locale
385
+
386
+ selected_locale
387
+ end
169
388
  end
170
389
  end
@@ -1,7 +1,11 @@
1
1
  # lib/otto/helpers/response.rb
2
2
 
3
+ require_relative 'base'
4
+
3
5
  class Otto
4
6
  module ResponseHelpers
7
+ include Otto::BaseHelpers
8
+
5
9
  attr_accessor :request
6
10
 
7
11
  def send_secure_cookie(name, value, ttl, opts = {})
@@ -76,5 +80,76 @@ class Otto
76
80
 
77
81
  headers
78
82
  end
83
+
84
+ # Set Content Security Policy (CSP) headers with nonce support
85
+ #
86
+ # This method generates and sets CSP headers with the provided nonce value,
87
+ # following the same usage pattern as send_cookie methods. The CSP policy
88
+ # is generated dynamically based on the security configuration and environment.
89
+ #
90
+ # @param content_type [String] Content-Type header value to set
91
+ # @param nonce [String] Nonce value to include in CSP directives
92
+ # @param opts [Hash] Options for CSP generation
93
+ # @option opts [Otto::Security::Config] :security_config Security config to use
94
+ # @option opts [Boolean] :development_mode Use development-friendly CSP directives
95
+ # @option opts [Boolean] :debug Enable debug logging for this request
96
+ # @return [void]
97
+ #
98
+ # @example Basic usage
99
+ # nonce = SecureRandom.base64(16)
100
+ # res.send_csp_headers('text/html; charset=utf-8', nonce)
101
+ #
102
+ # @example With options
103
+ # res.send_csp_headers('text/html; charset=utf-8', nonce, {
104
+ # development_mode: Rails.env.development?,
105
+ # debug: true
106
+ # })
107
+ def send_csp_headers(content_type, nonce, opts = {})
108
+ # Set content type if not already set
109
+ headers['content-type'] ||= content_type
110
+
111
+ # Warn if CSP header already exists but don't skip
112
+ if headers['content-security-policy']
113
+ warn 'CSP header already set, overriding with nonce-based policy'
114
+ end
115
+
116
+ # Get security configuration
117
+ security_config = opts[:security_config] ||
118
+ (request&.env && request.env['otto.security_config']) ||
119
+ nil
120
+
121
+ # Skip if CSP nonce support is not enabled
122
+ return unless security_config&.csp_nonce_enabled?
123
+
124
+ # Generate CSP policy with nonce
125
+ development_mode = opts[:development_mode] || false
126
+ csp_policy = security_config.generate_nonce_csp(nonce, development_mode: development_mode)
127
+
128
+ # Debug logging if enabled
129
+ debug_enabled = opts[:debug] || security_config.debug_csp?
130
+ if debug_enabled && defined?(Otto.logger)
131
+ Otto.logger.debug "[CSP] #{csp_policy}"
132
+ end
133
+
134
+ # Set the CSP header
135
+ headers['content-security-policy'] = csp_policy
136
+ end
137
+
138
+ # Set cache control headers to prevent caching
139
+ #
140
+ # This method sets comprehensive cache control headers to ensure that
141
+ # the response is not cached by browsers, proxies, or CDNs. This is
142
+ # particularly useful for sensitive pages or dynamic content that
143
+ # should always be fresh.
144
+ #
145
+ # @return [void]
146
+ #
147
+ # @example
148
+ # res.no_cache!
149
+ def no_cache!
150
+ headers['cache-control'] = 'no-store, no-cache, must-revalidate, max-age=0'
151
+ headers['expires'] = 'Mon, 7 Nov 2011 00:00:00 UTC'
152
+ headers['pragma'] = 'no-cache'
153
+ end
79
154
  end
80
155
  end
data/lib/otto/route.rb CHANGED
@@ -115,6 +115,11 @@ class Otto
115
115
  res.extend Otto::ResponseHelpers
116
116
  res.request = req
117
117
 
118
+ # Make security config available to response helpers
119
+ if otto.respond_to?(:security_config) && otto.security_config
120
+ env['otto.security_config'] = otto.security_config
121
+ end
122
+
118
123
  # Process parameters through security layer
119
124
  req.params.merge! extra_params
120
125
  req.params.replace Otto::Static.indifferent_params(req.params)
@@ -24,7 +24,8 @@ class Otto
24
24
  attr_accessor :csrf_protection, :csrf_token_key, :csrf_header_key, :csrf_session_key,
25
25
  :max_request_size, :max_param_depth, :max_param_keys,
26
26
  :trusted_proxies, :require_secure_cookies,
27
- :security_headers, :input_validation
27
+ :security_headers, :input_validation,
28
+ :csp_nonce_enabled, :debug_csp
28
29
 
29
30
  # Initialize security configuration with safe defaults
30
31
  #
@@ -42,6 +43,8 @@ class Otto
42
43
  @require_secure_cookies = false
43
44
  @security_headers = default_security_headers
44
45
  @input_validation = true
46
+ @csp_nonce_enabled = false
47
+ @debug_csp = false
45
48
  end
46
49
 
47
50
  # Enable CSRF (Cross-Site Request Forgery) protection
@@ -199,6 +202,53 @@ class Otto
199
202
  @security_headers['content-security-policy'] = policy
200
203
  end
201
204
 
205
+ # Enable Content Security Policy (CSP) with nonce support
206
+ #
207
+ # This enables dynamic CSP header generation with nonces for enhanced security.
208
+ # Unlike enable_csp!, this doesn't set a static policy but enables the response
209
+ # helper to generate CSP headers with nonces on a per-request basis.
210
+ #
211
+ # @param debug [Boolean] Enable debug logging for CSP headers (default: false)
212
+ # @return [void]
213
+ #
214
+ # @example
215
+ # config.enable_csp_with_nonce!(debug: true)
216
+ def enable_csp_with_nonce!(debug: false)
217
+ @csp_nonce_enabled = true
218
+ @debug_csp = debug
219
+ end
220
+
221
+ # Disable CSP nonce support
222
+ #
223
+ # @return [void]
224
+ def disable_csp_nonce!
225
+ @csp_nonce_enabled = false
226
+ end
227
+
228
+ # Check if CSP nonce support is enabled
229
+ #
230
+ # @return [Boolean] true if CSP nonce support is enabled
231
+ def csp_nonce_enabled?
232
+ @csp_nonce_enabled
233
+ end
234
+
235
+ # Check if CSP debug logging is enabled
236
+ #
237
+ # @return [Boolean] true if CSP debug logging is enabled
238
+ def debug_csp?
239
+ @debug_csp
240
+ end
241
+
242
+ # Generate a CSP policy string with the provided nonce
243
+ #
244
+ # @param nonce [String] The nonce value to include in the CSP
245
+ # @param development_mode [Boolean] Whether to use development-friendly directives
246
+ # @return [String] Complete CSP policy string
247
+ def generate_nonce_csp(nonce, development_mode: false)
248
+ directives = development_mode ? development_csp_directives(nonce) : production_csp_directives(nonce)
249
+ directives.join(' ')
250
+ end
251
+
202
252
  # Enable X-Frame-Options header to prevent clickjacking
203
253
  #
204
254
  # @param option [String] Frame options: 'DENY', 'SAMEORIGIN', or 'ALLOW-FROM uri'
@@ -298,6 +348,54 @@ class Otto
298
348
  a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
299
349
  result == 0
300
350
  end
351
+
352
+ # Generate CSP directives for development environment
353
+ #
354
+ # Development mode allows inline scripts/styles and hot reloading connections
355
+ # for better developer experience with build tools like Vite.
356
+ #
357
+ # @param nonce [String] The nonce value to include in script-src
358
+ # @return [Array<String>] Array of CSP directive strings
359
+ def development_csp_directives(nonce)
360
+ [
361
+ "default-src 'none';",
362
+ "script-src 'nonce-#{nonce}' 'unsafe-inline';", # Allow inline scripts for development tools
363
+ "style-src 'self' 'unsafe-inline';",
364
+ "connect-src 'self' ws: wss: http: https:;", # Allow HTTP and all WebSocket connections for dev tools
365
+ "img-src 'self' data:;",
366
+ "font-src 'self';",
367
+ "object-src 'none';",
368
+ "base-uri 'self';",
369
+ "form-action 'self';",
370
+ "frame-ancestors 'none';",
371
+ "manifest-src 'self';",
372
+ "worker-src 'self' data:;",
373
+ ]
374
+ end
375
+
376
+ # Generate CSP directives for production environment
377
+ #
378
+ # Production mode is more restrictive, only allowing HTTPS connections
379
+ # and nonce-only scripts for enhanced XSS protection.
380
+ #
381
+ # @param nonce [String] The nonce value to include in script-src
382
+ # @return [Array<String>] Array of CSP directive strings
383
+ def production_csp_directives(nonce)
384
+ [
385
+ "default-src 'none';", # Restrict to same origin by default
386
+ "script-src 'nonce-#{nonce}';", # Only allow scripts with valid nonce
387
+ "style-src 'self' 'unsafe-inline';", # Allow inline styles and same-origin stylesheets
388
+ "connect-src 'self' wss: https:;", # Only HTTPS and secure WebSockets
389
+ "img-src 'self' data:;", # Allow images from same origin and data URIs
390
+ "font-src 'self';", # Allow fonts from same origin only
391
+ "object-src 'none';", # Block <object>, <embed>, and <applet> elements
392
+ "base-uri 'self';", # Restrict <base> tag targets to same origin
393
+ "form-action 'self';", # Restrict form submissions to same origin
394
+ "frame-ancestors 'none';", # Prevent site from being embedded in frames
395
+ "manifest-src 'self';", # Allow web app manifests from same origin
396
+ "worker-src 'self' data:;", # Allow Workers from same origin and data blobs
397
+ ]
398
+ end
301
399
  end
302
400
 
303
401
  # Raised when a request exceeds the configured size limit
data/lib/otto/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # lib/otto/version.rb
2
2
 
3
3
  class Otto
4
- VERSION = '1.3.0'.freeze
4
+ VERSION = '1.4.0'.freeze
5
5
  end
data/lib/otto.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require 'json'
2
2
  require 'logger'
3
+ require 'ostruct'
3
4
  require 'securerandom'
4
5
  require 'uri'
5
6
 
@@ -42,8 +43,20 @@ class Otto
42
43
 
43
44
  @debug = ENV['OTTO_DEBUG'] == 'true'
44
45
  @logger = Logger.new($stdout, Logger::INFO)
46
+ @global_config = {}
45
47
 
46
- 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
47
60
  attr_accessor :not_found, :server_error, :middleware_stack
48
61
 
49
62
  def initialize(path = nil, opts = {})
@@ -58,6 +71,9 @@ class Otto
58
71
  @security_config = Otto::Security::Config.new
59
72
  @middleware_stack = []
60
73
 
74
+ # Configure locale support (merge global config with instance options)
75
+ configure_locale(opts)
76
+
61
77
  # Configure security based on options
62
78
  configure_security(opts)
63
79
 
@@ -157,6 +173,7 @@ class Otto
157
173
  def handle_request(env)
158
174
  locale = determine_locale env
159
175
  env['rack.locale'] = locale
176
+ env['otto.locale_config'] = @locale_config if @locale_config
160
177
  @static_route ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
161
178
  path_info = Rack::Utils.unescape(env['PATH_INFO'])
162
179
  path_info = '/' if path_info.to_s.empty?
@@ -370,8 +387,64 @@ class Otto
370
387
  @security_config.enable_frame_protection!(option)
371
388
  end
372
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
+
373
415
  private
374
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
+
375
448
  def configure_security(opts)
376
449
  # Enable CSRF protection if requested
377
450
  enable_csrf_protection! if opts[:csrf_protection]
data/otto.gemspec CHANGED
@@ -16,11 +16,11 @@ Gem::Specification.new do |spec|
16
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
-
23
+ spec.add_dependency 'ostruct'
24
24
  spec.add_dependency 'rack', '~> 3.1', '< 4.0'
25
25
  spec.add_dependency 'rack-parser', '~> 0.7'
26
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.3.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: 3.3.6
26
+ - !ruby/object:Gem::Dependency
27
+ name: ostruct
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: rack
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -63,7 +77,11 @@ executables: []
63
77
  extensions: []
64
78
  extra_rdoc_files: []
65
79
  files:
80
+ - ".github/dependabot.yml"
81
+ - ".github/workflows/ci.yml"
66
82
  - ".gitignore"
83
+ - ".pre-commit-config.yaml"
84
+ - ".pre-push-config.yaml"
67
85
  - ".rspec"
68
86
  - ".rubocop.yml"
69
87
  - Gemfile
@@ -76,11 +94,15 @@ files:
76
94
  - examples/dynamic_pages/app.rb
77
95
  - examples/dynamic_pages/config.ru
78
96
  - examples/dynamic_pages/routes
97
+ - examples/helpers_demo/app.rb
98
+ - examples/helpers_demo/config.ru
99
+ - examples/helpers_demo/routes
79
100
  - examples/security_features/app.rb
80
101
  - examples/security_features/config.ru
81
102
  - examples/security_features/routes
82
103
  - lib/otto.rb
83
104
  - lib/otto/design_system.rb
105
+ - lib/otto/helpers/base.rb
84
106
  - lib/otto/helpers/request.rb
85
107
  - lib/otto/helpers/response.rb
86
108
  - lib/otto/route.rb
@@ -104,7 +126,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
104
126
  requirements:
105
127
  - - ">="
106
128
  - !ruby/object:Gem::Version
107
- version: '3.4'
129
+ version: '3.2'
108
130
  - - "<"
109
131
  - !ruby/object:Gem::Version
110
132
  version: '4.0'