studio-engine 0.56.4 → 0.57.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/README.md +24 -9
  4. data/app/assets/images/state-flags/ak.svg +22 -0
  5. data/app/assets/images/state-flags/al.svg +6 -0
  6. data/app/assets/images/state-flags/ar.svg +244 -0
  7. data/app/assets/images/state-flags/az.svg +8 -0
  8. data/app/assets/images/state-flags/ca.svg +1 -0
  9. data/app/assets/images/state-flags/co.svg +9 -0
  10. data/app/assets/images/state-flags/ct.svg +2472 -0
  11. data/app/assets/images/state-flags/dc.svg +18 -0
  12. data/app/assets/images/state-flags/de.svg +1549 -0
  13. data/app/assets/images/state-flags/fl.svg +6784 -0
  14. data/app/assets/images/state-flags/ga.svg +71 -0
  15. data/app/assets/images/state-flags/hi.svg +11 -0
  16. data/app/assets/images/state-flags/ia.svg +1 -0
  17. data/app/assets/images/state-flags/id.svg +105 -0
  18. data/app/assets/images/state-flags/il.svg +538 -0
  19. data/app/assets/images/state-flags/in.svg +57 -0
  20. data/app/assets/images/state-flags/ks.svg +2575 -0
  21. data/app/assets/images/state-flags/ky.svg +2918 -0
  22. data/app/assets/images/state-flags/la.svg +1904 -0
  23. data/app/assets/images/state-flags/ma.svg +142 -0
  24. data/app/assets/images/state-flags/md.svg +23 -0
  25. data/app/assets/images/state-flags/me.svg +3277 -0
  26. data/app/assets/images/state-flags/mi.svg +792 -0
  27. data/app/assets/images/state-flags/mn.svg +2486 -0
  28. data/app/assets/images/state-flags/mo.svg +2726 -0
  29. data/app/assets/images/state-flags/ms.svg +9 -0
  30. data/app/assets/images/state-flags/mt.svg +1288 -0
  31. data/app/assets/images/state-flags/nc.svg +268 -0
  32. data/app/assets/images/state-flags/nd.svg +2365 -0
  33. data/app/assets/images/state-flags/ne.svg +1960 -0
  34. data/app/assets/images/state-flags/nh.svg +2 -0
  35. data/app/assets/images/state-flags/nj.svg +2265 -0
  36. data/app/assets/images/state-flags/nm.svg +6 -0
  37. data/app/assets/images/state-flags/nv.svg +468 -0
  38. data/app/assets/images/state-flags/ny.svg +995 -0
  39. data/app/assets/images/state-flags/oh.svg +49 -0
  40. data/app/assets/images/state-flags/ok.svg +1036 -0
  41. data/app/assets/images/state-flags/or.svg +1 -0
  42. data/app/assets/images/state-flags/pa.svg +304 -0
  43. data/app/assets/images/state-flags/pr.svg +7 -0
  44. data/app/assets/images/state-flags/ri.svg +29 -0
  45. data/app/assets/images/state-flags/sc.svg +84 -0
  46. data/app/assets/images/state-flags/sd.svg +446 -0
  47. data/app/assets/images/state-flags/thumb/ak.png +0 -0
  48. data/app/assets/images/state-flags/thumb/al.png +0 -0
  49. data/app/assets/images/state-flags/thumb/ar.png +0 -0
  50. data/app/assets/images/state-flags/thumb/az.png +0 -0
  51. data/app/assets/images/state-flags/thumb/ca.png +0 -0
  52. data/app/assets/images/state-flags/thumb/co.png +0 -0
  53. data/app/assets/images/state-flags/thumb/ct.png +0 -0
  54. data/app/assets/images/state-flags/thumb/dc.png +0 -0
  55. data/app/assets/images/state-flags/thumb/de.png +0 -0
  56. data/app/assets/images/state-flags/thumb/fl.png +0 -0
  57. data/app/assets/images/state-flags/thumb/ga.png +0 -0
  58. data/app/assets/images/state-flags/thumb/hi.png +0 -0
  59. data/app/assets/images/state-flags/thumb/ia.png +0 -0
  60. data/app/assets/images/state-flags/thumb/id.png +0 -0
  61. data/app/assets/images/state-flags/thumb/il.png +0 -0
  62. data/app/assets/images/state-flags/thumb/in.png +0 -0
  63. data/app/assets/images/state-flags/thumb/ks.png +0 -0
  64. data/app/assets/images/state-flags/thumb/ky.png +0 -0
  65. data/app/assets/images/state-flags/thumb/la.png +0 -0
  66. data/app/assets/images/state-flags/thumb/ma.png +0 -0
  67. data/app/assets/images/state-flags/thumb/md.png +0 -0
  68. data/app/assets/images/state-flags/thumb/me.png +0 -0
  69. data/app/assets/images/state-flags/thumb/mi.png +0 -0
  70. data/app/assets/images/state-flags/thumb/mn.png +0 -0
  71. data/app/assets/images/state-flags/thumb/mo.png +0 -0
  72. data/app/assets/images/state-flags/thumb/ms.png +0 -0
  73. data/app/assets/images/state-flags/thumb/mt.png +0 -0
  74. data/app/assets/images/state-flags/thumb/nc.png +0 -0
  75. data/app/assets/images/state-flags/thumb/nd.png +0 -0
  76. data/app/assets/images/state-flags/thumb/ne.png +0 -0
  77. data/app/assets/images/state-flags/thumb/nh.png +0 -0
  78. data/app/assets/images/state-flags/thumb/nj.png +0 -0
  79. data/app/assets/images/state-flags/thumb/nm.png +0 -0
  80. data/app/assets/images/state-flags/thumb/nv.png +0 -0
  81. data/app/assets/images/state-flags/thumb/ny.png +0 -0
  82. data/app/assets/images/state-flags/thumb/oh.png +0 -0
  83. data/app/assets/images/state-flags/thumb/ok.png +0 -0
  84. data/app/assets/images/state-flags/thumb/or.png +0 -0
  85. data/app/assets/images/state-flags/thumb/pa.png +0 -0
  86. data/app/assets/images/state-flags/thumb/pr.png +0 -0
  87. data/app/assets/images/state-flags/thumb/ri.png +0 -0
  88. data/app/assets/images/state-flags/thumb/sc.png +0 -0
  89. data/app/assets/images/state-flags/thumb/sd.png +0 -0
  90. data/app/assets/images/state-flags/thumb/tn.png +0 -0
  91. data/app/assets/images/state-flags/thumb/tx.png +0 -0
  92. data/app/assets/images/state-flags/thumb/ut.png +0 -0
  93. data/app/assets/images/state-flags/thumb/va.png +0 -0
  94. data/app/assets/images/state-flags/thumb/vt.png +0 -0
  95. data/app/assets/images/state-flags/thumb/wa.png +0 -0
  96. data/app/assets/images/state-flags/thumb/wi.png +0 -0
  97. data/app/assets/images/state-flags/thumb/wv.png +0 -0
  98. data/app/assets/images/state-flags/thumb/wy.png +0 -0
  99. data/app/assets/images/state-flags/tn.svg +23 -0
  100. data/app/assets/images/state-flags/tx.svg +16 -0
  101. data/app/assets/images/state-flags/ut.svg +3225 -0
  102. data/app/assets/images/state-flags/va.svg +1 -0
  103. data/app/assets/images/state-flags/vt.svg +397 -0
  104. data/app/assets/images/state-flags/wa.svg +2620 -0
  105. data/app/assets/images/state-flags/wi.svg +3611 -0
  106. data/app/assets/images/state-flags/wv.svg +1 -0
  107. data/app/assets/images/state-flags/wy.svg +210 -0
  108. data/app/controllers/concerns/studio/geo_detection.rb +198 -0
  109. data/app/controllers/studio/geo_settings_controller.rb +155 -0
  110. data/app/helpers/studio/geo_helper.rb +73 -0
  111. data/app/models/studio/geo_setting.rb +137 -0
  112. data/app/views/components/_geo_badge.html.erb +63 -0
  113. data/app/views/studio/geo_settings/edit.html.erb +368 -0
  114. data/db/migrate/20260818120000_create_studio_geo_settings.rb +46 -0
  115. data/lib/studio/engine.rb +30 -0
  116. data/lib/studio/geo/countries.rb +269 -0
  117. data/lib/studio/geo/lookup.rb +114 -0
  118. data/lib/studio/geo.rb +244 -0
  119. data/lib/studio/version.rb +1 -1
  120. data/lib/studio.rb +89 -0
  121. data/studio-engine.gemspec +4 -0
  122. metadata +135 -1
@@ -0,0 +1,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>