Module: Otto::RequestHelpers

Includes:
BaseHelpers
Defined in:
lib/otto/helpers/request.rb

Instance Method Summary collapse

Instance Method Details

#absolute_suri(host = current_server_name) ⇒ Object



66
67
68
69
# File 'lib/otto/helpers/request.rb', line 66

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

#ajax?Boolean

Returns:

  • (Boolean)


101
102
103
# File 'lib/otto/helpers/request.rb', line 101

def ajax?
  env['HTTP_X_REQUESTED_WITH'].to_s.downcase == 'xmlhttprequest'
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



295
296
297
298
299
# File 'lib/otto/helpers/request.rb', line 295

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



263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
# File 'lib/otto/helpers/request.rb', line 263

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



342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
# File 'lib/otto/helpers/request.rb', line 342

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 && default_locale
    raise ArgumentError, 'available_locales and default_locale are required (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



13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# File 'lib/otto/helpers/request.rb', line 13

def client_ipaddress
  remote_addr = env['REMOTE_ADDR']

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

  # 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



193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/otto/helpers/request.rb', line 193

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 { |key| key.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


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

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

#cookie?(name) ⇒ Boolean

Returns:

  • (Boolean)


109
110
111
# File 'lib/otto/helpers/request.rb', line 109

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

#current_absolute_uriObject



113
114
115
116
# File 'lib/otto/helpers/request.rb', line 113

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

#current_serverObject



42
43
44
# File 'lib/otto/helpers/request.rb', line 42

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

#current_server_nameObject



46
47
48
# File 'lib/otto/helpers/request.rb', line 46

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



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

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

#http_hostObject



50
51
52
# File 'lib/otto/helpers/request.rb', line 50

def http_host
  env['HTTP_HOST']
end

#local?Boolean

Returns:

  • (Boolean)


71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/otto/helpers/request.rb', line 71

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)


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

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

#otto_security_configObject



118
119
120
121
122
123
124
125
# File 'lib/otto/helpers/request.rb', line 118

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)


150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/otto/helpers/request.rb', line 150

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

#request_methodObject



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

def request_method
  env['REQUEST_METHOD']
end

#request_pathObject



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

def request_path
  env['REQUEST_PATH']
end

#request_uriObject



58
59
60
# File 'lib/otto/helpers/request.rb', line 58

def request_uri
  env['REQUEST_URI']
end

#root_pathObject



62
63
64
# File 'lib/otto/helpers/request.rb', line 62

def root_path
  env['SCRIPT_NAME']
end

#secure?Boolean

Returns:

  • (Boolean)


84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/otto/helpers/request.rb', line 84

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)


127
128
129
130
131
132
# File 'lib/otto/helpers/request.rb', line 127

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

  config.trusted_proxy?(ip)
end

#user_agentObject



9
10
11
# File 'lib/otto/helpers/request.rb', line 9

def user_agent
  env['HTTP_USER_AGENT']
end

#validate_ip_address(ip) ⇒ Object



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

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