Module: Studio::GeoDetection

Extended by:
ActiveSupport::Concern
Included in:
GeoSettingsController
Defined in:
app/controllers/concerns/studio/geo_detection.rb

Overview

Where the visitor appears to be, resolved once per session and available to every controller and view that includes this concern.

include Studio::GeoDetection

That one line gives an app: IP -> region detection with a session cache and a self-healing retry window, the geo_state / geo_country / geo_blocked? helpers the shared badge renders from, an admin simulation override, and require_geo_allowed — a before_action an app can hang on whichever surfaces it wants LOCKED.

LOCKING IS DELIBERATELY LOOSE. The engine ships the gate and the policy; each app decides what it protects, because "which actions are geo-restricted" is a product and legal question, not a framework one:

before_action :require_geo_allowed, only: %i[create withdraw]

Detection itself is registered here as a before_action, because a badge that renders on every page needs a location on every page. A controller that must not pay for it (webhooks, health checks) skips it the ordinary way:

skip_before_action :detect_geo_state

SESSION KEYS are the plain names an app already has in production (:geo_state, :geo_country, :geo_ip, :geo_detected_at, :geo_override) so an app adopting this concern keeps every live visitor's resolved location instead of re-geocoding the whole internet on deploy day.

Instance Method Summary collapse

Instance Method Details

#detect_geo_stateObject

Resolve the visitor's region, at most once per freshness window.

Failure is SWALLOWED by design: a geocoding provider being down must not 500 the page. It is logged, and the attempt is stamped so a provider that is timing out on every request is retried on the retry window rather than on every single request.



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'app/controllers/concerns/studio/geo_detection.rb', line 49

def detect_geo_state
  return if session[:geo_override].present?

  ip_changed = session[:geo_ip] != request.remote_ip
  # `now: Time.current`, NOT the module's Rails-free Time.now default. The
  # stamp written below is `Time.current.to_s` — "2026-08-19 05:00:00 UTC" —
  # and the comparison is textual, so a local-zone Time.now would produce
  # "2026-08-18 23:00:00 -0600" and compare as a DIFFERENT DAY. Measured in
  # turf-monster's suite: the self-heal retry never fired.
  stale = Studio::Geo.stale?(
    detected_at: session[:geo_detected_at],
    resolved: session[:geo_state].present?,
    now: Time.current,
    ttl: Studio.geo_ttl,
    retry_ttl: Studio.geo_retry_ttl
  )
  return unless ip_changed || stale

  result = geo_lookup(request.remote_ip)

  # state_code / region_code / region, in that order: providers disagree about
  # which field carries a subdivision, and outside the US the only answer is
  # often the region NAME ("Alberta"). Taking the first present one keeps a
  # foreign visitor placed rather than blank.
  raw = result&.try(:state_code).presence || result&.try(:region_code).presence || result&.try(:region)
  session[:geo_state] = Studio::Geo.normalize_subdivision(raw)
  session[:geo_country] = Studio::Geo.normalize_country(result&.try(:country_code))

  apply_development_region if session[:geo_state].blank?

  session[:geo_ip] = request.remote_ip
  session[:geo_detected_at] = Time.current.to_s
rescue StandardError => e
  Rails.logger.warn "Geo detection failed: #{e.message}"
  session[:geo_detected_at] = Time.current.to_s
end

#geo_blocked?Boolean

The gate's verdict for this visitor. Delegates the whole policy to Studio::GeoSetting, so an app never re-derives the fail-closed rule — the subtle part — from its own controller.

Returns:

  • (Boolean)


132
133
134
# File 'app/controllers/concerns/studio/geo_detection.rb', line 132

def geo_blocked?
  Studio::GeoSetting.blocked?(country: geo_country, subdivision: geo_state)
end

#geo_countryObject

ISO country code from the same lookup.

Defaults to the app's HOME country when undetected, and that default is load-bearing rather than cosmetic: it is what makes an unplaceable visitor fall into the fail-closed branch of the policy instead of sliding through as "somewhere else, therefore fine".



115
116
117
118
119
120
121
122
# File 'app/controllers/concerns/studio/geo_detection.rb', line 115

def geo_country
  if session[:geo_override].present?
    country, _subdivision = Studio::Geo.parse_region(session[:geo_override], home_country: Studio.geo_home_country)
    return country || Studio.geo_home_country
  end

  Studio::Geo.normalize_country(session[:geo_country]) || Studio.geo_home_country
end

#geo_override_active?Boolean

Returns:

  • (Boolean)


136
137
138
# File 'app/controllers/concerns/studio/geo_detection.rb', line 136

def geo_override_active?
  session[:geo_override].present?
end

#geo_region_tokenObject

"US-WA" — the two halves as one token, for logging and for the admin page.



125
126
127
# File 'app/controllers/concerns/studio/geo_detection.rb', line 125

def geo_region_token
  Studio::Geo.region_token(geo_country, geo_state)
end

#geo_stateObject Also known as: geo_subdivision

The visitor's subdivision code — "WA", or a region name where that is all the provider knows. geo_state is the name every existing app calls this; geo_subdivision is the same value under the vocabulary the engine uses everywhere else.



99
100
101
102
103
104
105
106
# File 'app/controllers/concerns/studio/geo_detection.rb', line 99

def geo_state
  if session[:geo_override].present?
    _country, subdivision = Studio::Geo.parse_region(session[:geo_override], home_country: Studio.geo_home_country)
    return subdivision
  end

  Studio::Geo.normalize_subdivision(session[:geo_state])
end

#require_geo_allowedObject

THE LOCK. Hang it on whatever an app must not serve from a blocked region:

before_action :require_geo_allowed, only: %i[create withdraw]

HTML redirects with an explanation, JSON answers 403 with the same words — the copy comes from Studio.geo_blocked_message so each app speaks in its own voice about its own rules.



147
148
149
150
151
152
153
154
155
# File 'app/controllers/concerns/studio/geo_detection.rb', line 147

def require_geo_allowed
  return unless geo_blocked?

  message = Studio.geo_blocked_message.call(geo_state, geo_country)
  respond_to do |format|
    format.html { redirect_to geo_blocked_redirect_path, alert: message }
    format.json { render json: { error: message }, status: :forbidden }
  end
end

#reset_geo_detection!Object

Force a fresh lookup on the next detect — what the /geo/check endpoint uses so an operator can re-test their own location without waiting out the TTL.



88
89
90
91
92
93
# File 'app/controllers/concerns/studio/geo_detection.rb', line 88

def reset_geo_detection!
  session.delete(:geo_detected_at)
  session.delete(:geo_ip)
  session.delete(:geo_state)
  session.delete(:geo_country)
end