Class: Otto::Request

Inherits:
Rack::Request
  • Object
show all
Defined in:
lib/otto/request.rb

Overview

Otto’s enhanced Rack::Request class with built-in helpers

This class extends Rack::Request with Otto’s framework helpers for HTTP request handling, privacy, security, and locale management. Projects can register additional helpers via Otto#register_request_helpers.

Examples:

Using Otto’s request in route handlers

def show(req, res)
  req.masked_ip      # Privacy-safe masked IP
  req.geo_country    # ISO country code
  req.check_locale!  # Set locale for request
end

See Also:

  • #register_request_helpers

Instance Method Summary collapse

Instance Method Details

#absolute_suri(host = current_server_name) ⇒ Object



176
177
178
179
# File 'lib/otto/request.rb', line 176

def absolute_suri(host = current_server_name)
  prefix = local? ? 'http://' : 'https://'
  [prefix, host, request_path].join
end

#ajax?Boolean

See: http://stackoverflow.com/questions/10013812/how-to-prevent-jquery-ajax-from-following-a-redirect-after-a-post

Returns:

  • (Boolean)


226
227
228
# File 'lib/otto/request.rb', line 226

def ajax?
  env['HTTP_X_REQUESTED_WITH'].to_s.downcase == 'xmlhttprequest'
end

#anonymized_user_agentString?

Deprecated.

Use env[‘HTTP_USER_AGENT’] directly (already anonymized when privacy enabled)

Get anonymized user agent string

Returns user agent with version numbers stripped for privacy. When privacy is enabled (default), env[‘HTTP_USER_AGENT’] is already anonymized by IPPrivacyMiddleware, so this just returns that value. When privacy is disabled, returns the raw user agent.

Examples:

req.anonymized_user_agent
# => 'Mozilla/X.X (Windows NT X.X; Win64; x64) AppleWebKit/X.X'

Returns:

  • (String, nil)

    Anonymized (or raw if privacy disabled) user agent



107
108
109
# File 'lib/otto/request.rb', line 107

def anonymized_user_agent
  user_agent
end

#app_path(*paths) ⇒ String

Build application path by joining path segments

This method safely joins multiple path segments, handling duplicate slashes and ensuring proper path formatting. Includes the script name (mount point) as the first segment.

Examples:

app_path('api', 'v1', 'users')
# => "/myapp/api/v1/users"
app_path(['admin', 'settings'])
# => "/myapp/admin/settings"

Parameters:

  • paths (Array<String>)

    Path segments to join

Returns:

  • (String)

    Properly formatted path



404
405
406
407
408
# File 'lib/otto/request.rb', line 404

def app_path(*paths)
  paths = paths.flatten.compact
  paths.unshift(env['SCRIPT_NAME']) if env['SCRIPT_NAME']
  paths.join('/').gsub('//', '/')
end

#blocked_user_agent?(blocked_agents: []) ⇒ Boolean

Check if user agent matches blocked patterns

This method checks if the current request’s user agent string matches any of the provided blocked agent patterns.

Examples:

blocked_user_agent?([:bot, :crawler, 'BadAgent'])
# => false if user agent contains 'bot', 'crawler', or 'BadAgent'

Parameters:

  • blocked_agents (Array<String, Symbol, Regexp>) (defaults to: [])

    Patterns to check against

Returns:

  • (Boolean)

    true if user agent is allowed, false if blocked



372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
# File 'lib/otto/request.rb', line 372

def blocked_user_agent?(blocked_agents: [])
  return true if blocked_agents.empty?

  user_agent_string = user_agent.to_s.downcase
  return true if user_agent_string.empty?

  blocked_agents.flatten.any? do |agent|
    case agent
    when Regexp
      user_agent_string.match?(agent)
    else
      user_agent_string.include?(agent.to_s.downcase)
    end
  end
end

#check_locale!(locale = nil, opts = {}) ⇒ String

Set the locale for the request based on multiple sources

This method determines the locale to be used for the request by checking the following sources in order of precedence: 1. The locale parameter passed to the method 2. The locale query parameter in the request 3. The user’s saved locale preference (if provided) 4. The rack.locale environment variable

If a valid locale is found, it’s stored in the request environment. If no valid locale is found, the default locale is used.

Examples:

Basic usage

check_locale!(
  available_locales: { 'en' => 'English', 'es' => 'Spanish' },
  default_locale: 'en'
)
# => 'en'

With user preference

check_locale!(nil, {
  available_locales: { 'en' => 'English', 'es' => 'Spanish' },
  default_locale: 'en',
  preferred_locale: 'es'
})
# => 'es'

Using Otto-level configuration

# Otto configured with: Otto.new(routes, { locale_config: { available: {...}, default: 'en' } })
check_locale!('es')  # Uses Otto's config automatically
# => 'es'

Parameters:

  • locale (String, nil) (defaults to: nil)

    The locale to use, if specified

  • opts (Hash) (defaults to: {})

    Configuration options

Options Hash (opts):

  • :available_locales (Hash)

    Hash of available locales to validate against (required unless configured at Otto level)

  • :default_locale (String)

    Default locale to use as fallback (required unless configured at Otto level)

  • :preferred_locale (String, nil)

    User’s saved locale preference

  • :locale_env_key (String)

    Environment key to store the locale (default: ‘locale’)

  • :debug (Boolean)

    Enable debug logging for locale selection

Returns:

  • (String)

    The selected locale



451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
# File 'lib/otto/request.rb', line 451

def check_locale!(locale = nil, opts = {})
  # Get configuration from options, Otto config, or environment (in that order)
  otto_config = env['otto.locale_config']

  available_locales = opts[:available_locales] ||
                      otto_config&.dig(:available_locales) ||
                      env['otto.available_locales']
  default_locale    = opts[:default_locale] ||
                      otto_config&.dig(:default_locale) ||
                      env['otto.default_locale']
  preferred_locale  = opts[:preferred_locale]
  locale_env_key    = opts[:locale_env_key] || 'locale'
  debug_enabled     = opts[:debug] || false

  # Guard clause - required configuration must be present
  unless available_locales.is_a?(Hash) && !available_locales.empty? && default_locale && available_locales.key?(default_locale)
    raise ArgumentError,
          'available_locales must be a non-empty Hash and include default_locale (provide via opts or Otto configuration)'
  end

  # Check sources in order of precedence
  locale ||= env['rack.request.query_hash'] && env['rack.request.query_hash']['locale']
  locale ||= preferred_locale if preferred_locale
  locale ||= (env['rack.locale'] || []).first

  # Validate locale against available translations
  have_translations = locale && available_locales.key?(locale.to_s)

  # Debug logging if enabled
  if debug_enabled && defined?(Otto.logger)
    message = format(
      '[check_locale!] sources[param=%s query=%s user=%s rack=%s] valid=%s',
      locale,
      env.dig('rack.request.query_hash', 'locale'),
      preferred_locale,
      (env['rack.locale'] || []).first,
      have_translations
    )
    Otto.logger.debug message
  end

  # Set the locale in request environment
  selected_locale = have_translations ? locale : default_locale
  env[locale_env_key] = selected_locale

  selected_locale
end

#client_ipaddressObject



136
137
138
139
140
141
142
143
144
145
146
# File 'lib/otto/request.rb', line 136

def client_ipaddress
  # Prefer the canonical client IP resolved once by IPPrivacyMiddleware
  # ("resolve once, read everywhere"). Falls back to the shared resolver
  # (Otto::Utils.resolve_client_ip) for standalone use without the
  # middleware, so the with- and without-middleware paths agree on which
  # forwarded headers to trust and how to walk a proxy chain.
  canonical = env['otto.client_ip']
  return canonical if canonical && !canonical.empty?

  Otto::Utils.resolve_client_ip(env, otto_security_config)
end

#collect_proxy_headers(header_prefix: nil, additional_keys: []) ⇒ String

Collect and format HTTP header details from the request environment

This method extracts and formats specific HTTP headers, including Cloudflare and proxy-related headers, for logging and debugging purposes.

Examples:

Basic usage

collect_proxy_headers
# => "X-Forwarded-For: 203.0.113.195 Remote-Addr: 192.0.2.1"

With custom prefix

collect_proxy_headers(header_prefix: 'X_CUSTOM_')
# => "X-Forwarded-For: 203.0.113.195 X-Custom-Token: abc123"

Parameters:

  • header_prefix (String, nil) (defaults to: nil)

    Custom header prefix to include (e.g. ‘X_SECRET_’)

  • additional_keys (Array<String>) (defaults to: [])

    Additional header keys to collect

Returns:

  • (String)

    Formatted header details as “key: value” pairs



302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
# File 'lib/otto/request.rb', line 302

def collect_proxy_headers(header_prefix: nil, additional_keys: [])
  keys = %w[
    HTTP_FLY_REQUEST_ID
    HTTP_VIA
    HTTP_X_FORWARDED_PROTO
    HTTP_X_FORWARDED_FOR
    HTTP_X_FORWARDED_HOST
    HTTP_X_FORWARDED_PORT
    HTTP_X_SCHEME
    HTTP_X_REAL_IP
    HTTP_CF_IPCOUNTRY
    HTTP_CF_RAY
    REMOTE_ADDR
  ]

  # Add any header that begins with the specified prefix
  if header_prefix
    prefix_keys = env.keys.select { _1.upcase.start_with?("HTTP_#{header_prefix.upcase}") }
    keys.concat(prefix_keys)
  end

  # Add any additional keys requested
  keys.concat(additional_keys) if additional_keys.any?

  keys.sort.filter_map do |key|
    value = env[key]
    next unless value

    # Normalize the header name to look like browser dev console
    # e.g. Content-Type instead of HTTP_CONTENT_TYPE
    pretty_name = key.sub(/^HTTP_/, '').split('_').map(&:capitalize).join('-')
    "#{pretty_name}: #{value}"
  end.join(' ')
end


230
231
232
# File 'lib/otto/request.rb', line 230

def cookie(name)
  cookies[name.to_s]
end

#cookie?(name) ⇒ Boolean

Returns:

  • (Boolean)


234
235
236
# File 'lib/otto/request.rb', line 234

def cookie?(name)
  !cookie(name).to_s.empty?
end

#csp_nonceString

Framework-owned, request-scoped CSP nonce, generated lazily on first access and memoized into the request env. Views call this to stamp nonce="…" onto their inline <script>/<link> tags; the same value is what Security::CSP::EmitMiddleware writes into the script-src 'nonce-…' header — so the header and the views agree structurally, not by convention. An untouched request generates nothing.

The env key is configurable via Security::Config#csp_nonce_key.

Returns:

  • (String)

    this request’s nonce (base64)



37
38
39
# File 'lib/otto/request.rb', line 37

def csp_nonce
  Otto::Security::CSP.nonce(env)
end

#current_absolute_uriObject



238
239
240
241
# File 'lib/otto/request.rb', line 238

def current_absolute_uri
  prefix = secure? && !local? ? 'https://' : 'http://'
  [prefix, http_host, request_path].join
end

#current_serverObject



152
153
154
# File 'lib/otto/request.rb', line 152

def current_server
  [current_server_name, env['SERVER_PORT']].join(':')
end

#current_server_nameObject



156
157
158
# File 'lib/otto/request.rb', line 156

def current_server_name
  env['SERVER_NAME']
end

#format_request_details(header_prefix: nil) ⇒ String

Format request details as a single string for logging

This method combines IP address, HTTP method, path, query parameters, and proxy header details into a single formatted string suitable for logging.

Examples:

format_request_details
# => "192.0.2.1; GET /path?query=string; Proxy[X-Forwarded-For: 203.0.113.195 Remote-Addr: 192.0.2.1]"

Parameters:

  • header_prefix (String, nil) (defaults to: nil)

    Custom header prefix for proxy headers

Returns:

  • (String)

    Formatted request details



349
350
351
352
353
354
355
356
357
358
359
# File 'lib/otto/request.rb', line 349

def format_request_details(header_prefix: nil)
  header_details = collect_proxy_headers(header_prefix: header_prefix)

  details = [
    client_ipaddress,
    "#{request_method} #{env['PATH_INFO']}?#{env['QUERY_STRING']}",
    "Proxy[#{header_details}]",
  ]

  details.join('; ')
end

#forwarded_by_trusted_proxy?Boolean

Whether the request arrived through a trusted proxy.

Prefers the canonical decision recorded once by IPPrivacyMiddleware in env[‘otto.via_trusted_proxy’] — evaluated against the original peer before REMOTE_ADDR is masked, so it stays correct even after masking. Falls back to evaluating the current REMOTE_ADDR when the middleware has not run (standalone request use).

This is the trusted-proxy identity check only and is independent of count-based depth mode: depth resolves the client IP but never grants proxy trust for X-Forwarded-Proto.

Returns:

  • (Boolean)


219
220
221
222
223
# File 'lib/otto/request.rb', line 219

def forwarded_by_trusted_proxy?
  return env['otto.via_trusted_proxy'] if env.key?('otto.via_trusted_proxy')

  otto_security_config ? trusted_proxy?(env['REMOTE_ADDR']) : false
end

#geo_countryString?

Get the geo-location country code for the request

Returns ISO 3166-1 alpha-2 country code or ‘XX’ for unknown. Only available when IP privacy is enabled (default).

Examples:

req.geo_country  # => 'US'

Returns:

  • (String, nil)

    Country code or nil if privacy disabled



91
92
93
# File 'lib/otto/request.rb', line 91

def geo_country
  redacted_fingerprint&.country || env['otto.privacy.geo_country']
end

#hashed_ipString?

Get hashed IP for session correlation

Returns daily-rotating hash of the IP address, allowing session tracking without storing the original IP. Only available when IP privacy is enabled (default).

Examples:

req.hashed_ip  # => 'a3f8b2c4d5e6f7...'

Returns:

  • (String, nil)

    Hexadecimal hash string or nil



132
133
134
# File 'lib/otto/request.rb', line 132

def hashed_ip
  redacted_fingerprint&.hashed_ip || env['otto.privacy.hashed_ip']
end

#http_hostObject



160
161
162
# File 'lib/otto/request.rb', line 160

def http_host
  env['HTTP_HOST']
end

#ipString?

Canonical client IP for the request.

Prefers env[‘otto.client_ip’] — the value resolved once, early, by IPPrivacyMiddleware (“resolve once, read everywhere”): the masked IP when privacy is enabled, or the resolved real IP when privacy is disabled or the address is exempt. This means downstream code no longer depends on REMOTE_ADDR / X-Forwarded-For rewriting being load-bearing.

Falls back to Rack’s native resolution when the middleware has not run (e.g. standalone request use without the Otto middleware stack).

Returns:

  • (String, nil)

    Canonical (privacy-applied) client IP



53
54
55
56
57
58
# File 'lib/otto/request.rb', line 53

def ip
  canonical = env['otto.client_ip']
  return canonical if canonical && !canonical.empty?

  super
end

#local?Boolean

Returns:

  • (Boolean)


181
182
183
184
185
186
187
188
189
190
191
192
# File 'lib/otto/request.rb', line 181

def local?
  return false unless Otto.env?(:dev, :development)

  ip = client_ipaddress
  return false unless ip

  # Check both IP and server name for comprehensive localhost detection
  server_name        = env['SERVER_NAME']
  local_server_names = ['localhost', '127.0.0.1', '0.0.0.0']

  local_or_private_ip?(ip) && local_server_names.include?(server_name)
end

#local_or_private_ip?(ip) ⇒ Boolean

Returns:

  • (Boolean)


274
275
276
277
278
279
280
281
282
283
# File 'lib/otto/request.rb', line 274

def local_or_private_ip?(ip)
  return false unless ip

  # Fast path for the common localhost cases (avoids IPAddr allocation);
  # private_ip? would also catch these via IPAddr#loopback?.
  return true if ['127.0.0.1', '::1'].include?(ip)

  # Check for private IP ranges
  private_ip?(ip)
end

#masked_ipString?

Get masked IP address

Returns privacy-safe masked IP. When privacy is enabled (default), this returns the masked version. When disabled, returns original IP.

Examples:

req.masked_ip  # => '192.168.1.0'

Returns:

  • (String, nil)

    Masked or original IP address



119
120
121
# File 'lib/otto/request.rb', line 119

def masked_ip
  env['otto.privacy.masked_ip'] || env['REMOTE_ADDR']
end

#otto_security_configObject



243
244
245
246
247
248
249
250
# File 'lib/otto/request.rb', line 243

def otto_security_config
  # Try to get security config from various sources
  if respond_to?(:otto) && otto.respond_to?(:security_config)
    otto.security_config
  elsif defined?(Otto) && Otto.respond_to?(:security_config)
    Otto.security_config
  end
end

#private_ip?(ip) ⇒ Boolean

Whether the given address is non-public (private, loopback, link-local, multicast or unspecified). IPv4 and IPv6 aware via Otto::Utils.private_ip? — the previous implementation was an IPv4-only regex that silently treated every IPv6 address (including ::1 and ULA fc00::/7) as public.

Parameters:

  • ip (String, IPAddr, nil)

    address to classify

Returns:

  • (Boolean)


270
271
272
# File 'lib/otto/request.rb', line 270

def private_ip?(ip)
  Otto::Utils.private_ip?(ip)
end

#redacted_fingerprintOtto::Privacy::RedactedFingerprint?

Get the privacy-safe fingerprint for this request

Returns nil if IP privacy is disabled. The fingerprint contains anonymized request information suitable for logging and analytics.

Examples:

fingerprint = req.redacted_fingerprint
fingerprint.masked_ip    # => '192.168.1.0'
fingerprint.country      # => 'US'

Returns:



79
80
81
# File 'lib/otto/request.rb', line 79

def redacted_fingerprint
  env['otto.privacy.fingerprint']
end

#request_methodObject



148
149
150
# File 'lib/otto/request.rb', line 148

def request_method
  env['REQUEST_METHOD']
end

#request_pathObject



164
165
166
# File 'lib/otto/request.rb', line 164

def request_path
  env['REQUEST_PATH']
end

#request_uriObject



168
169
170
# File 'lib/otto/request.rb', line 168

def request_uri
  env['REQUEST_URI']
end

#root_pathObject



172
173
174
# File 'lib/otto/request.rb', line 172

def root_path
  env['SCRIPT_NAME']
end

#secure?Boolean

Returns:

  • (Boolean)


194
195
196
197
198
199
200
201
202
203
204
# File 'lib/otto/request.rb', line 194

def secure?
  # Check direct HTTPS connection
  return true if env['HTTPS'] == 'on' || env['SERVER_PORT'] == '443'

  # Only trust forwarded proto headers when the request actually arrived via
  # a trusted proxy.
  return false unless forwarded_by_trusted_proxy?

  # X-Scheme is set by nginx; X-Forwarded-Proto by elastic load balancer
  env['HTTP_X_FORWARDED_PROTO'] == 'https' || env['HTTP_X_SCHEME'] == 'https'
end

#trusted_proxy?(ip) ⇒ Boolean

Returns:

  • (Boolean)


252
253
254
255
256
257
# File 'lib/otto/request.rb', line 252

def trusted_proxy?(ip)
  config = otto_security_config
  return false unless config

  config.trusted_proxy?(ip)
end

#user_agentObject



23
24
25
# File 'lib/otto/request.rb', line 23

def user_agent
  env['HTTP_USER_AGENT']
end

#validate_ip_address(ip) ⇒ Object



259
260
261
# File 'lib/otto/request.rb', line 259

def validate_ip_address(ip)
  Otto::Utils.normalize_ip(ip)
end