studio-engine 0.56.4 → 0.57.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/README.md +24 -9
  4. data/app/assets/images/state-flags/ak.svg +22 -0
  5. data/app/assets/images/state-flags/al.svg +6 -0
  6. data/app/assets/images/state-flags/ar.svg +244 -0
  7. data/app/assets/images/state-flags/az.svg +8 -0
  8. data/app/assets/images/state-flags/ca.svg +1 -0
  9. data/app/assets/images/state-flags/co.svg +9 -0
  10. data/app/assets/images/state-flags/ct.svg +2472 -0
  11. data/app/assets/images/state-flags/dc.svg +18 -0
  12. data/app/assets/images/state-flags/de.svg +1549 -0
  13. data/app/assets/images/state-flags/fl.svg +6784 -0
  14. data/app/assets/images/state-flags/ga.svg +71 -0
  15. data/app/assets/images/state-flags/hi.svg +11 -0
  16. data/app/assets/images/state-flags/ia.svg +1 -0
  17. data/app/assets/images/state-flags/id.svg +105 -0
  18. data/app/assets/images/state-flags/il.svg +538 -0
  19. data/app/assets/images/state-flags/in.svg +57 -0
  20. data/app/assets/images/state-flags/ks.svg +2575 -0
  21. data/app/assets/images/state-flags/ky.svg +2918 -0
  22. data/app/assets/images/state-flags/la.svg +1904 -0
  23. data/app/assets/images/state-flags/ma.svg +142 -0
  24. data/app/assets/images/state-flags/md.svg +23 -0
  25. data/app/assets/images/state-flags/me.svg +3277 -0
  26. data/app/assets/images/state-flags/mi.svg +792 -0
  27. data/app/assets/images/state-flags/mn.svg +2486 -0
  28. data/app/assets/images/state-flags/mo.svg +2726 -0
  29. data/app/assets/images/state-flags/ms.svg +9 -0
  30. data/app/assets/images/state-flags/mt.svg +1288 -0
  31. data/app/assets/images/state-flags/nc.svg +268 -0
  32. data/app/assets/images/state-flags/nd.svg +2365 -0
  33. data/app/assets/images/state-flags/ne.svg +1960 -0
  34. data/app/assets/images/state-flags/nh.svg +2 -0
  35. data/app/assets/images/state-flags/nj.svg +2265 -0
  36. data/app/assets/images/state-flags/nm.svg +6 -0
  37. data/app/assets/images/state-flags/nv.svg +468 -0
  38. data/app/assets/images/state-flags/ny.svg +995 -0
  39. data/app/assets/images/state-flags/oh.svg +49 -0
  40. data/app/assets/images/state-flags/ok.svg +1036 -0
  41. data/app/assets/images/state-flags/or.svg +1 -0
  42. data/app/assets/images/state-flags/pa.svg +304 -0
  43. data/app/assets/images/state-flags/pr.svg +7 -0
  44. data/app/assets/images/state-flags/ri.svg +29 -0
  45. data/app/assets/images/state-flags/sc.svg +84 -0
  46. data/app/assets/images/state-flags/sd.svg +446 -0
  47. data/app/assets/images/state-flags/thumb/ak.png +0 -0
  48. data/app/assets/images/state-flags/thumb/al.png +0 -0
  49. data/app/assets/images/state-flags/thumb/ar.png +0 -0
  50. data/app/assets/images/state-flags/thumb/az.png +0 -0
  51. data/app/assets/images/state-flags/thumb/ca.png +0 -0
  52. data/app/assets/images/state-flags/thumb/co.png +0 -0
  53. data/app/assets/images/state-flags/thumb/ct.png +0 -0
  54. data/app/assets/images/state-flags/thumb/dc.png +0 -0
  55. data/app/assets/images/state-flags/thumb/de.png +0 -0
  56. data/app/assets/images/state-flags/thumb/fl.png +0 -0
  57. data/app/assets/images/state-flags/thumb/ga.png +0 -0
  58. data/app/assets/images/state-flags/thumb/hi.png +0 -0
  59. data/app/assets/images/state-flags/thumb/ia.png +0 -0
  60. data/app/assets/images/state-flags/thumb/id.png +0 -0
  61. data/app/assets/images/state-flags/thumb/il.png +0 -0
  62. data/app/assets/images/state-flags/thumb/in.png +0 -0
  63. data/app/assets/images/state-flags/thumb/ks.png +0 -0
  64. data/app/assets/images/state-flags/thumb/ky.png +0 -0
  65. data/app/assets/images/state-flags/thumb/la.png +0 -0
  66. data/app/assets/images/state-flags/thumb/ma.png +0 -0
  67. data/app/assets/images/state-flags/thumb/md.png +0 -0
  68. data/app/assets/images/state-flags/thumb/me.png +0 -0
  69. data/app/assets/images/state-flags/thumb/mi.png +0 -0
  70. data/app/assets/images/state-flags/thumb/mn.png +0 -0
  71. data/app/assets/images/state-flags/thumb/mo.png +0 -0
  72. data/app/assets/images/state-flags/thumb/ms.png +0 -0
  73. data/app/assets/images/state-flags/thumb/mt.png +0 -0
  74. data/app/assets/images/state-flags/thumb/nc.png +0 -0
  75. data/app/assets/images/state-flags/thumb/nd.png +0 -0
  76. data/app/assets/images/state-flags/thumb/ne.png +0 -0
  77. data/app/assets/images/state-flags/thumb/nh.png +0 -0
  78. data/app/assets/images/state-flags/thumb/nj.png +0 -0
  79. data/app/assets/images/state-flags/thumb/nm.png +0 -0
  80. data/app/assets/images/state-flags/thumb/nv.png +0 -0
  81. data/app/assets/images/state-flags/thumb/ny.png +0 -0
  82. data/app/assets/images/state-flags/thumb/oh.png +0 -0
  83. data/app/assets/images/state-flags/thumb/ok.png +0 -0
  84. data/app/assets/images/state-flags/thumb/or.png +0 -0
  85. data/app/assets/images/state-flags/thumb/pa.png +0 -0
  86. data/app/assets/images/state-flags/thumb/pr.png +0 -0
  87. data/app/assets/images/state-flags/thumb/ri.png +0 -0
  88. data/app/assets/images/state-flags/thumb/sc.png +0 -0
  89. data/app/assets/images/state-flags/thumb/sd.png +0 -0
  90. data/app/assets/images/state-flags/thumb/tn.png +0 -0
  91. data/app/assets/images/state-flags/thumb/tx.png +0 -0
  92. data/app/assets/images/state-flags/thumb/ut.png +0 -0
  93. data/app/assets/images/state-flags/thumb/va.png +0 -0
  94. data/app/assets/images/state-flags/thumb/vt.png +0 -0
  95. data/app/assets/images/state-flags/thumb/wa.png +0 -0
  96. data/app/assets/images/state-flags/thumb/wi.png +0 -0
  97. data/app/assets/images/state-flags/thumb/wv.png +0 -0
  98. data/app/assets/images/state-flags/thumb/wy.png +0 -0
  99. data/app/assets/images/state-flags/tn.svg +23 -0
  100. data/app/assets/images/state-flags/tx.svg +16 -0
  101. data/app/assets/images/state-flags/ut.svg +3225 -0
  102. data/app/assets/images/state-flags/va.svg +1 -0
  103. data/app/assets/images/state-flags/vt.svg +397 -0
  104. data/app/assets/images/state-flags/wa.svg +2620 -0
  105. data/app/assets/images/state-flags/wi.svg +3611 -0
  106. data/app/assets/images/state-flags/wv.svg +1 -0
  107. data/app/assets/images/state-flags/wy.svg +210 -0
  108. data/app/controllers/concerns/studio/geo_detection.rb +198 -0
  109. data/app/controllers/studio/geo_settings_controller.rb +155 -0
  110. data/app/helpers/studio/geo_helper.rb +73 -0
  111. data/app/models/studio/geo_setting.rb +137 -0
  112. data/app/views/components/_geo_badge.html.erb +63 -0
  113. data/app/views/studio/geo_settings/edit.html.erb +368 -0
  114. data/db/migrate/20260818120000_create_studio_geo_settings.rb +46 -0
  115. data/lib/studio/engine.rb +30 -0
  116. data/lib/studio/geo/countries.rb +269 -0
  117. data/lib/studio/geo/lookup.rb +114 -0
  118. data/lib/studio/geo.rb +244 -0
  119. data/lib/studio/version.rb +1 -1
  120. data/lib/studio.rb +89 -0
  121. data/studio-engine.gemspec +4 -0
  122. metadata +135 -1
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ module Geo
5
+ # ISO 3166-1 alpha-2, code => short English name.
6
+ #
7
+ # Here because a country picker needs the whole list and an app should not
8
+ # have to add a gem (or hand-type 249 rows) to render one. Names are the
9
+ # short forms from the public-domain `iso3166.tab` that ships with tzdata —
10
+ # the same table every Unix carries — so they are the familiar ones ("United
11
+ # States", not "United States of America") and short enough for a grid.
12
+ #
13
+ # This is the vocabulary the COUNTRY half of a ban list is written in;
14
+ # subdivisions live in US_SUBDIVISIONS beside it.
15
+ COUNTRIES = {
16
+ "AD" => "Andorra",
17
+ "AE" => "United Arab Emirates",
18
+ "AF" => "Afghanistan",
19
+ "AG" => "Antigua & Barbuda",
20
+ "AI" => "Anguilla",
21
+ "AL" => "Albania",
22
+ "AM" => "Armenia",
23
+ "AO" => "Angola",
24
+ "AQ" => "Antarctica",
25
+ "AR" => "Argentina",
26
+ "AS" => "Samoa (American)",
27
+ "AT" => "Austria",
28
+ "AU" => "Australia",
29
+ "AW" => "Aruba",
30
+ "AX" => "Åland Islands",
31
+ "AZ" => "Azerbaijan",
32
+ "BA" => "Bosnia & Herzegovina",
33
+ "BB" => "Barbados",
34
+ "BD" => "Bangladesh",
35
+ "BE" => "Belgium",
36
+ "BF" => "Burkina Faso",
37
+ "BG" => "Bulgaria",
38
+ "BH" => "Bahrain",
39
+ "BI" => "Burundi",
40
+ "BJ" => "Benin",
41
+ "BL" => "St Barthelemy",
42
+ "BM" => "Bermuda",
43
+ "BN" => "Brunei",
44
+ "BO" => "Bolivia",
45
+ "BQ" => "Caribbean NL",
46
+ "BR" => "Brazil",
47
+ "BS" => "Bahamas",
48
+ "BT" => "Bhutan",
49
+ "BV" => "Bouvet Island",
50
+ "BW" => "Botswana",
51
+ "BY" => "Belarus",
52
+ "BZ" => "Belize",
53
+ "CA" => "Canada",
54
+ "CC" => "Cocos (Keeling) Islands",
55
+ "CD" => "Congo (Dem. Rep.)",
56
+ "CF" => "Central African Rep.",
57
+ "CG" => "Congo (Rep.)",
58
+ "CH" => "Switzerland",
59
+ "CI" => "Côte d’Ivoire",
60
+ "CK" => "Cook Islands",
61
+ "CL" => "Chile",
62
+ "CM" => "Cameroon",
63
+ "CN" => "China",
64
+ "CO" => "Colombia",
65
+ "CR" => "Costa Rica",
66
+ "CU" => "Cuba",
67
+ "CV" => "Cape Verde",
68
+ "CW" => "Curaçao",
69
+ "CX" => "Christmas Island",
70
+ "CY" => "Cyprus",
71
+ "CZ" => "Czech Republic",
72
+ "DE" => "Germany",
73
+ "DJ" => "Djibouti",
74
+ "DK" => "Denmark",
75
+ "DM" => "Dominica",
76
+ "DO" => "Dominican Republic",
77
+ "DZ" => "Algeria",
78
+ "EC" => "Ecuador",
79
+ "EE" => "Estonia",
80
+ "EG" => "Egypt",
81
+ "EH" => "Western Sahara",
82
+ "ER" => "Eritrea",
83
+ "ES" => "Spain",
84
+ "ET" => "Ethiopia",
85
+ "FI" => "Finland",
86
+ "FJ" => "Fiji",
87
+ "FK" => "Falkland Islands",
88
+ "FM" => "Micronesia",
89
+ "FO" => "Faroe Islands",
90
+ "FR" => "France",
91
+ "GA" => "Gabon",
92
+ "GB" => "Britain (UK)",
93
+ "GD" => "Grenada",
94
+ "GE" => "Georgia",
95
+ "GF" => "French Guiana",
96
+ "GG" => "Guernsey",
97
+ "GH" => "Ghana",
98
+ "GI" => "Gibraltar",
99
+ "GL" => "Greenland",
100
+ "GM" => "Gambia",
101
+ "GN" => "Guinea",
102
+ "GP" => "Guadeloupe",
103
+ "GQ" => "Equatorial Guinea",
104
+ "GR" => "Greece",
105
+ "GS" => "South Georgia & the South Sandwich Islands",
106
+ "GT" => "Guatemala",
107
+ "GU" => "Guam",
108
+ "GW" => "Guinea-Bissau",
109
+ "GY" => "Guyana",
110
+ "HK" => "Hong Kong",
111
+ "HM" => "Heard Island & McDonald Islands",
112
+ "HN" => "Honduras",
113
+ "HR" => "Croatia",
114
+ "HT" => "Haiti",
115
+ "HU" => "Hungary",
116
+ "ID" => "Indonesia",
117
+ "IE" => "Ireland",
118
+ "IL" => "Israel",
119
+ "IM" => "Isle of Man",
120
+ "IN" => "India",
121
+ "IO" => "British Indian Ocean Territory",
122
+ "IQ" => "Iraq",
123
+ "IR" => "Iran",
124
+ "IS" => "Iceland",
125
+ "IT" => "Italy",
126
+ "JE" => "Jersey",
127
+ "JM" => "Jamaica",
128
+ "JO" => "Jordan",
129
+ "JP" => "Japan",
130
+ "KE" => "Kenya",
131
+ "KG" => "Kyrgyzstan",
132
+ "KH" => "Cambodia",
133
+ "KI" => "Kiribati",
134
+ "KM" => "Comoros",
135
+ "KN" => "St Kitts & Nevis",
136
+ "KP" => "Korea (North)",
137
+ "KR" => "Korea (South)",
138
+ "KW" => "Kuwait",
139
+ "KY" => "Cayman Islands",
140
+ "KZ" => "Kazakhstan",
141
+ "LA" => "Laos",
142
+ "LB" => "Lebanon",
143
+ "LC" => "St Lucia",
144
+ "LI" => "Liechtenstein",
145
+ "LK" => "Sri Lanka",
146
+ "LR" => "Liberia",
147
+ "LS" => "Lesotho",
148
+ "LT" => "Lithuania",
149
+ "LU" => "Luxembourg",
150
+ "LV" => "Latvia",
151
+ "LY" => "Libya",
152
+ "MA" => "Morocco",
153
+ "MC" => "Monaco",
154
+ "MD" => "Moldova",
155
+ "ME" => "Montenegro",
156
+ "MF" => "St Martin (French)",
157
+ "MG" => "Madagascar",
158
+ "MH" => "Marshall Islands",
159
+ "MK" => "North Macedonia",
160
+ "ML" => "Mali",
161
+ "MM" => "Myanmar (Burma)",
162
+ "MN" => "Mongolia",
163
+ "MO" => "Macau",
164
+ "MP" => "Northern Mariana Islands",
165
+ "MQ" => "Martinique",
166
+ "MR" => "Mauritania",
167
+ "MS" => "Montserrat",
168
+ "MT" => "Malta",
169
+ "MU" => "Mauritius",
170
+ "MV" => "Maldives",
171
+ "MW" => "Malawi",
172
+ "MX" => "Mexico",
173
+ "MY" => "Malaysia",
174
+ "MZ" => "Mozambique",
175
+ "NA" => "Namibia",
176
+ "NC" => "New Caledonia",
177
+ "NE" => "Niger",
178
+ "NF" => "Norfolk Island",
179
+ "NG" => "Nigeria",
180
+ "NI" => "Nicaragua",
181
+ "NL" => "Netherlands",
182
+ "NO" => "Norway",
183
+ "NP" => "Nepal",
184
+ "NR" => "Nauru",
185
+ "NU" => "Niue",
186
+ "NZ" => "New Zealand",
187
+ "OM" => "Oman",
188
+ "PA" => "Panama",
189
+ "PE" => "Peru",
190
+ "PF" => "French Polynesia",
191
+ "PG" => "Papua New Guinea",
192
+ "PH" => "Philippines",
193
+ "PK" => "Pakistan",
194
+ "PL" => "Poland",
195
+ "PM" => "St Pierre & Miquelon",
196
+ "PN" => "Pitcairn",
197
+ "PR" => "Puerto Rico",
198
+ "PS" => "Palestine",
199
+ "PT" => "Portugal",
200
+ "PW" => "Palau",
201
+ "PY" => "Paraguay",
202
+ "QA" => "Qatar",
203
+ "RE" => "Réunion",
204
+ "RO" => "Romania",
205
+ "RS" => "Serbia",
206
+ "RU" => "Russia",
207
+ "RW" => "Rwanda",
208
+ "SA" => "Saudi Arabia",
209
+ "SB" => "Solomon Islands",
210
+ "SC" => "Seychelles",
211
+ "SD" => "Sudan",
212
+ "SE" => "Sweden",
213
+ "SG" => "Singapore",
214
+ "SH" => "St Helena",
215
+ "SI" => "Slovenia",
216
+ "SJ" => "Svalbard & Jan Mayen",
217
+ "SK" => "Slovakia",
218
+ "SL" => "Sierra Leone",
219
+ "SM" => "San Marino",
220
+ "SN" => "Senegal",
221
+ "SO" => "Somalia",
222
+ "SR" => "Suriname",
223
+ "SS" => "South Sudan",
224
+ "ST" => "Sao Tome & Principe",
225
+ "SV" => "El Salvador",
226
+ "SX" => "St Maarten (Dutch)",
227
+ "SY" => "Syria",
228
+ "SZ" => "Eswatini (Swaziland)",
229
+ "TC" => "Turks & Caicos Is",
230
+ "TD" => "Chad",
231
+ "TF" => "French S. Terr.",
232
+ "TG" => "Togo",
233
+ "TH" => "Thailand",
234
+ "TJ" => "Tajikistan",
235
+ "TK" => "Tokelau",
236
+ "TL" => "East Timor",
237
+ "TM" => "Turkmenistan",
238
+ "TN" => "Tunisia",
239
+ "TO" => "Tonga",
240
+ "TR" => "Turkey",
241
+ "TT" => "Trinidad & Tobago",
242
+ "TV" => "Tuvalu",
243
+ "TW" => "Taiwan",
244
+ "TZ" => "Tanzania",
245
+ "UA" => "Ukraine",
246
+ "UG" => "Uganda",
247
+ "UM" => "US minor outlying islands",
248
+ "US" => "United States",
249
+ "UY" => "Uruguay",
250
+ "UZ" => "Uzbekistan",
251
+ "VA" => "Vatican City",
252
+ "VC" => "St Vincent",
253
+ "VE" => "Venezuela",
254
+ "VG" => "Virgin Islands (UK)",
255
+ "VI" => "Virgin Islands (US)",
256
+ "VN" => "Vietnam",
257
+ "VU" => "Vanuatu",
258
+ "WF" => "Wallis & Futuna",
259
+ "WS" => "Samoa (western)",
260
+ "YE" => "Yemen",
261
+ "YT" => "Mayotte",
262
+ "ZA" => "South Africa",
263
+ "ZM" => "Zambia",
264
+ "ZW" => "Zimbabwe",
265
+ }.freeze
266
+
267
+ COUNTRY_CODES = COUNTRIES.keys.freeze
268
+ end
269
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ module Geo
5
+ # The IP -> location provider, configured once for every app that installs
6
+ # this gem. Separate from Studio::Geo itself, which stays pure: this is the
7
+ # half that knows about Geocoder, Rails.cache and the network.
8
+ #
9
+ # Two settings here are not preferences — they are the difference between a
10
+ # working gate and a silently dead one:
11
+ #
12
+ # use_https ipinfo.io 301-redirects http -> https with a non-JSON body,
13
+ # and Geocoder does not follow the redirect. A plain-HTTP
14
+ # lookup therefore returns NO RESULT ("response was not valid
15
+ # JSON") on every request — no exception, no log line the app
16
+ # would notice — and every geo-gated feature fails closed for
17
+ # every visitor. It cost turf-monster two weeks of blocked
18
+ # payments before anyone connected the two.
19
+ #
20
+ # cache The anonymous tier rate-limits by REQUESTING IP, and a
21
+ # platform's egress IPs are shared with every other tenant on
22
+ # that dyno, so the quota can be exhausted by traffic that is
23
+ # not yours. When it is, lookups 429 and every visitor reads as
24
+ # unplaceable. A cross-process cache keyed by the lookup URL
25
+ # (which embeds the IP) collapses repeat visitors and every
26
+ # uptime-monitor hit into one lookup per TTL. Geocoder writes
27
+ # the cache only for VALID responses, so a 429 or a timeout is
28
+ # never cached and simply retries on the next request.
29
+ module Lookup
30
+ # Adapts Rails.cache to the []/[]= duck Geocoder's Generic cache store
31
+ # expects, adding the TTL that Rails.cache.write supports and Geocoder
32
+ # never passes.
33
+ class RailsCache
34
+ def initialize(ttl: 86_400)
35
+ @ttl = ttl
36
+ end
37
+
38
+ def [](url)
39
+ ::Rails.cache.read(url)
40
+ end
41
+
42
+ def []=(url, value)
43
+ ::Rails.cache.write(url, value, expires_in: @ttl)
44
+ end
45
+ end
46
+
47
+ module_function
48
+
49
+ # Returns true when the provider was configured, false when it was left
50
+ # alone. Both are normal answers:
51
+ #
52
+ # · no geocoder gem — an app can include Studio::GeoDetection with no
53
+ # lookup at all and simply never place anyone; every gate then behaves
54
+ # as it does for an unplaceable visitor;
55
+ # · the HOST already configured Geocoder — its own configuration wins.
56
+ # A host that has set a cache store has set this up deliberately, and
57
+ # an engine that overwrote it would be changing a shipped app's IP
58
+ # provider on a gem bump. Measured: turf-monster's suite asserts its
59
+ # own cache class, and this method used to replace it.
60
+ #
61
+ # `force:` is how the engine's own re-configuration (and a host that wants
62
+ # the shared setup back) says "yes, mine".
63
+ def configure!(provider: Studio.geo_ip_provider,
64
+ api_key: nil,
65
+ timeout: Studio.geo_lookup_timeout,
66
+ cache_ttl: Studio.geo_cache_ttl,
67
+ force: true)
68
+ return false unless available?
69
+ return false if !force && host_configured?
70
+
71
+ api_key ||= resolved_api_key
72
+
73
+ options = {
74
+ ip_lookup: provider,
75
+ use_https: true,
76
+ timeout: timeout,
77
+ units: :mi,
78
+ cache: RailsCache.new(ttl: cache_ttl),
79
+ cache_options: { prefix: "geocoder:" }
80
+ }
81
+ # An authenticated key lifts the anonymous rate limit. A blank one is a
82
+ # safe no-op — the anonymous tier — so this stays dormant until the app
83
+ # sets a token rather than failing closed on a missing secret.
84
+ options[provider] = { api_key: api_key } if api_key
85
+
86
+ ::Geocoder.configure(**options)
87
+ true
88
+ end
89
+
90
+ # The marker of a deliberate host setup. Geocoder ships with no cache store
91
+ # at all, and every configuration worth respecting sets one — including
92
+ # this module's.
93
+ def host_configured?
94
+ !::Geocoder.config.cache.nil?
95
+ end
96
+
97
+ def available?
98
+ require "geocoder"
99
+ true
100
+ rescue LoadError
101
+ false
102
+ end
103
+
104
+ def resolved_api_key
105
+ key = Studio.geo_ip_api_key
106
+ key = key.call if key.respond_to?(:call)
107
+ key = ENV["IPINFO_API_TOKEN"] if key.nil? || key.to_s.strip.empty?
108
+ return nil if key.nil? || key.to_s.strip.empty?
109
+
110
+ key.to_s
111
+ end
112
+ end
113
+ end
114
+ end
data/lib/studio/geo.rb ADDED
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "geo/countries"
4
+
5
+ module Studio
6
+ # The house geo primitive: where a visitor appears to be, and whether this app
7
+ # is willing to serve them there.
8
+ #
9
+ # Deliberately PURE — no ActiveRecord, no Rails, no request, no Geocoder call.
10
+ # It takes plain strings and returns plain strings and booleans, which is what
11
+ # makes the load-bearing half (the blocking policy) unit-testable a branch at a
12
+ # time. The pieces that need the world live beside it:
13
+ #
14
+ # Studio::GeoDetection (controller concern) resolves IP -> region and caches
15
+ # it in the session, then asks THIS module the verdict
16
+ # Studio::GeoSetting (model) stores the operator's choices
17
+ # Studio::GeoHelper (view helper) renders flags from these codes
18
+ #
19
+ # VOCABULARY, because the two halves of an address are easy to conflate:
20
+ #
21
+ # country ISO 3166-1 alpha-2, upcased — "US", "CA", "GB"
22
+ # subdivision the state/province/region code WITHIN a country — "WA", "AB"
23
+ # region token the two joined, "US-WA", which is the only form stored
24
+ #
25
+ # The token matters. "CA" is California AND Canada, so a bare subdivision code
26
+ # is only meaningful next to its country. A ban list of bare codes cannot say
27
+ # which one it means; a list of tokens always can. Bare codes are still ACCEPTED
28
+ # on input (turf-monster stored years of them) and normalized to tokens against
29
+ # the app's home country on the way in — one vocabulary inside, tolerance at the
30
+ # door.
31
+ module Geo
32
+ # US states + DC + PR, name => code. The map is here rather than in a host app
33
+ # because two different surfaces need it: the geocoder hands back a full name
34
+ # ("Washington") that has to become a code, and the published exclusion list
35
+ # has to turn a code back into a name a reader recognises.
36
+ US_SUBDIVISIONS = {
37
+ "Alabama" => "AL", "Alaska" => "AK", "Arizona" => "AZ", "Arkansas" => "AR",
38
+ "California" => "CA", "Colorado" => "CO", "Connecticut" => "CT", "Delaware" => "DE",
39
+ "Florida" => "FL", "Georgia" => "GA", "Hawaii" => "HI", "Idaho" => "ID",
40
+ "Illinois" => "IL", "Indiana" => "IN", "Iowa" => "IA", "Kansas" => "KS",
41
+ "Kentucky" => "KY", "Louisiana" => "LA", "Maine" => "ME", "Maryland" => "MD",
42
+ "Massachusetts" => "MA", "Michigan" => "MI", "Minnesota" => "MN", "Mississippi" => "MS",
43
+ "Missouri" => "MO", "Montana" => "MT", "Nebraska" => "NE", "Nevada" => "NV",
44
+ "New Hampshire" => "NH", "New Jersey" => "NJ", "New Mexico" => "NM", "New York" => "NY",
45
+ "North Carolina" => "NC", "North Dakota" => "ND", "Ohio" => "OH", "Oklahoma" => "OK",
46
+ "Oregon" => "OR", "Pennsylvania" => "PA", "Rhode Island" => "RI", "South Carolina" => "SC",
47
+ "South Dakota" => "SD", "Tennessee" => "TN", "Texas" => "TX", "Utah" => "UT",
48
+ "Vermont" => "VT", "Virginia" => "VA", "Washington" => "WA", "West Virginia" => "WV",
49
+ "Wisconsin" => "WI", "Wyoming" => "WY",
50
+ "District of Columbia" => "DC", "Puerto Rico" => "PR"
51
+ }.freeze
52
+
53
+ # code => name, for the published exclusion list and the admin grid labels.
54
+ US_SUBDIVISION_NAMES = US_SUBDIVISIONS.invert.freeze
55
+
56
+ # The editor grid's order: alphabetical by code, DC and PR last, which is how
57
+ # every published US exclusion list a reader has seen is laid out.
58
+ US_SUBDIVISION_CODES = ((US_SUBDIVISIONS.values - %w[DC PR]).sort + %w[DC PR]).freeze
59
+
60
+ # Regional Indicator Symbol A. A country's two letters mapped into this block
61
+ # render as one flag glyph on every platform that ships emoji.
62
+ REGIONAL_INDICATOR_A = 0x1F1E6
63
+
64
+ COUNTRY_CODE = /\A[A-Za-z]{2}\z/
65
+ SUBDIVISION_CODE = /\A[A-Za-z]{1,3}\z/
66
+
67
+ module_function
68
+
69
+ # ---- normalization -----------------------------------------------------
70
+
71
+ # "us" / " US " -> "US"; anything that is not two ASCII letters -> nil. A
72
+ # geocoder that returns a full country name, an empty string, or nil all land
73
+ # on nil rather than becoming a code that means nothing.
74
+ def normalize_country(raw)
75
+ return nil if raw.nil?
76
+
77
+ code = raw.to_s.strip
78
+ return nil unless code.match?(COUNTRY_CODE)
79
+
80
+ code.upcase
81
+ end
82
+
83
+ # "Washington" -> "WA", "wa" -> "WA". A value that is neither a known US state
84
+ # name nor a short code is passed through UNCHANGED (upcased only when it is
85
+ # already code-shaped), because outside the US the geocoder's region string is
86
+ # frequently the only identifier there is — "Alberta" is a real answer, and
87
+ # dropping it would silently blank the visitor's location.
88
+ def normalize_subdivision(raw)
89
+ return nil if raw.nil?
90
+
91
+ value = raw.to_s.strip
92
+ return nil if value.empty?
93
+ return value.upcase if value.match?(SUBDIVISION_CODE)
94
+
95
+ US_SUBDIVISIONS[value] || value
96
+ end
97
+
98
+ # "US" + "WA" -> "US-WA". nil when either half is missing: a half-address is
99
+ # not a region, and storing one would match every visitor from that country.
100
+ def region_token(country, subdivision)
101
+ country = normalize_country(country)
102
+ subdivision = normalize_subdivision(subdivision)
103
+ return nil if country.nil? || subdivision.nil?
104
+
105
+ "#{country}-#{subdivision}"
106
+ end
107
+
108
+ # The inverse, tolerant of the legacy bare form. "US-WA" -> ["US", "WA"];
109
+ # "WA" -> [home_country, "WA"]. A bare code is read as a subdivision of the
110
+ # app's own country, which is the only reading that was ever meant when an app
111
+ # stored one.
112
+ def parse_region(token, home_country: "US")
113
+ value = token.to_s.strip
114
+ return [nil, nil] if value.empty?
115
+
116
+ # A dash means the token carries BOTH halves — including "CU-", which is a
117
+ # country with no subdivision (an app that blocks a whole country, and an
118
+ # operator simulating it). Without the dash the value is a bare subdivision
119
+ # of this app's own country, which is the only reading an older app ever
120
+ # meant when it stored one.
121
+ if value.include?("-")
122
+ head, tail = value.split("-", 2)
123
+ [normalize_country(head), normalize_subdivision(tail)]
124
+ else
125
+ [normalize_country(home_country), normalize_subdivision(value)]
126
+ end
127
+ end
128
+
129
+ # Whatever the operator typed or an older app stored, as one canonical token.
130
+ def normalize_region_token(token, home_country: "US")
131
+ country, subdivision = parse_region(token, home_country: home_country)
132
+ region_token(country, subdivision)
133
+ end
134
+
135
+ def country_name(code)
136
+ COUNTRIES[normalize_country(code)]
137
+ end
138
+
139
+ def subdivision_name(code, country: "US")
140
+ return nil if code.nil?
141
+ return US_SUBDIVISION_NAMES[normalize_subdivision(code)] if normalize_country(country) == "US"
142
+
143
+ nil
144
+ end
145
+
146
+ # ---- presentation ------------------------------------------------------
147
+
148
+ # The country's flag as a regional-indicator emoji pair.
149
+ #
150
+ # Why an emoji rather than an asset: shipping ~250 country SVGs is a lot of
151
+ # bytes for a 16px badge, and every platform already has the glyphs. Returns
152
+ # nil for anything that is not exactly two ASCII letters, so a blank, a
153
+ # malformed code, or a full country name renders text-only rather than
154
+ # emitting garbage codepoints.
155
+ def country_flag_emoji(alpha2)
156
+ code = normalize_country(alpha2)
157
+ return nil if code.nil?
158
+
159
+ code.chars.map { |c| c.ord - "A".ord + REGIONAL_INDICATOR_A }.pack("U*")
160
+ end
161
+
162
+ # True when the visitor is outside the app's home country, and therefore must
163
+ # NOT be shown a home-country subdivision flag. This is the load-bearing half
164
+ # of flag selection: a subdivision flag lookup matches on a bare code, so an
165
+ # Italian region normalising to "CA" would otherwise be shown the CALIFORNIA
166
+ # flag — a wrong answer that looks right.
167
+ def foreign?(country, home_country: "US")
168
+ code = normalize_country(country)
169
+ return false if code.nil?
170
+
171
+ code != normalize_country(home_country)
172
+ end
173
+
174
+ # ---- freshness ---------------------------------------------------------
175
+
176
+ # Should the caller re-run the IP lookup?
177
+ #
178
+ # A RESOLVED region is trusted for `ttl` (a day): people do not teleport, and
179
+ # the lookup costs a network round trip on every request that misses. A BLANK
180
+ # result is trusted only for `retry_ttl` (minutes), because a blank is usually
181
+ # a transient failure — a provider timeout, a rate limit, an IP the provider
182
+ # cannot place — and caching "nowhere" for a full day fails every geo-gated
183
+ # feature closed until the visitor's IP changes.
184
+ #
185
+ # Compared as STRINGS to match the session-stored Time#to_s stamps (UTC, and
186
+ # therefore lexicographically chronological). `now` is injectable so the TTL
187
+ # policy is testable without sleeping.
188
+ def stale?(detected_at:, resolved:, now: Time.now, ttl: 86_400, retry_ttl: 300)
189
+ return true if detected_at.nil? || detected_at.to_s.strip.empty?
190
+
191
+ window = resolved ? ttl : retry_ttl
192
+ detected_at.to_s < (now - window).to_s
193
+ end
194
+
195
+ # ---- policy ------------------------------------------------------------
196
+
197
+ # THE VERDICT. Is this visitor blocked?
198
+ #
199
+ # enforcing the operator's kill switch; false blocks nobody
200
+ # country/subdivision where the visitor appears to be
201
+ # banned_countries ISO alpha-2 codes this app refuses outright
202
+ # banned_subdivisions region tokens ("US-WA") — bare codes tolerated
203
+ # home_country this app's own country, for bare-code resolution
204
+ # fail_closed how an UNDETECTABLE home-country visitor is treated
205
+ #
206
+ # Three ways to be blocked, in order:
207
+ #
208
+ # 1. the resolved country is on the country list;
209
+ # 2. the resolved region is on the subdivision list;
210
+ # 3. FAIL CLOSED — the app blocks specific subdivisions of its own country,
211
+ # and this visitor is in that country with NO detectable subdivision (a
212
+ # VPN, a datacenter IP, a provider outage, a lookup timeout). A blank
213
+ # could be any of the blocked regions masked by a failed lookup, so it
214
+ # cannot be waved through.
215
+ #
216
+ # Rule 3 is deliberately narrower than "block every blank": it only fires when
217
+ # subdivision-level rules for the home country actually exist. An app that
218
+ # blocks nothing at that grain is hiding nothing, so failing its visitors
219
+ # closed would cost real users access to protect no rule. A resolved NON-home
220
+ # country with a blank subdivision stays allowed for the same reason — only
221
+ # the home-country ambiguity is dangerous.
222
+ def blocked?(country:, subdivision:, banned_countries: [], banned_subdivisions: [],
223
+ home_country: "US", enforcing: true, fail_closed: true)
224
+ return false unless enforcing
225
+
226
+ home = normalize_country(home_country)
227
+ country = normalize_country(country)
228
+ subdivision = normalize_subdivision(subdivision)
229
+
230
+ countries = Array(banned_countries).filter_map { |c| normalize_country(c) }
231
+ return true if country && countries.include?(country)
232
+
233
+ tokens = Array(banned_subdivisions).filter_map { |t| normalize_region_token(t, home_country: home) }
234
+ token = region_token(country, subdivision)
235
+ return true if token && tokens.include?(token)
236
+
237
+ return false unless fail_closed
238
+ return false unless subdivision.nil?
239
+ return false unless country == home
240
+
241
+ tokens.any? { |t| t.start_with?("#{home}-") }
242
+ end
243
+ end
244
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.56.4"
2
+ VERSION = "0.57.1"
3
3
  end