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



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

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

#ajax?Boolean

Returns:

  • (Boolean)


196
197
198
# File 'lib/otto/request.rb', line 196

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

#anonymized_user_agentString?

Deprecated.

Use env 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 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



81
82
83
# File 'lib/otto/request.rb', line 81

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



390
391
392
393
394
# File 'lib/otto/request.rb', line 390

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



358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
# File 'lib/otto/request.rb', line 358

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



437
438
439
440
441
442
443
444
445
446
447
448
449
450
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
# File 'lib/otto/request.rb', line 437

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



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/otto/request.rb', line 110

def client_ipaddress
  remote_addr = env['REMOTE_ADDR']

  # If we don't have a security config or trusted proxies, use direct connection
  return validate_ip_address(remote_addr) if !otto_security_config || !trusted_proxy?(remote_addr)

  # Check forwarded headers from trusted proxies
  forwarded_ips = [
    env['HTTP_X_FORWARDED_FOR'],
    env['HTTP_X_REAL_IP'],
    env['HTTP_CLIENT_IP'],
  ].compact.map { |header| header.split(/,\s*/) }.flatten

  # Return the first valid IP that's not a private/loopback address
  forwarded_ips.each do |ip|
    clean_ip = validate_ip_address(ip.strip)
    return clean_ip if clean_ip && !private_ip?(clean_ip)
  end

  # Fallback to remote address
  validate_ip_address(remote_addr)
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



288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/otto/request.rb', line 288

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


200
201
202
# File 'lib/otto/request.rb', line 200

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

#cookie?(name) ⇒ Boolean

Returns:

  • (Boolean)


204
205
206
# File 'lib/otto/request.rb', line 204

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

#current_absolute_uriObject



208
209
210
211
# File 'lib/otto/request.rb', line 208

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

#current_serverObject



137
138
139
# File 'lib/otto/request.rb', line 137

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

#current_server_nameObject



141
142
143
# File 'lib/otto/request.rb', line 141

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



335
336
337
338
339
340
341
342
343
344
345
# File 'lib/otto/request.rb', line 335

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

#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



65
66
67
# File 'lib/otto/request.rb', line 65

def geo_country
  redacted_fingerprint&.country || env['otto.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



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

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

#http_hostObject



145
146
147
# File 'lib/otto/request.rb', line 145

def http_host
  env['HTTP_HOST']
end

#local?Boolean

Returns:

  • (Boolean)


166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/otto/request.rb', line 166

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)


261
262
263
264
265
266
267
268
269
# File 'lib/otto/request.rb', line 261

def local_or_private_ip?(ip)
  return false unless ip

  # Check for localhost
  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



93
94
95
# File 'lib/otto/request.rb', line 93

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

#otto_security_configObject



213
214
215
216
217
218
219
220
# File 'lib/otto/request.rb', line 213

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

Returns:

  • (Boolean)


245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/otto/request.rb', line 245

def private_ip?(ip)
  return false unless ip

  # RFC 1918 private ranges and loopback
  private_ranges = [
    /\A10\./, # 10.0.0.0/8
    /\A172\.(1[6-9]|2[0-9]|3[01])\./, # 172.16.0.0/12
    /\A192\.168\./,              # 192.168.0.0/16
    /\A169\.254\./,              # 169.254.0.0/16 (link-local)
    /\A224\./,                   # 224.0.0.0/4 (multicast)
    /\A0\./, # 0.0.0.0/8
  ]

  private_ranges.any? { |range| ip.match?(range) }
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:



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

def redacted_fingerprint
  env['otto.redacted_fingerprint']
end

#request_methodObject



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

def request_method
  env['REQUEST_METHOD']
end

#request_pathObject



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

def request_path
  env['REQUEST_PATH']
end

#request_uriObject



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

def request_uri
  env['REQUEST_URI']
end

#root_pathObject



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

def root_path
  env['SCRIPT_NAME']
end

#secure?Boolean

Returns:

  • (Boolean)


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

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

  remote_addr = env['REMOTE_ADDR']

  # Only trust forwarded proto headers from trusted proxies
  if otto_security_config && trusted_proxy?(remote_addr)
    # X-Scheme is set by nginx
    # X-FORWARDED-PROTO is set by elastic load balancer
    return env['HTTP_X_FORWARDED_PROTO'] == 'https' || env['HTTP_X_SCHEME'] == 'https'
  end

  false
end

#trusted_proxy?(ip) ⇒ Boolean

Returns:

  • (Boolean)


222
223
224
225
226
227
# File 'lib/otto/request.rb', line 222

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



229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
# File 'lib/otto/request.rb', line 229

def validate_ip_address(ip)
  return nil if ip.nil? || ip.empty?

  # Remove any port number
  clean_ip = ip.split(':').first

  # Basic IP format validation
  return nil unless clean_ip.match?(/\A\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/)

  # Validate each octet
  octets = clean_ip.split('.')
  return nil unless octets.all? { |octet| (0..255).cover?(octet.to_i) }

  clean_ip
end