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,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# /admin/geo — the shared geo manager, plus /geo/check, the probe that reports
|
|
5
|
+
# what the server thinks of THIS visitor.
|
|
6
|
+
#
|
|
7
|
+
# A plain host-inherited controller whose view is a bare content wrapper, like
|
|
8
|
+
# /admin/emails and /admin/style, so it renders inside each host's application
|
|
9
|
+
# layout and picks up that app's navbar and theme.
|
|
10
|
+
#
|
|
11
|
+
# Three things an operator can do here, and each exists because doing it any
|
|
12
|
+
# other way means a deploy:
|
|
13
|
+
#
|
|
14
|
+
# 1. turn the gate on or off — the kill switch;
|
|
15
|
+
# 2. edit the blocked list — countries, and subdivisions of the home country;
|
|
16
|
+
# 3. SIMULATE a blocked location, so the blocked experience can be walked
|
|
17
|
+
# from a desk without a VPN.
|
|
18
|
+
#
|
|
19
|
+
# Routes are opt-in (Studio.draw_geo_routes) because turf-monster owns these
|
|
20
|
+
# exact helper names until its adoption lands. See Studio.routes.
|
|
21
|
+
class GeoSettingsController < ApplicationController
|
|
22
|
+
# This page ASKS the questions the concern answers — where is this visitor,
|
|
23
|
+
# are they blocked, are they simulating — so it includes the concern rather
|
|
24
|
+
# than assuming the host's ApplicationController did. A host that already
|
|
25
|
+
# includes it loses nothing: Rails' callback chain de-duplicates a repeated
|
|
26
|
+
# `before_action :detect_geo_state`, so detection still runs once.
|
|
27
|
+
include Studio::GeoDetection
|
|
28
|
+
|
|
29
|
+
# The probe is PUBLIC — a page's own JavaScript asks it whether this visitor
|
|
30
|
+
# is blocked, and that question has to be answerable before sign-in.
|
|
31
|
+
skip_before_action :require_authentication, only: :check, raise: false
|
|
32
|
+
before_action :require_admin, except: :check
|
|
33
|
+
|
|
34
|
+
# GET /geo/check — force a fresh detection and report the verdict.
|
|
35
|
+
#
|
|
36
|
+
# Forcing is the point: an operator moving between networks needs an answer
|
|
37
|
+
# about where they are NOW, not the one cached for the next day. A simulated
|
|
38
|
+
# location is left alone, or the probe would silently cancel the simulation
|
|
39
|
+
# it is supposed to describe.
|
|
40
|
+
def check
|
|
41
|
+
unless geo_override_active?
|
|
42
|
+
reset_geo_detection!
|
|
43
|
+
detect_geo_state
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
render json: {
|
|
47
|
+
country: geo_country,
|
|
48
|
+
subdivision: geo_state,
|
|
49
|
+
region: geo_region_token,
|
|
50
|
+
simulated: geo_override_active?,
|
|
51
|
+
blocked: geo_blocked?,
|
|
52
|
+
# The legacy key turf-monster's client already reads. Kept so an app can
|
|
53
|
+
# adopt this endpoint without shipping a JavaScript change on the same day.
|
|
54
|
+
state: geo_state
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
TABS = %w[states countries].freeze
|
|
59
|
+
|
|
60
|
+
def edit
|
|
61
|
+
@geo_setting = Studio::GeoSetting.current
|
|
62
|
+
# Which editor is open. It rides in the URL so a save can send the operator
|
|
63
|
+
# back to the one they were working in rather than resetting to states.
|
|
64
|
+
@tab = TABS.include?(params[:tab]) ? params[:tab] : "states"
|
|
65
|
+
# Where the simulate button would put you. The page NAMES it — "Simulate
|
|
66
|
+
# WA" beats "Simulate a blocked location", because the operator is about to
|
|
67
|
+
# be moved and should read where to.
|
|
68
|
+
@simulated_region = simulated_region
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def update
|
|
72
|
+
@geo_setting = Studio::GeoSetting.current
|
|
73
|
+
|
|
74
|
+
@geo_setting.enabled = ActiveModel::Type::Boolean.new.cast(params.dig(:geo_setting, :enabled))
|
|
75
|
+
@geo_setting.banned_subdivisions = submitted_subdivisions
|
|
76
|
+
@geo_setting.banned_countries = submitted_countries
|
|
77
|
+
@geo_setting.save!
|
|
78
|
+
|
|
79
|
+
redirect_to admin_geo_path(tab: submitted_tab), notice: "Geo settings updated."
|
|
80
|
+
rescue StandardError => e
|
|
81
|
+
redirect_to admin_geo_path(tab: submitted_tab), alert: "Failed to update: #{e.message}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# POST /admin/geo/toggle — stand in a blocked location, or stop.
|
|
85
|
+
def toggle_override
|
|
86
|
+
if geo_override_active?
|
|
87
|
+
session.delete(:geo_override)
|
|
88
|
+
return redirect_back fallback_location: admin_geo_path, notice: "Geo simulation cleared."
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
region = simulated_region
|
|
92
|
+
# Nothing is blocked yet, so there is no blocked experience to walk. Say
|
|
93
|
+
# that plainly instead of pinning the operator to a location that behaves
|
|
94
|
+
# exactly like the one they are already in.
|
|
95
|
+
if region.nil?
|
|
96
|
+
return redirect_back fallback_location: admin_geo_path,
|
|
97
|
+
alert: "Nothing is blocked yet — add a country or a state first."
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
session[:geo_override] = region
|
|
101
|
+
redirect_back fallback_location: admin_geo_path, notice: "Simulating #{place_name(region)}."
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# The page posts this list three different ways: a checkbox grid posts bare
|
|
107
|
+
# codes one per box ("WA"), a text field posts one comma-separated string
|
|
108
|
+
# ("CA-AB, MX-BC"), and the grid's hidden anchor posts a blank so that
|
|
109
|
+
# unchecking the LAST box still submits an empty list rather than nothing.
|
|
110
|
+
# Split and flatten all three into plain entries here; the model decides what
|
|
111
|
+
# each entry means.
|
|
112
|
+
def submitted_subdivisions
|
|
113
|
+
split_list(params.dig(:geo_setting, :banned_subdivisions))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def submitted_countries
|
|
117
|
+
split_list(params.dig(:geo_setting, :banned_countries))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The tab radio posts with the form, so "save" lands back where the operator
|
|
121
|
+
# was. Whitelisted rather than echoed: this value goes into a redirect URL.
|
|
122
|
+
def submitted_tab
|
|
123
|
+
TABS.include?(params[:tab]) ? params[:tab] : nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def split_list(raw)
|
|
127
|
+
Array(raw).flat_map { |value| value.to_s.split(/[\s,]+/) }.reject(&:blank?)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# WHERE the simulator stands. The app's configured choice wins; otherwise the
|
|
131
|
+
# first region this app actually blocks, which is the one worth walking. With
|
|
132
|
+
# no policy at all there is nothing to simulate, so it falls back to the home
|
|
133
|
+
# country's own first blocked subdivision — or, failing that, the home
|
|
134
|
+
# country itself, which at least exercises the resolved-location path.
|
|
135
|
+
# "US-WA" -> "WA" at home, "CU-" -> "CU" abroad. The token is the storage
|
|
136
|
+
# form; a person reading a flash message wants the place.
|
|
137
|
+
def place_name(region)
|
|
138
|
+
Studio::Geo.parse_region(region, home_country: Studio.geo_home_country).compact.last || region
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def simulated_region
|
|
142
|
+
configured = Studio.geo_simulated_region.presence
|
|
143
|
+
return Studio::Geo.normalize_region_token(configured, home_country: Studio.geo_home_country) if configured
|
|
144
|
+
|
|
145
|
+
subdivision = Studio::GeoSetting.effective_banned_subdivisions.first
|
|
146
|
+
return subdivision if subdivision
|
|
147
|
+
|
|
148
|
+
country = Studio::GeoSetting.effective_banned_countries.first
|
|
149
|
+
# "CU-" is a country with no subdivision — the shape Studio::Geo reads back
|
|
150
|
+
# as [country, nil], which is exactly what a country-wide block looks like
|
|
151
|
+
# to the policy.
|
|
152
|
+
country ? "#{country}-" : nil
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# View-side geo: the flag art and the words. The decisions all live in
|
|
5
|
+
# Studio::Geo — this is the thin layer that turns a code into something a page
|
|
6
|
+
# can render, and it is where the asset lookup lives because only a view knows
|
|
7
|
+
# about the asset pipeline.
|
|
8
|
+
#
|
|
9
|
+
# Method names are `geo_`-prefixed on purpose. Rails includes every helper
|
|
10
|
+
# module into every view, so an unprefixed `country_flag_emoji` here would
|
|
11
|
+
# collide with a host app's own helper of that name during the window between
|
|
12
|
+
# this gem release and that app deleting its copy — and the collision resolves
|
|
13
|
+
# silently, by include order, to whichever was loaded last.
|
|
14
|
+
module GeoHelper
|
|
15
|
+
# Subdivision flags ship WITH the gem (app/assets/images/state-flags) so an
|
|
16
|
+
# app gets the complete badge from an empty repository. Only the US set is
|
|
17
|
+
# shipped today; every other country renders the emoji flag instead, which
|
|
18
|
+
# needs no bytes at all.
|
|
19
|
+
FLAG_ASSET_DIR = File.expand_path("../../assets/images/state-flags", __dir__)
|
|
20
|
+
|
|
21
|
+
def geo_country_flag_emoji(alpha2)
|
|
22
|
+
Studio::Geo.country_flag_emoji(alpha2)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def geo_foreign?(country)
|
|
26
|
+
Studio::Geo.foreign?(country, home_country: Studio.geo_home_country)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def geo_subdivision_name(code, country: Studio.geo_home_country)
|
|
30
|
+
Studio::Geo.subdivision_name(code, country: country)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The asset path for a subdivision's flag, or nil when the gem ships no art
|
|
34
|
+
# for it. Nil is a normal answer, not an error: the badge renders the code
|
|
35
|
+
# text-only, which is exactly what a country outside the shipped set gets.
|
|
36
|
+
#
|
|
37
|
+
# Guarded on the COUNTRY as well as the code, and that guard is the important
|
|
38
|
+
# one: the lookup matches a bare two-letter code, so an Italian region
|
|
39
|
+
# normalising to "CA" would otherwise be handed the CALIFORNIA flag — a wrong
|
|
40
|
+
# answer that looks right.
|
|
41
|
+
#
|
|
42
|
+
# The guard is `== home`, NOT `unless foreign?`, and the difference is a case
|
|
43
|
+
# a mutation found in turf-monster's suite: an UNPARSEABLE country ("XX!")
|
|
44
|
+
# is not foreign by `foreign?` — deliberately, so the policy's fail-closed
|
|
45
|
+
# branch still treats it as home — and would therefore have been handed a
|
|
46
|
+
# home subdivision flag. Rendering art is the stricter question: show it only
|
|
47
|
+
# when the country is positively the home one.
|
|
48
|
+
# The small raster of the same flag, for a page that renders dozens at once.
|
|
49
|
+
# The SVG set is real vector art — several files run past a megabyte — so a
|
|
50
|
+
# 52-square grid of them is ~10 MB of downloads for something painted at 16px.
|
|
51
|
+
# These are 64x48 PNGs, ~4 KB each, rendered from the same source art.
|
|
52
|
+
def geo_subdivision_flag_thumb_path(code, country: Studio.geo_home_country)
|
|
53
|
+
geo_subdivision_flag_path(code, country: country, variant: "thumb/", extension: "png")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def geo_country_name(code)
|
|
57
|
+
Studio::Geo.country_name(code)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def geo_subdivision_flag_path(code, country: Studio.geo_home_country, variant: "", extension: "svg")
|
|
61
|
+
home = Studio::Geo.normalize_country(Studio.geo_home_country)
|
|
62
|
+
return nil unless Studio::Geo.normalize_country(country) == home
|
|
63
|
+
|
|
64
|
+
subdivision = Studio::Geo.normalize_subdivision(code)
|
|
65
|
+
return nil if subdivision.nil?
|
|
66
|
+
|
|
67
|
+
file = "#{variant}#{subdivision.downcase}.#{extension}"
|
|
68
|
+
return nil unless File.exist?(File.join(FLAG_ASSET_DIR, file))
|
|
69
|
+
|
|
70
|
+
image_path("state-flags/#{file}")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# The operator's geo choices for THIS app: whether the gate is live, which
|
|
5
|
+
# countries it refuses outright, and which subdivisions of which country.
|
|
6
|
+
#
|
|
7
|
+
# One row per app (keyed by Studio.app_name), exactly like ThemeSetting — the
|
|
8
|
+
# engine's other "this deployment's settings" table. The gate reads this row and
|
|
9
|
+
# the published exclusion page renders THIS row, so the policy a visitor is told
|
|
10
|
+
# about can never drift from the policy that is enforced.
|
|
11
|
+
#
|
|
12
|
+
# STORAGE VOCABULARY: `banned_subdivisions` holds region tokens — "US-WA", not
|
|
13
|
+
# "WA" — because a bare code cannot say whether "CA" is California or Canada.
|
|
14
|
+
# Bare codes are still accepted on the way in (an app migrating years of them
|
|
15
|
+
# has a list of bare codes) and normalized against Studio.geo_home_country
|
|
16
|
+
# before they are written. See Studio::Geo for the full vocabulary.
|
|
17
|
+
#
|
|
18
|
+
# NIL-SAFE THROUGHOUT, because the table is installed by a migration the host
|
|
19
|
+
# runs. An app that has not run it yet must still boot, still serve, and still
|
|
20
|
+
# answer "is this visitor blocked?" — with "no", since an app with no geo row
|
|
21
|
+
# has declared no policy to enforce.
|
|
22
|
+
class GeoSetting < ApplicationRecord
|
|
23
|
+
include Sluggable
|
|
24
|
+
|
|
25
|
+
self.table_name = "studio_geo_settings"
|
|
26
|
+
|
|
27
|
+
validates :app_name, presence: true, uniqueness: true
|
|
28
|
+
|
|
29
|
+
before_validation :normalize_lists
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# This app's row, or an unsaved instance carrying the configured defaults.
|
|
33
|
+
# Never nil, so a caller can always ask it a question.
|
|
34
|
+
def current
|
|
35
|
+
return new(default_attributes) unless table_ready?
|
|
36
|
+
|
|
37
|
+
find_by(app_name: Studio.app_name) || new(default_attributes)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The PUBLISHED exclusion list, as region tokens. Falls back to the
|
|
41
|
+
# configured defaults when no row has been provisioned yet, so a fresh app
|
|
42
|
+
# publishes the same list it enforces from its first request.
|
|
43
|
+
def effective_banned_subdivisions
|
|
44
|
+
setting = current
|
|
45
|
+
list = setting.persisted? ? setting.banned_subdivisions : Studio.geo_default_banned_subdivisions
|
|
46
|
+
normalize_tokens(list)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def effective_banned_countries
|
|
50
|
+
setting = current
|
|
51
|
+
list = setting.persisted? ? setting.banned_countries : Studio.geo_default_banned_countries
|
|
52
|
+
Array(list).filter_map { |code| Studio::Geo.normalize_country(code) }.uniq.sort
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The gate is LIVE — a provisioned, enabled row. When false nothing is
|
|
56
|
+
# geo-restricted at all, including the fail-closed branch: that is the
|
|
57
|
+
# operator's kill switch, and it has to mean OFF rather than "off except
|
|
58
|
+
# for the visitors we cannot place".
|
|
59
|
+
def enforcing?
|
|
60
|
+
setting = current
|
|
61
|
+
setting.persisted? && setting.enabled?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The verdict for one visitor. Pure policy lives in Studio::Geo; this
|
|
65
|
+
# method's only job is to hand it this app's stored choices.
|
|
66
|
+
def blocked?(country:, subdivision:)
|
|
67
|
+
setting = current
|
|
68
|
+
return false unless setting.persisted? && setting.enabled?
|
|
69
|
+
|
|
70
|
+
Studio::Geo.blocked?(
|
|
71
|
+
country: country,
|
|
72
|
+
subdivision: subdivision,
|
|
73
|
+
banned_countries: setting.banned_countries,
|
|
74
|
+
banned_subdivisions: setting.banned_subdivisions,
|
|
75
|
+
home_country: Studio.geo_home_country,
|
|
76
|
+
enforcing: true,
|
|
77
|
+
fail_closed: Studio.geo_fail_closed
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Bare subdivision codes for one country — what a checkbox grid renders and
|
|
82
|
+
# what a published list prints. "US-WA" -> "WA" for country "US".
|
|
83
|
+
def banned_subdivision_codes(country: Studio.geo_home_country)
|
|
84
|
+
prefix = "#{Studio::Geo.normalize_country(country)}-"
|
|
85
|
+
effective_banned_subdivisions.filter_map { |token| token.delete_prefix(prefix) if token.start_with?(prefix) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def default_attributes
|
|
89
|
+
{
|
|
90
|
+
app_name: Studio.app_name,
|
|
91
|
+
enabled: false,
|
|
92
|
+
banned_countries: Array(Studio.geo_default_banned_countries),
|
|
93
|
+
banned_subdivisions: normalize_tokens(Studio.geo_default_banned_subdivisions)
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def normalize_tokens(list)
|
|
98
|
+
Array(list)
|
|
99
|
+
.filter_map { |token| Studio::Geo.normalize_region_token(token, home_country: Studio.geo_home_country) }
|
|
100
|
+
.uniq
|
|
101
|
+
.sort
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def table_ready?
|
|
105
|
+
table_exists?
|
|
106
|
+
rescue ActiveRecord::ActiveRecordError, NameError
|
|
107
|
+
false
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Bare codes for the grid — the instance half of .banned_subdivision_codes,
|
|
112
|
+
# reading THIS row rather than the effective list.
|
|
113
|
+
def subdivision_codes(country: Studio.geo_home_country)
|
|
114
|
+
prefix = "#{Studio::Geo.normalize_country(country)}-"
|
|
115
|
+
Array(banned_subdivisions).filter_map { |token| token.delete_prefix(prefix) if token.to_s.start_with?(prefix) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def name_slug
|
|
119
|
+
"geo-#{app_name.to_s.parameterize}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
# One vocabulary in the column. Whatever the form posted — bare codes from a
|
|
125
|
+
# US grid, tokens from a country editor, blanks from unchecked boxes — is
|
|
126
|
+
# canonicalised here rather than at each read site, so a later reader never
|
|
127
|
+
# has to guess which form it is holding.
|
|
128
|
+
def normalize_lists
|
|
129
|
+
self.banned_subdivisions = self.class.normalize_tokens(banned_subdivisions)
|
|
130
|
+
self.banned_countries = Array(banned_countries)
|
|
131
|
+
.filter_map { |code| Studio::Geo.normalize_country(code) }
|
|
132
|
+
.uniq
|
|
133
|
+
.sort
|
|
134
|
+
self.app_name = Studio.app_name if app_name.blank?
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<%# The visitor's detected location — PUBLIC, no sign-in required.
|
|
2
|
+
|
|
3
|
+
Detection is IP-based and runs for every request (Studio::GeoDetection), so
|
|
4
|
+
the badge can always say where the browser appears to be:
|
|
5
|
+
|
|
6
|
+
· a subdivision flag + code for a home-country visitor ("WA")
|
|
7
|
+
· a country flag emoji + region name for anyone else ("🇨🇦 Alberta")
|
|
8
|
+
· a red "??" when the location cannot be resolved at all — which is not a
|
|
9
|
+
cosmetic state: under the fail-closed rule an undetectable home-country
|
|
10
|
+
visitor is treated as BLOCKED, and the badge has to say so rather than
|
|
11
|
+
quietly render nothing
|
|
12
|
+
|
|
13
|
+
Red also means blocked-by-policy, or a simulated location an operator has
|
|
14
|
+
pinned from /admin/geo.
|
|
15
|
+
|
|
16
|
+
THE BLOCKED LOOK IS AN ATTRIBUTE, not a class list, so anything that knows
|
|
17
|
+
the policy changed can repaint the badge by flipping one attribute — which is
|
|
18
|
+
exactly what /admin/geo's live preview does while an operator ticks regions,
|
|
19
|
+
before anything is saved.
|
|
20
|
+
|
|
21
|
+
Locals are optional. With none, the badge reads the request's own detection;
|
|
22
|
+
passing them renders a specimen (the style guide, a view test) with no
|
|
23
|
+
session at all. %>
|
|
24
|
+
<%
|
|
25
|
+
subdivision = local_assigns.key?(:subdivision) ? local_assigns[:subdivision] : geo_state
|
|
26
|
+
country = local_assigns.key?(:country) ? local_assigns[:country] : geo_country
|
|
27
|
+
blocked = local_assigns.key?(:blocked) ? local_assigns[:blocked] : geo_blocked?
|
|
28
|
+
simulated = local_assigns.key?(:simulated) ? local_assigns[:simulated] : geo_override_active?
|
|
29
|
+
|
|
30
|
+
# A home-country subdivision flag is ONLY correct for a home-country visitor.
|
|
31
|
+
# The lookup matches a bare two-letter code, so a foreign region normalising to
|
|
32
|
+
# "CA" would be shown the CALIFORNIA flag — wrong, and wrong in the way that
|
|
33
|
+
# looks right. Outside the home country we render the country's emoji flag and
|
|
34
|
+
# never consult the subdivision art at all.
|
|
35
|
+
foreign = geo_foreign?(country)
|
|
36
|
+
country_flag = foreign ? geo_country_flag_emoji(country) : nil
|
|
37
|
+
flag_path = foreign ? nil : geo_subdivision_flag_path(subdivision, country: country)
|
|
38
|
+
label = subdivision.presence || (foreign ? country : nil) || "??"
|
|
39
|
+
%>
|
|
40
|
+
<% unless @_studio_geo_badge_css %>
|
|
41
|
+
<% @_studio_geo_badge_css = true %>
|
|
42
|
+
<style>
|
|
43
|
+
/* One rule owns the blocked look, so a repaint is one attribute. */
|
|
44
|
+
.geo-badge[data-blocked="true"] {
|
|
45
|
+
background-color: rgb(239 68 68 / 0.1);
|
|
46
|
+
border-color: rgb(239 68 68 / 0.3);
|
|
47
|
+
color: rgb(248 113 113);
|
|
48
|
+
}
|
|
49
|
+
</style>
|
|
50
|
+
<% end %>
|
|
51
|
+
<span class="geo-badge inline-flex items-center gap-1.5 text-xs font-mono px-2 py-0.5 rounded-lg whitespace-nowrap
|
|
52
|
+
<%= subdivision.present? ? 'bg-surface-alt text-secondary border border-subtle' : 'bg-surface-alt text-muted border border-subtle' %>"
|
|
53
|
+
data-geo-badge
|
|
54
|
+
data-country="<%= country %>"
|
|
55
|
+
data-subdivision="<%= subdivision %>"
|
|
56
|
+
data-blocked="<%= (simulated || blocked).to_s %>">
|
|
57
|
+
<% if country_flag %>
|
|
58
|
+
<span aria-label="<%= country %>" role="img" style="line-height: 1; flex-shrink: 0;"><%= country_flag %></span>
|
|
59
|
+
<% elsif flag_path %>
|
|
60
|
+
<img src="<%= flag_path %>" alt="<%= subdivision %>" class="rounded-sm" style="height: 12px; width: 16px; object-fit: cover; flex-shrink: 0;">
|
|
61
|
+
<% end %>
|
|
62
|
+
<%= label %>
|
|
63
|
+
</span>
|