studio-engine 0.56.4 → 0.57.0

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,368 @@
1
+ <%# /admin/geo — the shared geo manager.
2
+ Three sections, in the order an operator asks the questions: where does this
3
+ server think I am, is the gate on, and what does it block. %>
4
+ <%
5
+ setting = @geo_setting
6
+ home_country = Studio.geo_home_country
7
+ home_grid = home_country == "US" ? Studio::Geo::US_SUBDIVISION_CODES : []
8
+ banned_codes = setting.subdivision_codes(country: home_country)
9
+ other_tokens = Array(setting.banned_subdivisions).reject { |t| t.to_s.start_with?("#{home_country}-") }
10
+ banned_nations = Array(setting.banned_countries)
11
+ sim_region = @simulated_region
12
+ sim_place = sim_region ? Studio::Geo.parse_region(sim_region, home_country: home_country).compact.last : nil
13
+ on_countries = @tab == "countries"
14
+ %>
15
+ <style>
16
+ /* A blocked square paints from the CHECKBOX, so a click shows immediately.
17
+ The unchecked look stays on the theme's own utility classes below; only the
18
+ blocked state is written here, in plain CSS rather than a `has-[:checked]:`
19
+ Tailwind variant, because a host's build may not scan this gem's views for a
20
+ class string that appears nowhere else in that app.
21
+ Unlayered on purpose: it must win over the utility classes it overrides. */
22
+ .geo-grid label:has(input:checked) {
23
+ background-color: rgb(239 68 68 / 0.1);
24
+ border-color: rgb(239 68 68 / 0.3);
25
+ color: rgb(248 113 113);
26
+ }
27
+ /* Keyboard users get the same affordance the mouse gets. */
28
+ .geo-grid label:focus-within { outline: 2px solid rgb(239 68 68 / 0.5); outline-offset: 1px; }
29
+
30
+ /* Summary chips wear the blocked look, because that is what they are. */
31
+ .geo-chip {
32
+ background-color: rgb(239 68 68 / 0.1);
33
+ border: 1px solid rgb(239 68 68 / 0.3);
34
+ color: rgb(248 113 113);
35
+ }
36
+ /* "Nothing blocked yet." shows only when the row is otherwise empty — one rule
37
+ instead of a server-side branch AND a client-side one that could disagree. */
38
+ [data-geo-summary]:has(.geo-chip) .geo-summary-empty { display: none; }
39
+
40
+ /* The two editors share one space; the checked radio decides which is open.
41
+ CSS rather than JavaScript so the tabs work before (and without) any script,
42
+ and because the radio is what carries the choice back through a save. */
43
+ .geo-panel { display: none; }
44
+ .geo-tabs:has(#geo_tab_states:checked) .geo-panel-states { display: block; }
45
+ .geo-tabs:has(#geo_tab_countries:checked) .geo-panel-countries { display: block; }
46
+ .geo-tab-label { cursor: pointer; }
47
+ .geo-tabs:has(#geo_tab_states:checked) label[for="geo_tab_states"],
48
+ .geo-tabs:has(#geo_tab_countries:checked) label[for="geo_tab_countries"] {
49
+ background-color: rgb(148 163 184 / 0.16);
50
+ color: inherit;
51
+ }
52
+ </style>
53
+
54
+ <div class="max-w-3xl mx-auto"
55
+ data-geo-page
56
+ data-geo-home="<%= home_country %>"
57
+ data-geo-country="<%= geo_country %>"
58
+ data-geo-subdivision="<%= geo_state %>"
59
+ data-geo-fail-closed="<%= Studio.geo_fail_closed %>">
60
+ <h1 class="text-2xl font-extrabold text-heading mb-6">Geo Settings</h1>
61
+
62
+ <%# -- What the server thinks of THIS visitor ---------------------------- %>
63
+ <div class="card p-6 mb-6">
64
+ <h2 class="text-lg font-bold text-heading mb-4">Current Detection</h2>
65
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
66
+ <div class="bg-inset rounded-lg p-4 text-center">
67
+ <div class="text-xs text-muted uppercase tracking-wider mb-1">Country</div>
68
+ <div class="text-2xl font-bold text-heading font-mono flex items-center justify-center gap-2">
69
+ <span role="img" aria-hidden="true"><%= geo_country_flag_emoji(geo_country) %></span>
70
+ <%= geo_country || "??" %>
71
+ </div>
72
+ </div>
73
+ <div class="bg-inset rounded-lg p-4 text-center">
74
+ <div class="text-xs text-muted uppercase tracking-wider mb-1">Subdivision</div>
75
+ <div class="text-2xl font-bold text-heading font-mono flex items-center justify-center gap-2">
76
+ <% if (here = geo_subdivision_flag_thumb_path(geo_state, country: geo_country)) %>
77
+ <img src="<%= here %>" alt="" class="rounded-sm" style="height: 16px; width: 21px; object-fit: cover;">
78
+ <% end %>
79
+ <%= geo_state || "??" %>
80
+ </div>
81
+ </div>
82
+ <%# The verdict, and the one tile the live preview rewrites: tick your own
83
+ region below and this answers before you save. %>
84
+ <div class="bg-inset rounded-lg p-4 text-center">
85
+ <div class="text-xs text-muted uppercase tracking-wider mb-1">Blocked?</div>
86
+ <div class="text-2xl font-bold font-mono" data-geo-verdict>
87
+ <span class="geo-when-blocked text-red-400">YES</span>
88
+ <span class="geo-when-allowed text-primary">NO</span>
89
+ </div>
90
+ </div>
91
+ <div class="bg-inset rounded-lg p-4 text-center">
92
+ <div class="text-xs text-muted uppercase tracking-wider mb-1">Simulation</div>
93
+ <div class="text-2xl font-bold font-mono <%= geo_override_active? ? 'text-red-400' : 'text-muted' %>"><%= geo_override_active? ? session[:geo_override] : "OFF" %></div>
94
+ </div>
95
+ </div>
96
+ <div class="mt-4 flex flex-wrap gap-3 items-center">
97
+ <%= button_to geo_override_active? ? "Clear simulation" : "Simulate #{sim_place || 'a blocked location'}",
98
+ admin_geo_toggle_path, method: :post,
99
+ class: "btn #{geo_override_active? ? 'btn-danger' : 'btn-outline'} btn-sm" %>
100
+ <span class="text-xs text-muted">
101
+ Simulation pins your session to a blocked region so you can walk the blocked experience without a VPN.
102
+ </span>
103
+ </div>
104
+ </div>
105
+
106
+ <%# scope: :geo_setting is REQUIRED, not stylistic. A namespaced model's param
107
+ key is `studio_geo_setting`, so a bare form_with would post fields the
108
+ controller (which reads params[:geo_setting]) never looks at — a Save that
109
+ redirects with "updated" and changes nothing. %>
110
+ <%= form_with(model: setting, scope: :geo_setting, url: admin_geo_update_path,
111
+ method: :patch, local: true) do |f| %>
112
+ <%# THE SUMMARY CARD. The switch, and what it is switching on — so the answer
113
+ to "what does this app block?" is the first thing on the page rather than
114
+ something you assemble by reading a 52-square grid. The chips follow the
115
+ editor live, so they describe the policy you are about to save. %>
116
+ <div class="card p-6 mb-6">
117
+ <h2 class="text-lg font-bold text-heading mb-4">Configuration</h2>
118
+
119
+ <div class="mb-6">
120
+ <label class="flex items-center gap-3 cursor-pointer">
121
+ <%= f.check_box :enabled, class: "w-5 h-5 rounded accent-primary" %>
122
+ <span class="text-heading font-bold">Enable geo-blocking</span>
123
+ </label>
124
+ <p class="text-sm text-muted mt-1 ml-8">
125
+ The kill switch. While this is off nothing is geo-restricted — including visitors this app cannot place.
126
+ </p>
127
+ </div>
128
+
129
+ <div class="mb-4">
130
+ <h3 class="text-xs text-muted uppercase tracking-wider mb-2">
131
+ Blocked regions in <%= home_country %>
132
+ <span class="font-mono">(<span data-geo-summary-count="states"><%= banned_codes.size %></span>)</span>
133
+ </h3>
134
+ <div class="flex flex-wrap gap-1.5" data-geo-summary="states">
135
+ <% banned_codes.sort.each do |code| %>
136
+ <span class="geo-chip inline-flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs font-mono">
137
+ <% if (flag = geo_subdivision_flag_thumb_path(code, country: home_country)) %>
138
+ <img src="<%= flag %>" alt="" class="rounded-sm" style="height: 12px; width: 16px; object-fit: cover;">
139
+ <% end %>
140
+ <%= code %>
141
+ </span>
142
+ <% end %>
143
+ <span class="text-xs text-muted geo-summary-empty">Nothing blocked yet.</span>
144
+ </div>
145
+ </div>
146
+
147
+ <div>
148
+ <h3 class="text-xs text-muted uppercase tracking-wider mb-2">
149
+ Blocked countries
150
+ <span class="font-mono">(<span data-geo-summary-count="countries"><%= banned_nations.size %></span>)</span>
151
+ </h3>
152
+ <div class="flex flex-wrap gap-1.5" data-geo-summary="countries">
153
+ <% banned_nations.sort.each do |code| %>
154
+ <span class="geo-chip inline-flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs font-mono">
155
+ <span role="img" aria-hidden="true" style="line-height: 1;"><%= geo_country_flag_emoji(code) %></span>
156
+ <%= code %>
157
+ </span>
158
+ <% end %>
159
+ <span class="text-xs text-muted geo-summary-empty">Nothing blocked yet.</span>
160
+ </div>
161
+ </div>
162
+ </div>
163
+
164
+ <%# THE EDITORS. Two grids, one open at a time. %>
165
+ <div class="card p-6 mb-6">
166
+ <div class="geo-tabs">
167
+ <%# The radios ride INSIDE the form on purpose: the browser posts the
168
+ checked one, so the controller can send you back to the tab you were
169
+ editing instead of resetting to states on every save. %>
170
+ <input type="radio" name="tab" id="geo_tab_states" value="states" class="sr-only" <%= "checked" unless on_countries %>>
171
+ <input type="radio" name="tab" id="geo_tab_countries" value="countries" class="sr-only" <%= "checked" if on_countries %>>
172
+
173
+ <div class="inline-flex gap-1 p-1 rounded-lg bg-inset border border-subtle mb-4 text-sm">
174
+ <label for="geo_tab_states" class="geo-tab-label px-3 py-1.5 rounded-md text-secondary">
175
+ Regions in <%= home_country %>
176
+ <span class="text-xs text-muted">(<span data-geo-summary-count="states"><%= banned_codes.size %></span>)</span>
177
+ </label>
178
+ <label for="geo_tab_countries" class="geo-tab-label px-3 py-1.5 rounded-md text-secondary">
179
+ Countries
180
+ <span class="text-xs text-muted">(<span data-geo-summary-count="countries"><%= banned_nations.size %></span>)</span>
181
+ </label>
182
+ </div>
183
+
184
+ <%# -- Subdivisions of the home country ---------------------------- %>
185
+ <div class="geo-panel geo-panel-states">
186
+ <% if home_grid.any? %>
187
+ <p class="text-xs text-muted mb-4">
188
+ Check the states where this app may not operate. Changes apply when you save.
189
+ </p>
190
+ <%# One hidden field so UNCHECKING the last box posts an empty list rather
191
+ than posting nothing — which Rails would read as "no change" and the
192
+ operator would read as a save that silently did nothing. %>
193
+ <%= hidden_field_tag "geo_setting[banned_subdivisions][]", "" %>
194
+ <div class="geo-grid geo-grid-states grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
195
+ <% home_grid.each do |code| %>
196
+ <%# The checkbox is the ONE source of truth for how this square looks:
197
+ the server sets `checked`, the CSS above paints it, and a click
198
+ repaints it immediately. Painting from a server-rendered class
199
+ instead — which this page did first — meant a click changed
200
+ nothing on screen until Save, so an operator toggling squares
201
+ reasonably concluded the grid was dead while it was in fact
202
+ recording every click. %>
203
+ <label title="<%= Studio::Geo.subdivision_name(code) || code %>"
204
+ class="flex items-center justify-center gap-1.5 p-2 rounded border cursor-pointer transition text-xs font-mono
205
+ bg-surface-alt border-subtle text-secondary hover:border-strong">
206
+ <input type="checkbox" name="geo_setting[banned_subdivisions][]" value="<%= code %>"
207
+ <%= 'checked' if banned_codes.include?(code) %>
208
+ class="sr-only">
209
+ <% if (flag = geo_subdivision_flag_thumb_path(code, country: home_country)) %>
210
+ <img src="<%= flag %>" alt="" class="rounded-sm"
211
+ style="height: 12px; width: 16px; object-fit: cover; flex-shrink: 0;">
212
+ <% end %>
213
+ <%= code %>
214
+ </label>
215
+ <% end %>
216
+ </div>
217
+ <% else %>
218
+ <p class="text-xs text-muted mb-3">
219
+ Region codes for <%= home_country %>, comma separated — <span class="font-mono">AB, BC</span>.
220
+ </p>
221
+ <%= text_field_tag "geo_setting[banned_subdivisions]", banned_codes.join(", "),
222
+ class: "input w-full font-mono" %>
223
+ <% end %>
224
+
225
+ <% if other_tokens.any? %>
226
+ <div class="mt-6">
227
+ <h3 class="text-sm font-bold text-heading mb-2">Blocked regions elsewhere</h3>
228
+ <p class="text-xs text-muted mb-3">
229
+ Regions outside <%= home_country %>, written as <span class="font-mono">COUNTRY-REGION</span>.
230
+ </p>
231
+ <%= text_field_tag "geo_setting[banned_subdivisions][]", other_tokens.join(", "),
232
+ class: "input w-full font-mono" %>
233
+ </div>
234
+ <% end %>
235
+ </div>
236
+
237
+ <%# -- Countries ---------------------------------------------------- %>
238
+ <div class="geo-panel geo-panel-countries">
239
+ <p class="text-xs text-muted mb-4">
240
+ Check the countries this app refuses outright. A blocked country blocks every visitor placed
241
+ there, whatever their region. Changes apply when you save.
242
+ </p>
243
+ <%= hidden_field_tag "geo_setting[banned_countries][]", "" %>
244
+ <div class="geo-grid geo-grid-countries grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2 max-h-96 overflow-y-auto pr-1">
245
+ <% Studio::Geo::COUNTRY_CODES.each do |code| %>
246
+ <label title="<%= Studio::Geo.country_name(code) || code %>"
247
+ class="flex items-center justify-center gap-1.5 p-2 rounded border cursor-pointer transition text-xs font-mono
248
+ bg-surface-alt border-subtle text-secondary hover:border-strong">
249
+ <input type="checkbox" name="geo_setting[banned_countries][]" value="<%= code %>"
250
+ <%= 'checked' if banned_nations.include?(code) %>
251
+ class="sr-only">
252
+ <span role="img" aria-hidden="true" style="line-height: 1; flex-shrink: 0;"><%= geo_country_flag_emoji(code) %></span>
253
+ <%= code %>
254
+ </label>
255
+ <% end %>
256
+ </div>
257
+ </div>
258
+ </div>
259
+ </div>
260
+
261
+ <div class="flex justify-end">
262
+ <%= f.submit "Save Settings", class: "btn btn-primary" %>
263
+ </div>
264
+ <% end %>
265
+ </div>
266
+
267
+ <script>
268
+ // LIVE PREVIEW. Tick your own region and the navbar badge (and the Blocked?
269
+ // tile) answer immediately, before anything is saved — so an operator can see
270
+ // what a rule will do to a real visitor rather than saving to find out.
271
+ //
272
+ // This mirrors Studio::Geo.blocked? on the client, deliberately and narrowly:
273
+ // same three rules, same order, same fail-closed narrowing. It decides nothing
274
+ // — the server re-decides on save and on every request afterwards — but a
275
+ // mirror can still drift, so turf-monster's browser lane asserts the painted
276
+ // result against a policy the server also enforces.
277
+ (function () {
278
+ var page = document.querySelector("[data-geo-page]");
279
+ if (!page) return;
280
+
281
+ var home = page.dataset.geoHome;
282
+ var country = page.dataset.geoCountry;
283
+ var subdivision = page.dataset.geoSubdivision;
284
+ var failClosed = page.dataset.geoFailClosed === "true";
285
+ var enabledBox = page.querySelector('input[type="checkbox"][name="geo_setting[enabled]"]');
286
+
287
+ function checkedValues(selector) {
288
+ return Array.prototype.map.call(page.querySelectorAll(selector + ":checked"), function (el) {
289
+ return el.value;
290
+ });
291
+ }
292
+
293
+ function blocked() {
294
+ if (!enabledBox || !enabledBox.checked) return false;
295
+
296
+ var countries = checkedValues('input[name="geo_setting[banned_countries][]"]');
297
+ if (country && countries.indexOf(country) !== -1) return true;
298
+
299
+ var subdivisions = checkedValues('input[name="geo_setting[banned_subdivisions][]"]');
300
+ if (subdivision && country === home && subdivisions.indexOf(subdivision) !== -1) return true;
301
+
302
+ // Fail closed, narrowed exactly as the server narrows it: only a
303
+ // home-country visitor with NO region, and only when region rules exist.
304
+ if (!failClosed) return false;
305
+ if (subdivision) return false;
306
+ if (country !== home) return false;
307
+ return subdivisions.length > 0;
308
+ }
309
+
310
+ // The summary chips are rebuilt from the editor's own squares, so they
311
+ // describe the policy you are ABOUT to save rather than the one on disk —
312
+ // and they are built from the square's own flag, so there is no second copy
313
+ // of the artwork to keep in step.
314
+ function refreshSummary(kind, selector) {
315
+ var row = page.querySelector('[data-geo-summary="' + kind + '"]');
316
+ var counts = page.querySelectorAll('[data-geo-summary-count="' + kind + '"]');
317
+ if (!row) return;
318
+
319
+ Array.prototype.forEach.call(row.querySelectorAll(".geo-chip"), function (chip) {
320
+ chip.remove();
321
+ });
322
+
323
+ var boxes = Array.prototype.filter.call(page.querySelectorAll(selector), function (box) {
324
+ return box.checked;
325
+ });
326
+ boxes.sort(function (a, b) { return a.value.localeCompare(b.value); });
327
+
328
+ boxes.forEach(function (box) {
329
+ var square = box.closest("label");
330
+ var chip = document.createElement("span");
331
+ chip.className = "geo-chip inline-flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs font-mono";
332
+ var art = square && square.querySelector("img, span[role='img']");
333
+ if (art) chip.appendChild(art.cloneNode(true));
334
+ chip.appendChild(document.createTextNode(box.value));
335
+ row.insertBefore(chip, row.querySelector(".geo-summary-empty"));
336
+ });
337
+
338
+ // The tab label and the summary heading carry the same number; a page that
339
+ // showed two different counts for one list would be its own bug report.
340
+ Array.prototype.forEach.call(counts, function (el) { el.textContent = boxes.length; });
341
+ }
342
+
343
+ function paint() {
344
+ refreshSummary("states", 'input[name="geo_setting[banned_subdivisions][]"]');
345
+ refreshSummary("countries", 'input[name="geo_setting[banned_countries][]"]');
346
+
347
+ var value = blocked() ? "blocked" : "allowed";
348
+ document.documentElement.setAttribute("data-geo-preview", value);
349
+ Array.prototype.forEach.call(document.querySelectorAll("[data-geo-badge]"), function (badge) {
350
+ // Only the badge showing THIS visitor's location previews; a specimen
351
+ // badge rendered with other locals keeps whatever it was given.
352
+ if (badge.dataset.country === country && (badge.dataset.subdivision || "") === (subdivision || "")) {
353
+ badge.setAttribute("data-blocked", blocked() ? "true" : "false");
354
+ }
355
+ });
356
+ }
357
+
358
+ page.addEventListener("change", paint);
359
+ paint();
360
+ })();
361
+ </script>
362
+
363
+ <style>
364
+ /* The verdict tile follows the same one attribute the badge does. */
365
+ .geo-when-blocked, .geo-when-allowed { display: none; }
366
+ [data-geo-preview="blocked"] .geo-when-blocked { display: inline; }
367
+ [data-geo-preview="allowed"] .geo-when-allowed { display: inline; }
368
+ </style>
@@ -0,0 +1,46 @@
1
+ # The operator's geo choices for ONE app — whether the geo gate is live, which
2
+ # countries it refuses, and which subdivisions of which country.
3
+ #
4
+ # One row per app (Studio.app_name), like theme_settings: an app deploys once,
5
+ # and its geo policy is a property of that deployment rather than of a user.
6
+ #
7
+ # Both lists are jsonb arrays rather than a join table on purpose. They are read
8
+ # on EVERY request (the gate runs before the action) and edited by one person a
9
+ # few times a year, so the cost that matters is the read — one row, no join — and
10
+ # the shape that matters is "the list, as the operator sees it".
11
+ class CreateStudioGeoSettings < ActiveRecord::Migration[7.2]
12
+ def change
13
+ # jsonb where it exists, json where it does not. Every app in the ecosystem
14
+ # runs Postgres today, but a migration that can only run on one adapter is a
15
+ # migration a new app can trip over before it has picked a database — and the
16
+ # engine's own test host is sqlite, so this is exercised rather than assumed.
17
+ json_type = connection.adapter_name.to_s.downcase.include?("postgres") ? :jsonb : :json
18
+
19
+ create_table :studio_geo_settings do |t|
20
+ t.string :app_name, null: false
21
+
22
+ # The kill switch. Default FALSE: installing the table must not start
23
+ # blocking anybody. An operator turns the gate on from /admin/geo once the
24
+ # lists say what they mean.
25
+ t.boolean :enabled, null: false, default: false
26
+
27
+ # ISO 3166-1 alpha-2 codes this app refuses outright — ["CU", "IR"].
28
+ t.column :banned_countries, json_type, default: []
29
+
30
+ # Region TOKENS — ["US-WA", "US-ID"] — never bare subdivision codes. "CA"
31
+ # is California and Canada; the country half is what tells them apart.
32
+ # Studio::GeoSetting normalizes bare input to tokens before writing, so an
33
+ # app migrating a legacy bare list does not have to.
34
+ t.column :banned_subdivisions, json_type, default: []
35
+
36
+ # Sluggable, for the same reason every other Studio settings row has one:
37
+ # the admin surfaces address records by slug, never by sequential id.
38
+ t.string :slug
39
+
40
+ t.timestamps
41
+ end
42
+
43
+ add_index :studio_geo_settings, :app_name, unique: true
44
+ add_index :studio_geo_settings, :slug, unique: true
45
+ end
46
+ end
data/lib/studio/engine.rb CHANGED
@@ -61,6 +61,13 @@ module Studio
61
61
  # Named on the CLASS, not bare: an initializer block is instance_exec'd on
62
62
  # an Engine INSTANCE, where a bare call resolves to nothing and boots red.
63
63
  app.config.assets.precompile += Studio::Engine.default_email_banner_logical_paths
64
+
65
+ # The subdivision flags behind the shared geo badge. Same reasoning as the
66
+ # banners above: a sprockets host needs each logical path enumerated, and
67
+ # enumerating from disk means adding a flag is a one-file change. Only the
68
+ # US set ships — every other country's badge renders an emoji flag, which
69
+ # costs no bytes at all.
70
+ app.config.assets.precompile += Studio::Engine.subdivision_flag_logical_paths
64
71
  end
65
72
 
66
73
  # Logical asset paths ("emails/magic-link.png") for every default banner the
@@ -72,12 +79,35 @@ module Studio
72
79
  .sort
73
80
  end
74
81
 
82
+ # Logical asset paths ("state-flags/wa.svg") for every subdivision flag the
83
+ # gem ships.
84
+ # Both the vector art and the small rasters behind it: the badge renders one
85
+ # SVG, /admin/geo's grid renders 52 PNGs (the SVG set is ~10 MB of real
86
+ # vector art, which is not a page's worth of downloads for 16px squares).
87
+ def self.subdivision_flag_logical_paths
88
+ Dir[File.expand_path("../../app/assets/images/state-flags/*", __dir__),
89
+ File.expand_path("../../app/assets/images/state-flags/thumb/*", __dir__)]
90
+ .select { |path| File.file?(path) }
91
+ .map { |path| path.include?("/thumb/") ? "state-flags/thumb/#{File.basename(path)}" : "state-flags/#{File.basename(path)}" }
92
+ .sort
93
+ end
94
+
75
95
  rake_tasks do
76
96
  load File.expand_path("../tasks/studio_email.rake", __dir__)
77
97
  load File.expand_path("../tasks/studio_ses.rake", __dir__)
78
98
  end
79
99
 
80
100
  config.after_initialize do
101
+ # Configure the IP -> location provider for every app that has the gem,
102
+ # so geo detection works the same way everywhere: HTTPS (without which
103
+ # ipinfo silently returns nothing), a 3s timeout, and a Rails.cache-backed
104
+ # IP cache. AFTER initialize, because the cache duck needs Rails.cache to
105
+ # exist. An app that configures Geocoder itself sets
106
+ # Studio.configure_geocoder = false — or simply configures Geocoder itself,
107
+ # which `force: false` leaves alone. An app with no geocoder gem is
108
+ # unaffected either way (configure! returns false and does nothing).
109
+ Studio::Geo::Lookup.configure!(force: false) if Studio.configure_geocoder
110
+
81
111
  # Validate the host app's User model satisfies the engine's contract.
82
112
  # See docs/USER_CONTRACT.md. Opt out with Studio.validate_user_contract = false.
83
113
  if defined?(::User) && ::User.is_a?(Class) &&