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,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) &&
|