Module: Studio::Geo
- Defined in:
- lib/studio/geo.rb,
lib/studio/geo/lookup.rb,
lib/studio/geo/countries.rb
Overview
The house geo primitive: where a visitor appears to be, and whether this app is willing to serve them there.
Deliberately PURE — no ActiveRecord, no Rails, no request, no Geocoder call. It takes plain strings and returns plain strings and booleans, which is what makes the load-bearing half (the blocking policy) unit-testable a branch at a time. The pieces that need the world live beside it:
Studio::GeoDetection (controller concern) resolves IP -> region and caches
it in the session, then asks THIS module the verdict
Studio::GeoSetting (model) stores the operator's choices
Studio::GeoHelper (view helper) renders flags from these codes
VOCABULARY, because the two halves of an address are easy to conflate:
country ISO 3166-1 alpha-2, upcased — "US", "CA", "GB"
subdivision the state/province/region code WITHIN a country — "WA", "AB"
region token the two joined, "US-WA", which is the only form stored
The token matters. "CA" is California AND Canada, so a bare subdivision code is only meaningful next to its country. A ban list of bare codes cannot say which one it means; a list of tokens always can. Bare codes are still ACCEPTED on input (turf-monster stored years of them) and normalized to tokens against the app's home country on the way in — one vocabulary inside, tolerance at the door.
Defined Under Namespace
Modules: Lookup
Constant Summary collapse
- US_SUBDIVISIONS =
US states + DC + PR, name => code. The map is here rather than in a host app because two different surfaces need it: the geocoder hands back a full name ("Washington") that has to become a code, and the published exclusion list has to turn a code back into a name a reader recognises.
{ "Alabama" => "AL", "Alaska" => "AK", "Arizona" => "AZ", "Arkansas" => "AR", "California" => "CA", "Colorado" => "CO", "Connecticut" => "CT", "Delaware" => "DE", "Florida" => "FL", "Georgia" => "GA", "Hawaii" => "HI", "Idaho" => "ID", "Illinois" => "IL", "Indiana" => "IN", "Iowa" => "IA", "Kansas" => "KS", "Kentucky" => "KY", "Louisiana" => "LA", "Maine" => "ME", "Maryland" => "MD", "Massachusetts" => "MA", "Michigan" => "MI", "Minnesota" => "MN", "Mississippi" => "MS", "Missouri" => "MO", "Montana" => "MT", "Nebraska" => "NE", "Nevada" => "NV", "New Hampshire" => "NH", "New Jersey" => "NJ", "New Mexico" => "NM", "New York" => "NY", "North Carolina" => "NC", "North Dakota" => "ND", "Ohio" => "OH", "Oklahoma" => "OK", "Oregon" => "OR", "Pennsylvania" => "PA", "Rhode Island" => "RI", "South Carolina" => "SC", "South Dakota" => "SD", "Tennessee" => "TN", "Texas" => "TX", "Utah" => "UT", "Vermont" => "VT", "Virginia" => "VA", "Washington" => "WA", "West Virginia" => "WV", "Wisconsin" => "WI", "Wyoming" => "WY", "District of Columbia" => "DC", "Puerto Rico" => "PR" }.freeze
- US_SUBDIVISION_NAMES =
code => name, for the published exclusion list and the admin grid labels.
US_SUBDIVISIONS.invert.freeze
- US_SUBDIVISION_CODES =
The editor grid's order: alphabetical by code, DC and PR last, which is how every published US exclusion list a reader has seen is laid out.
((US_SUBDIVISIONS.values - %w[DC PR]).sort + %w[DC PR]).freeze
- REGIONAL_INDICATOR_A =
Regional Indicator Symbol A. A country's two letters mapped into this block render as one flag glyph on every platform that ships emoji.
0x1F1E6- COUNTRY_CODE =
/\A[A-Za-z]{2}\z/- SUBDIVISION_CODE =
/\A[A-Za-z]{1,3}\z/- COUNTRIES =
ISO 3166-1 alpha-2, code => short English name.
Here because a country picker needs the whole list and an app should not have to add a gem (or hand-type 249 rows) to render one. Names are the short forms from the public-domain
iso3166.tabthat ships with tzdata — the same table every Unix carries — so they are the familiar ones ("United States", not "United States of America") and short enough for a grid.This is the vocabulary the COUNTRY half of a ban list is written in; subdivisions live in US_SUBDIVISIONS beside it.
{ "AD" => "Andorra", "AE" => "United Arab Emirates", "AF" => "Afghanistan", "AG" => "Antigua & Barbuda", "AI" => "Anguilla", "AL" => "Albania", "AM" => "Armenia", "AO" => "Angola", "AQ" => "Antarctica", "AR" => "Argentina", "AS" => "Samoa (American)", "AT" => "Austria", "AU" => "Australia", "AW" => "Aruba", "AX" => "Åland Islands", "AZ" => "Azerbaijan", "BA" => "Bosnia & Herzegovina", "BB" => "Barbados", "BD" => "Bangladesh", "BE" => "Belgium", "BF" => "Burkina Faso", "BG" => "Bulgaria", "BH" => "Bahrain", "BI" => "Burundi", "BJ" => "Benin", "BL" => "St Barthelemy", "BM" => "Bermuda", "BN" => "Brunei", "BO" => "Bolivia", "BQ" => "Caribbean NL", "BR" => "Brazil", "BS" => "Bahamas", "BT" => "Bhutan", "BV" => "Bouvet Island", "BW" => "Botswana", "BY" => "Belarus", "BZ" => "Belize", "CA" => "Canada", "CC" => "Cocos (Keeling) Islands", "CD" => "Congo (Dem. Rep.)", "CF" => "Central African Rep.", "CG" => "Congo (Rep.)", "CH" => "Switzerland", "CI" => "Côte d’Ivoire", "CK" => "Cook Islands", "CL" => "Chile", "CM" => "Cameroon", "CN" => "China", "CO" => "Colombia", "CR" => "Costa Rica", "CU" => "Cuba", "CV" => "Cape Verde", "CW" => "Curaçao", "CX" => "Christmas Island", "CY" => "Cyprus", "CZ" => "Czech Republic", "DE" => "Germany", "DJ" => "Djibouti", "DK" => "Denmark", "DM" => "Dominica", "DO" => "Dominican Republic", "DZ" => "Algeria", "EC" => "Ecuador", "EE" => "Estonia", "EG" => "Egypt", "EH" => "Western Sahara", "ER" => "Eritrea", "ES" => "Spain", "ET" => "Ethiopia", "FI" => "Finland", "FJ" => "Fiji", "FK" => "Falkland Islands", "FM" => "Micronesia", "FO" => "Faroe Islands", "FR" => "France", "GA" => "Gabon", "GB" => "Britain (UK)", "GD" => "Grenada", "GE" => "Georgia", "GF" => "French Guiana", "GG" => "Guernsey", "GH" => "Ghana", "GI" => "Gibraltar", "GL" => "Greenland", "GM" => "Gambia", "GN" => "Guinea", "GP" => "Guadeloupe", "GQ" => "Equatorial Guinea", "GR" => "Greece", "GS" => "South Georgia & the South Sandwich Islands", "GT" => "Guatemala", "GU" => "Guam", "GW" => "Guinea-Bissau", "GY" => "Guyana", "HK" => "Hong Kong", "HM" => "Heard Island & McDonald Islands", "HN" => "Honduras", "HR" => "Croatia", "HT" => "Haiti", "HU" => "Hungary", "ID" => "Indonesia", "IE" => "Ireland", "IL" => "Israel", "IM" => "Isle of Man", "IN" => "India", "IO" => "British Indian Ocean Territory", "IQ" => "Iraq", "IR" => "Iran", "IS" => "Iceland", "IT" => "Italy", "JE" => "Jersey", "JM" => "Jamaica", "JO" => "Jordan", "JP" => "Japan", "KE" => "Kenya", "KG" => "Kyrgyzstan", "KH" => "Cambodia", "KI" => "Kiribati", "KM" => "Comoros", "KN" => "St Kitts & Nevis", "KP" => "Korea (North)", "KR" => "Korea (South)", "KW" => "Kuwait", "KY" => "Cayman Islands", "KZ" => "Kazakhstan", "LA" => "Laos", "LB" => "Lebanon", "LC" => "St Lucia", "LI" => "Liechtenstein", "LK" => "Sri Lanka", "LR" => "Liberia", "LS" => "Lesotho", "LT" => "Lithuania", "LU" => "Luxembourg", "LV" => "Latvia", "LY" => "Libya", "MA" => "Morocco", "MC" => "Monaco", "MD" => "Moldova", "ME" => "Montenegro", "MF" => "St Martin (French)", "MG" => "Madagascar", "MH" => "Marshall Islands", "MK" => "North Macedonia", "ML" => "Mali", "MM" => "Myanmar (Burma)", "MN" => "Mongolia", "MO" => "Macau", "MP" => "Northern Mariana Islands", "MQ" => "Martinique", "MR" => "Mauritania", "MS" => "Montserrat", "MT" => "Malta", "MU" => "Mauritius", "MV" => "Maldives", "MW" => "Malawi", "MX" => "Mexico", "MY" => "Malaysia", "MZ" => "Mozambique", "NA" => "Namibia", "NC" => "New Caledonia", "NE" => "Niger", "NF" => "Norfolk Island", "NG" => "Nigeria", "NI" => "Nicaragua", "NL" => "Netherlands", "NO" => "Norway", "NP" => "Nepal", "NR" => "Nauru", "NU" => "Niue", "NZ" => "New Zealand", "OM" => "Oman", "PA" => "Panama", "PE" => "Peru", "PF" => "French Polynesia", "PG" => "Papua New Guinea", "PH" => "Philippines", "PK" => "Pakistan", "PL" => "Poland", "PM" => "St Pierre & Miquelon", "PN" => "Pitcairn", "PR" => "Puerto Rico", "PS" => "Palestine", "PT" => "Portugal", "PW" => "Palau", "PY" => "Paraguay", "QA" => "Qatar", "RE" => "Réunion", "RO" => "Romania", "RS" => "Serbia", "RU" => "Russia", "RW" => "Rwanda", "SA" => "Saudi Arabia", "SB" => "Solomon Islands", "SC" => "Seychelles", "SD" => "Sudan", "SE" => "Sweden", "SG" => "Singapore", "SH" => "St Helena", "SI" => "Slovenia", "SJ" => "Svalbard & Jan Mayen", "SK" => "Slovakia", "SL" => "Sierra Leone", "SM" => "San Marino", "SN" => "Senegal", "SO" => "Somalia", "SR" => "Suriname", "SS" => "South Sudan", "ST" => "Sao Tome & Principe", "SV" => "El Salvador", "SX" => "St Maarten (Dutch)", "SY" => "Syria", "SZ" => "Eswatini (Swaziland)", "TC" => "Turks & Caicos Is", "TD" => "Chad", "TF" => "French S. Terr.", "TG" => "Togo", "TH" => "Thailand", "TJ" => "Tajikistan", "TK" => "Tokelau", "TL" => "East Timor", "TM" => "Turkmenistan", "TN" => "Tunisia", "TO" => "Tonga", "TR" => "Turkey", "TT" => "Trinidad & Tobago", "TV" => "Tuvalu", "TW" => "Taiwan", "TZ" => "Tanzania", "UA" => "Ukraine", "UG" => "Uganda", "UM" => "US minor outlying islands", "US" => "United States", "UY" => "Uruguay", "UZ" => "Uzbekistan", "VA" => "Vatican City", "VC" => "St Vincent", "VE" => "Venezuela", "VG" => "Virgin Islands (UK)", "VI" => "Virgin Islands (US)", "VN" => "Vietnam", "VU" => "Vanuatu", "WF" => "Wallis & Futuna", "WS" => "Samoa (western)", "YE" => "Yemen", "YT" => "Mayotte", "ZA" => "South Africa", "ZM" => "Zambia", "ZW" => "Zimbabwe", }.freeze
- COUNTRY_CODES =
COUNTRIES.keys.freeze
Class Method Summary collapse
-
.blocked?(country:, subdivision:, banned_countries: [], banned_subdivisions: [], home_country: "US", enforcing: true, fail_closed: true) ⇒ Boolean
THE VERDICT.
-
.country_flag_emoji(alpha2) ⇒ Object
The country's flag as a regional-indicator emoji pair.
- .country_name(code) ⇒ Object
-
.foreign?(country, home_country: "US") ⇒ Boolean
True when the visitor is outside the app's home country, and therefore must NOT be shown a home-country subdivision flag.
-
.normalize_country(raw) ⇒ Object
"us" / " US " -> "US"; anything that is not two ASCII letters -> nil.
-
.normalize_region_token(token, home_country: "US") ⇒ Object
Whatever the operator typed or an older app stored, as one canonical token.
-
.normalize_subdivision(raw) ⇒ Object
"Washington" -> "WA", "wa" -> "WA".
-
.parse_region(token, home_country: "US") ⇒ Object
The inverse, tolerant of the legacy bare form.
-
.region_token(country, subdivision) ⇒ Object
"US" + "WA" -> "US-WA".
-
.stale?(detected_at:, resolved:, now: Time.now, ttl: 86_400, retry_ttl: 300) ⇒ Boolean
Should the caller re-run the IP lookup?.
- .subdivision_name(code, country: "US") ⇒ Object
Class Method Details
.blocked?(country:, subdivision:, banned_countries: [], banned_subdivisions: [], home_country: "US", enforcing: true, fail_closed: true) ⇒ Boolean
THE VERDICT. Is this visitor blocked?
enforcing the operator's kill switch; false blocks nobody
country/subdivision where the visitor appears to be
banned_countries ISO alpha-2 codes this app refuses outright
banned_subdivisions region tokens ("US-WA") — bare codes tolerated
home_country this app's own country, for bare-code resolution
fail_closed how an UNDETECTABLE home-country visitor is treated
Three ways to be blocked, in order:
1. the resolved country is on the country list;
2. the resolved region is on the subdivision list;
3. FAIL CLOSED — the app blocks specific subdivisions of its own country,
and this visitor is in that country with NO detectable subdivision (a
VPN, a datacenter IP, a provider outage, a lookup timeout). A blank
could be any of the blocked regions masked by a failed lookup, so it
cannot be waved through.
Rule 3 is deliberately narrower than "block every blank": it only fires when subdivision-level rules for the home country actually exist. An app that blocks nothing at that grain is hiding nothing, so failing its visitors closed would cost real users access to protect no rule. A resolved NON-home country with a blank subdivision stays allowed for the same reason — only the home-country ambiguity is dangerous.
222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 |
# File 'lib/studio/geo.rb', line 222 def blocked?(country:, subdivision:, banned_countries: [], banned_subdivisions: [], home_country: "US", enforcing: true, fail_closed: true) return false unless enforcing home = normalize_country(home_country) country = normalize_country(country) subdivision = normalize_subdivision(subdivision) countries = Array(banned_countries).filter_map { |c| normalize_country(c) } return true if country && countries.include?(country) tokens = Array(banned_subdivisions).filter_map { |t| normalize_region_token(t, home_country: home) } token = region_token(country, subdivision) return true if token && tokens.include?(token) return false unless fail_closed return false unless subdivision.nil? return false unless country == home tokens.any? { |t| t.start_with?("#{home}-") } end |
.country_flag_emoji(alpha2) ⇒ Object
The country's flag as a regional-indicator emoji pair.
Why an emoji rather than an asset: shipping ~250 country SVGs is a lot of bytes for a 16px badge, and every platform already has the glyphs. Returns nil for anything that is not exactly two ASCII letters, so a blank, a malformed code, or a full country name renders text-only rather than emitting garbage codepoints.
155 156 157 158 159 160 |
# File 'lib/studio/geo.rb', line 155 def country_flag_emoji(alpha2) code = normalize_country(alpha2) return nil if code.nil? code.chars.map { |c| c.ord - "A".ord + REGIONAL_INDICATOR_A }.pack("U*") end |
.country_name(code) ⇒ Object
135 136 137 |
# File 'lib/studio/geo.rb', line 135 def country_name(code) COUNTRIES[normalize_country(code)] end |
.foreign?(country, home_country: "US") ⇒ Boolean
True when the visitor is outside the app's home country, and therefore must NOT be shown a home-country subdivision flag. This is the load-bearing half of flag selection: a subdivision flag lookup matches on a bare code, so an Italian region normalising to "CA" would otherwise be shown the CALIFORNIA flag — a wrong answer that looks right.
167 168 169 170 171 172 |
# File 'lib/studio/geo.rb', line 167 def foreign?(country, home_country: "US") code = normalize_country(country) return false if code.nil? code != normalize_country(home_country) end |
.normalize_country(raw) ⇒ Object
"us" / " US " -> "US"; anything that is not two ASCII letters -> nil. A geocoder that returns a full country name, an empty string, or nil all land on nil rather than becoming a code that means nothing.
74 75 76 77 78 79 80 81 |
# File 'lib/studio/geo.rb', line 74 def normalize_country(raw) return nil if raw.nil? code = raw.to_s.strip return nil unless code.match?(COUNTRY_CODE) code.upcase end |
.normalize_region_token(token, home_country: "US") ⇒ Object
Whatever the operator typed or an older app stored, as one canonical token.
130 131 132 133 |
# File 'lib/studio/geo.rb', line 130 def normalize_region_token(token, home_country: "US") country, subdivision = parse_region(token, home_country: home_country) region_token(country, subdivision) end |
.normalize_subdivision(raw) ⇒ Object
"Washington" -> "WA", "wa" -> "WA". A value that is neither a known US state name nor a short code is passed through UNCHANGED (upcased only when it is already code-shaped), because outside the US the geocoder's region string is frequently the only identifier there is — "Alberta" is a real answer, and dropping it would silently blank the visitor's location.
88 89 90 91 92 93 94 95 96 |
# File 'lib/studio/geo.rb', line 88 def normalize_subdivision(raw) return nil if raw.nil? value = raw.to_s.strip return nil if value.empty? return value.upcase if value.match?(SUBDIVISION_CODE) US_SUBDIVISIONS[value] || value end |
.parse_region(token, home_country: "US") ⇒ Object
The inverse, tolerant of the legacy bare form. "US-WA" -> ["US", "WA"]; "WA" -> [home_country, "WA"]. A bare code is read as a subdivision of the app's own country, which is the only reading that was ever meant when an app stored one.
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 |
# File 'lib/studio/geo.rb', line 112 def parse_region(token, home_country: "US") value = token.to_s.strip return [nil, nil] if value.empty? # A dash means the token carries BOTH halves — including "CU-", which is a # country with no subdivision (an app that blocks a whole country, and an # operator simulating it). Without the dash the value is a bare subdivision # of this app's own country, which is the only reading an older app ever # meant when it stored one. if value.include?("-") head, tail = value.split("-", 2) [normalize_country(head), normalize_subdivision(tail)] else [normalize_country(home_country), normalize_subdivision(value)] end end |
.region_token(country, subdivision) ⇒ Object
"US" + "WA" -> "US-WA". nil when either half is missing: a half-address is not a region, and storing one would match every visitor from that country.
100 101 102 103 104 105 106 |
# File 'lib/studio/geo.rb', line 100 def region_token(country, subdivision) country = normalize_country(country) subdivision = normalize_subdivision(subdivision) return nil if country.nil? || subdivision.nil? "#{country}-#{subdivision}" end |
.stale?(detected_at:, resolved:, now: Time.now, ttl: 86_400, retry_ttl: 300) ⇒ Boolean
Should the caller re-run the IP lookup?
A RESOLVED region is trusted for ttl (a day): people do not teleport, and
the lookup costs a network round trip on every request that misses. A BLANK
result is trusted only for retry_ttl (minutes), because a blank is usually
a transient failure — a provider timeout, a rate limit, an IP the provider
cannot place — and caching "nowhere" for a full day fails every geo-gated
feature closed until the visitor's IP changes.
Compared as STRINGS to match the session-stored Time#to_s stamps (UTC, and
therefore lexicographically chronological). now is injectable so the TTL
policy is testable without sleeping.
188 189 190 191 192 193 |
# File 'lib/studio/geo.rb', line 188 def stale?(detected_at:, resolved:, now: Time.now, ttl: 86_400, retry_ttl: 300) return true if detected_at.nil? || detected_at.to_s.strip.empty? window = resolved ? ttl : retry_ttl detected_at.to_s < (now - window).to_s end |
.subdivision_name(code, country: "US") ⇒ Object
139 140 141 142 143 144 |
# File 'lib/studio/geo.rb', line 139 def subdivision_name(code, country: "US") return nil if code.nil? return US_SUBDIVISION_NAMES[normalize_subdivision(code)] if normalize_country(country) == "US" nil end |