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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -1
- data/README.md +24 -9
- data/app/assets/images/state-flags/ak.svg +22 -0
- data/app/assets/images/state-flags/al.svg +6 -0
- data/app/assets/images/state-flags/ar.svg +244 -0
- data/app/assets/images/state-flags/az.svg +8 -0
- data/app/assets/images/state-flags/ca.svg +1 -0
- data/app/assets/images/state-flags/co.svg +9 -0
- data/app/assets/images/state-flags/ct.svg +2472 -0
- data/app/assets/images/state-flags/dc.svg +18 -0
- data/app/assets/images/state-flags/de.svg +1549 -0
- data/app/assets/images/state-flags/fl.svg +6784 -0
- data/app/assets/images/state-flags/ga.svg +71 -0
- data/app/assets/images/state-flags/hi.svg +11 -0
- data/app/assets/images/state-flags/ia.svg +1 -0
- data/app/assets/images/state-flags/id.svg +105 -0
- data/app/assets/images/state-flags/il.svg +538 -0
- data/app/assets/images/state-flags/in.svg +57 -0
- data/app/assets/images/state-flags/ks.svg +2575 -0
- data/app/assets/images/state-flags/ky.svg +2918 -0
- data/app/assets/images/state-flags/la.svg +1904 -0
- data/app/assets/images/state-flags/ma.svg +142 -0
- data/app/assets/images/state-flags/md.svg +23 -0
- data/app/assets/images/state-flags/me.svg +3277 -0
- data/app/assets/images/state-flags/mi.svg +792 -0
- data/app/assets/images/state-flags/mn.svg +2486 -0
- data/app/assets/images/state-flags/mo.svg +2726 -0
- data/app/assets/images/state-flags/ms.svg +9 -0
- data/app/assets/images/state-flags/mt.svg +1288 -0
- data/app/assets/images/state-flags/nc.svg +268 -0
- data/app/assets/images/state-flags/nd.svg +2365 -0
- data/app/assets/images/state-flags/ne.svg +1960 -0
- data/app/assets/images/state-flags/nh.svg +2 -0
- data/app/assets/images/state-flags/nj.svg +2265 -0
- data/app/assets/images/state-flags/nm.svg +6 -0
- data/app/assets/images/state-flags/nv.svg +468 -0
- data/app/assets/images/state-flags/ny.svg +995 -0
- data/app/assets/images/state-flags/oh.svg +49 -0
- data/app/assets/images/state-flags/ok.svg +1036 -0
- data/app/assets/images/state-flags/or.svg +1 -0
- data/app/assets/images/state-flags/pa.svg +304 -0
- data/app/assets/images/state-flags/pr.svg +7 -0
- data/app/assets/images/state-flags/ri.svg +29 -0
- data/app/assets/images/state-flags/sc.svg +84 -0
- data/app/assets/images/state-flags/sd.svg +446 -0
- data/app/assets/images/state-flags/thumb/ak.png +0 -0
- data/app/assets/images/state-flags/thumb/al.png +0 -0
- data/app/assets/images/state-flags/thumb/ar.png +0 -0
- data/app/assets/images/state-flags/thumb/az.png +0 -0
- data/app/assets/images/state-flags/thumb/ca.png +0 -0
- data/app/assets/images/state-flags/thumb/co.png +0 -0
- data/app/assets/images/state-flags/thumb/ct.png +0 -0
- data/app/assets/images/state-flags/thumb/dc.png +0 -0
- data/app/assets/images/state-flags/thumb/de.png +0 -0
- data/app/assets/images/state-flags/thumb/fl.png +0 -0
- data/app/assets/images/state-flags/thumb/ga.png +0 -0
- data/app/assets/images/state-flags/thumb/hi.png +0 -0
- data/app/assets/images/state-flags/thumb/ia.png +0 -0
- data/app/assets/images/state-flags/thumb/id.png +0 -0
- data/app/assets/images/state-flags/thumb/il.png +0 -0
- data/app/assets/images/state-flags/thumb/in.png +0 -0
- data/app/assets/images/state-flags/thumb/ks.png +0 -0
- data/app/assets/images/state-flags/thumb/ky.png +0 -0
- data/app/assets/images/state-flags/thumb/la.png +0 -0
- data/app/assets/images/state-flags/thumb/ma.png +0 -0
- data/app/assets/images/state-flags/thumb/md.png +0 -0
- data/app/assets/images/state-flags/thumb/me.png +0 -0
- data/app/assets/images/state-flags/thumb/mi.png +0 -0
- data/app/assets/images/state-flags/thumb/mn.png +0 -0
- data/app/assets/images/state-flags/thumb/mo.png +0 -0
- data/app/assets/images/state-flags/thumb/ms.png +0 -0
- data/app/assets/images/state-flags/thumb/mt.png +0 -0
- data/app/assets/images/state-flags/thumb/nc.png +0 -0
- data/app/assets/images/state-flags/thumb/nd.png +0 -0
- data/app/assets/images/state-flags/thumb/ne.png +0 -0
- data/app/assets/images/state-flags/thumb/nh.png +0 -0
- data/app/assets/images/state-flags/thumb/nj.png +0 -0
- data/app/assets/images/state-flags/thumb/nm.png +0 -0
- data/app/assets/images/state-flags/thumb/nv.png +0 -0
- data/app/assets/images/state-flags/thumb/ny.png +0 -0
- data/app/assets/images/state-flags/thumb/oh.png +0 -0
- data/app/assets/images/state-flags/thumb/ok.png +0 -0
- data/app/assets/images/state-flags/thumb/or.png +0 -0
- data/app/assets/images/state-flags/thumb/pa.png +0 -0
- data/app/assets/images/state-flags/thumb/pr.png +0 -0
- data/app/assets/images/state-flags/thumb/ri.png +0 -0
- data/app/assets/images/state-flags/thumb/sc.png +0 -0
- data/app/assets/images/state-flags/thumb/sd.png +0 -0
- data/app/assets/images/state-flags/thumb/tn.png +0 -0
- data/app/assets/images/state-flags/thumb/tx.png +0 -0
- data/app/assets/images/state-flags/thumb/ut.png +0 -0
- data/app/assets/images/state-flags/thumb/va.png +0 -0
- data/app/assets/images/state-flags/thumb/vt.png +0 -0
- data/app/assets/images/state-flags/thumb/wa.png +0 -0
- data/app/assets/images/state-flags/thumb/wi.png +0 -0
- data/app/assets/images/state-flags/thumb/wv.png +0 -0
- data/app/assets/images/state-flags/thumb/wy.png +0 -0
- data/app/assets/images/state-flags/tn.svg +23 -0
- data/app/assets/images/state-flags/tx.svg +16 -0
- data/app/assets/images/state-flags/ut.svg +3225 -0
- data/app/assets/images/state-flags/va.svg +1 -0
- data/app/assets/images/state-flags/vt.svg +397 -0
- data/app/assets/images/state-flags/wa.svg +2620 -0
- data/app/assets/images/state-flags/wi.svg +3611 -0
- data/app/assets/images/state-flags/wv.svg +1 -0
- data/app/assets/images/state-flags/wy.svg +210 -0
- data/app/controllers/concerns/studio/geo_detection.rb +198 -0
- data/app/controllers/studio/geo_settings_controller.rb +155 -0
- data/app/helpers/studio/geo_helper.rb +73 -0
- data/app/models/studio/geo_setting.rb +137 -0
- data/app/views/components/_geo_badge.html.erb +63 -0
- data/app/views/studio/geo_settings/edit.html.erb +368 -0
- data/db/migrate/20260818120000_create_studio_geo_settings.rb +46 -0
- data/lib/studio/engine.rb +30 -0
- data/lib/studio/geo/countries.rb +269 -0
- data/lib/studio/geo/lookup.rb +114 -0
- data/lib/studio/geo.rb +244 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +89 -0
- data/studio-engine.gemspec +4 -0
- 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
|
data/lib/studio/version.rb
CHANGED