super_settings 2.5.0 → 2.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5417df66afb91b92e9142075a083883d6884a5f148858b43738cafb4bbe5aea3
4
- data.tar.gz: 7156046f2de10818424ff1997a1aea922ea5c67ebea1c0fe2ce8ffbb5f8a9746
3
+ metadata.gz: 290e32e0c4e0a4512fa7e4192a91fd9e654d1500a28bae270c8f21ec9c1a8478
4
+ data.tar.gz: 11ff70806b5849faae35e1d05f226af696f40a0f60334880fe0944d7770d48c1
5
5
  SHA512:
6
- metadata.gz: a8fd1761f7cf0eeee7b30d06ea1dc7b766dde492c3b8786fb549469ee76e1d81321696ea1d8bef1a272896be6992ab5af598cf521e4c91090602f5fe809de146
7
- data.tar.gz: fc346701a55ad717e806d234780df064aebe15d981e1e5da65c4d435bcb6b8b13d6b5ac240d654e827e167ebd083dbb8e4319831bb7aa09aba94b44ac27b8f7f
6
+ metadata.gz: 912971873912df17a2ff9eb3c2462356161fb19ee7bb2c127264f12b59cfff2282ad1084eb62c3e62ec91afed8a49de22ad3fc0cb30a40d2982e230a951765dc
7
+ data.tar.gz: b843b3b01bbd4ab7e8a91328c20134379bb6f5a218d7bef0a34f0b3868aed9b7f41746b1cbb3949e76f3b22290fd44be7ec0e4a1af360057e7235bb2ac0f4fc7
data/CHANGELOG.md CHANGED
@@ -4,6 +4,43 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 2.6.1
8
+
9
+ ### Security
10
+
11
+ - Fixed a stored cross-site scripting (XSS) vulnerability where a setting key was interpolated into the history pagination links in the web UI without HTML escaping. A user with write access could craft a key that executed JavaScript in another user's browser.
12
+
13
+ ### Fixed
14
+
15
+ - Fixed `Setting.save!` raising a `NoMethodError` in non-Rails applications that relied on the implicit ActiveRecord storage default. The transaction now resolves the storage class through the public accessor instead of the uninitialized instance variable.
16
+ - Fixed `LocalCache#to_h` returning only the first element of `array` type settings. It now returns the full array value.
17
+ - Fixed `Setting#save!` always updating the `updated_at` timestamp (and triggering a write) even when nothing had changed, which caused unnecessary cache invalidation across processes.
18
+ - Fixed a race condition in `LocalCache` where a value read on a cache miss could overwrite a fresher value written by a concurrent refresh.
19
+ - Fixed `LocalCache#refresh` never picking up newly added settings if the cache had been loaded while the data store was empty.
20
+ - Fixed `LocalCache` returning mutable array values for settings added by a cache refresh or a cache miss. All cached values are now frozen so callers cannot mutate the shared cache.
21
+ - Fixed the web UI history view failing to render when a history record has no timestamp.
22
+ - Fixed a duplicate-key race in `ActiveRecordStorage#save!` that raised an unhandled `ActiveRecord::RecordNotUnique` when the same key was created concurrently. The conflict is now retried and merged.
23
+ - Fixed a bulk update against `HttpStorage` silently reporting success when the remote API rejected the changes. `bulk_update` now returns `false` and `save!` raises a `SuperSettings::Setting::PersistenceError` in this case so storage failures can be distinguished from validation errors. The storage failure message is reported in the `errors` payload so the web UI and API clients are told why the update failed.
24
+ - Fixed thread-safety issues in the cached S3 and MongoDB clients that could expose a stale client or permanently cache a `nil` client after a transient connection failure.
25
+ - Fixed `MongoDBStorage.find_by_key` returning records that reported `persisted?` as `false`.
26
+ - Fixed the escaping of `SuperSettings.authentication_url` when injected into the inline web UI JavaScript. URLs containing single quotes previously produced corrupted or invalid JavaScript.
27
+ - Fixed the Rack application returning 404 for all routes when mounted under a path (e.g. via `map` or Rails `mount`) without repeating the mount path in the constructor.
28
+ - Fixed `HttpClient` corrupting base URLs that include a query string when appending the trailing path separator.
29
+ - Fixed `HttpClient` retrying non-idempotent POST requests after a connection error, which could apply an update twice. The retry of a GET request now discards the failed connection instead of reusing it.
30
+ - Fixed the `/settings/updated_since` endpoint returning a 500 (or a misleading empty success) when the `time` parameter was missing or unparseable. It now returns a 400 Bad Request.
31
+ - Fixed the web UI POST endpoint returning a 500 for malformed JSON request bodies instead of a 400 Bad Request. The endpoint now also returns a 400 Bad Request when the `settings` parameter is missing or is not an array of hashes.
32
+ - Fixed an authenticated but unauthorized user being redirected to the login page (a potential redirect loop) instead of receiving a 403 Forbidden.
33
+ - Fixed the Rails layout helper using the raw dark mode selector instead of the resolved value, which could render a page with mismatched light/dark styling.
34
+ - Fixed `Coerce.boolean` returning `true` for a whitespace-only string.
35
+ - Fixed the Rails engine eagerly loading `ActiveJob::Base` during initialization.
36
+ - Added a missing `require "time"` so `Time.parse` based coercion works in non-Rails applications.
37
+
38
+ ## 2.6.0
39
+
40
+ ### Added
41
+
42
+ - Added `SuperSettings::Application#dark_mode_selector` configuration option that allows you to specify a CSS selector that sets dark mode when it matches an element in the page. This is an alternative to using the `color_scheme` option and allows for more flexible control of when dark mode is enabled.
43
+
7
44
  ## 2.5.0
8
45
 
9
46
  ### Added
data/README.md CHANGED
@@ -308,6 +308,13 @@ SuperSettings.configure do |config|
308
308
  # Set the color scheme to use for the Web UI. Options are :light (default), :dark, or :system.
309
309
  config.controller.color_scheme = :dark
310
310
 
311
+ # Set a CSS selector for enabling dark mode. When the selector matches an element on the page,
312
+ # dark mode styles will be applied. A theme toggle button will also be rendered in the header
313
+ # allowing users to switch between light and dark modes (persisted via localStorage).
314
+ # This is an alternative to the color_scheme option. If neither color_scheme nor dark_mode_selector
315
+ # is set, it defaults to "[data-theme=dark]" which enables the toggle automatically.
316
+ config.controller.dark_mode_selector = "[data-theme=dark]"
317
+
311
318
  # Add additional code to the controller. In this case we are adding code to ensure only
312
319
  # admins can access the functionality and changing the layout to use one defined by the application.
313
320
  config.controller.enhance do
@@ -461,15 +468,9 @@ This will work out of the box with the defaults for the storage engines when run
461
468
 
462
469
  - `REDIS_URL` - `redis://localhost:6379/0`
463
470
  - `REST_API_URL` - `http://localhost:3000/settings` (this is the default URL for the Rails application)
464
- - `S3_URL` - `s3://accesskey:secretkey@region-1/settings/settings.json` (the S3 endpoint will be set to `http://localhost:9000`)
471
+ - `S3_URL` - `s3://accesskey:secretkey@region-1/settings/settings.json` (the S3 endpoint will be set to `http://localhost:24456`)
465
472
  - `MONGODB_URL` - `mongodb://localhost:27017/super_settings`
466
473
 
467
- Finally, you can run the application in dark mode by setting the `COLOR_SCHEME` environment variable.
468
-
469
- ```bash
470
- COLOR_SCHEME=dark bundle exec rackup
471
- ```
472
-
473
474
  ## License
474
475
 
475
476
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/VERSION CHANGED
@@ -1 +1 @@
1
- 2.5.0
1
+ 2.6.1
@@ -29,6 +29,7 @@ module SuperSettings
29
29
  application_dir = File.expand_path(File.join("..", "..", "..", "lib", "super_settings", "application"), __dir__)
30
30
  erb = ERB.new(File.read(File.join(application_dir, "layout_vars.css.erb")))
31
31
  color_scheme = SuperSettings.configuration.controller.color_scheme
32
+ dark_mode_selector = SuperSettings.configuration.controller.resolved_dark_mode_selector
32
33
  erb.result(binding).html_safe
33
34
  end
34
35
  end
@@ -56,7 +56,7 @@ module SuperSettings
56
56
  window.__superSettingsI18n = #{translations_json};
57
57
  #{File.read(File.join(__dir__, "scripts.js"))}
58
58
  #{File.read(File.join(__dir__, "api.js"))}
59
- #{"SuperSettingsAPI.authenticationUrl = '#{SuperSettings.authentication_url.gsub("'", "\\'")}';" if SuperSettings.authentication_url}
59
+ #{"SuperSettingsAPI.authenticationUrl = #{SuperSettings.authentication_url.to_s.to_json.gsub("</", "<\\/")};" if SuperSettings.authentication_url}
60
60
  #{SuperSettings.web_ui_javascript}
61
61
  </script>
62
62
  HTML
@@ -222,6 +222,14 @@ module SuperSettings
222
222
  @color_scheme if defined?(@color_scheme)
223
223
  end
224
224
 
225
+ # A CSS selector that sets dark mode when it matches an element in the page.
226
+ # This is an alternative to using the color_scheme option.
227
+ #
228
+ # @return [String, nil]
229
+ def dark_mode_selector
230
+ @dark_mode_selector if defined?(@dark_mode_selector)
231
+ end
232
+
225
233
  # Whether the application is in read-only mode.
226
234
  #
227
235
  # @return [Boolean]
@@ -8,6 +8,18 @@
8
8
  <meta name="format-detection" content="telephone=no email=no date=no address=no">
9
9
  <meta name="robots" content="noindex, nofollow">
10
10
  <meta name="referrer" content="no-referrer-when-downgrade">
11
+ <% if dark_mode_selector %>
12
+ <script>
13
+ (function() {
14
+ try {
15
+ if (localStorage.getItem("super_settings_theme") === "dark") {
16
+ document.documentElement.setAttribute("data-theme", "dark");
17
+ }
18
+ } catch (e) {
19
+ }
20
+ })();
21
+ </script>
22
+ <% end %>
11
23
  <%= layout_style_tag %>
12
24
  <%= add_to_head %>
13
25
  </head>
@@ -16,6 +28,24 @@
16
28
  <header class="super-settings-page-header">
17
29
  <div class="super-settings-page-header-inner">
18
30
  <%= application_header %>
31
+ <% if dark_mode_selector %>
32
+ <button type="button" class="super-settings-theme-toggle" id="super-settings-theme-toggle" aria-label="Toggle dark mode">
33
+ <svg class="super-settings-theme-icon super-settings-theme-icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
34
+ <circle cx="12" cy="12" r="4"></circle>
35
+ <path d="M12 2v2"></path>
36
+ <path d="M12 20v2"></path>
37
+ <path d="m4.93 4.93 1.41 1.41"></path>
38
+ <path d="m17.66 17.66 1.41 1.41"></path>
39
+ <path d="M2 12h2"></path>
40
+ <path d="M20 12h2"></path>
41
+ <path d="m6.34 17.66-1.41 1.41"></path>
42
+ <path d="m19.07 4.93-1.41 1.41"></path>
43
+ </svg>
44
+ <svg class="super-settings-theme-icon super-settings-theme-icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
45
+ <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path>
46
+ </svg>
47
+ </button>
48
+ <% end %>
19
49
  </div>
20
50
  </header>
21
51
 
@@ -72,5 +102,31 @@
72
102
  </details>
73
103
  </footer>
74
104
  <% end %>
105
+ <% if dark_mode_selector %>
106
+ <script>
107
+ (function() {
108
+ var toggle = document.getElementById("super-settings-theme-toggle");
109
+ if (!toggle) return;
110
+
111
+ var html = document.documentElement;
112
+ toggle.addEventListener("click", function() {
113
+ var isDark = html.getAttribute("data-theme") === "dark";
114
+ if (isDark) {
115
+ html.removeAttribute("data-theme");
116
+ try {
117
+ localStorage.removeItem("super_settings_theme");
118
+ } catch (e) {
119
+ }
120
+ } else {
121
+ html.setAttribute("data-theme", "dark");
122
+ try {
123
+ localStorage.setItem("super_settings_theme", "dark");
124
+ } catch (e) {
125
+ }
126
+ }
127
+ });
128
+ })();
129
+ </script>
130
+ <% end %>
75
131
  </body>
76
132
  </html>
@@ -112,6 +112,43 @@ a:visited {
112
112
  justify-content: flex-end;
113
113
  }
114
114
 
115
+ .super-settings-theme-toggle {
116
+ display: flex;
117
+ align-items: center;
118
+ justify-content: center;
119
+ width: 36px;
120
+ height: 36px;
121
+ border: 1px solid transparent;
122
+ border-radius: 8px;
123
+ background: transparent;
124
+ color: #9ca3af;
125
+ cursor: pointer;
126
+ flex-shrink: 0;
127
+ transition: color 0.2s, border-color 0.2s;
128
+ }
129
+
130
+ .super-settings-theme-toggle:hover {
131
+ color: var(--link-color);
132
+ border-color: var(--link-color);
133
+ }
134
+
135
+ .super-settings-theme-icon {
136
+ width: 18px;
137
+ height: 18px;
138
+ }
139
+
140
+ .super-settings-theme-icon-moon {
141
+ display: none;
142
+ }
143
+
144
+ [data-theme="dark"] .super-settings-theme-icon-sun {
145
+ display: none;
146
+ }
147
+
148
+ [data-theme="dark"] .super-settings-theme-icon-moon {
149
+ display: block;
150
+ }
151
+
115
152
  /* ══════════════════════════════════════════
116
153
  LANGUAGE MENU (in footer)
117
154
  ══════════════════════════════════════════ */
@@ -13,7 +13,11 @@
13
13
  <% end %>
14
14
  <% if color_scheme == :system || color_scheme == :dark %>
15
15
  :root {
16
- --text-color: #e5e7eb;
16
+ <% elsif dark_mode_selector %>
17
+ :root<%= dark_mode_selector %> {
18
+ <% end %>
19
+ <% if color_scheme == :system || color_scheme == :dark || dark_mode_selector %>
20
+ --text-color: #eef0f8;
17
21
  --background-color: #1a1d23;
18
22
  --header-background-color: #21252b;
19
23
  --link-color: #2dd4bf;
@@ -413,8 +413,13 @@
413
413
  const historyItems = parent.querySelector(".super-settings-history-items");
414
414
  let itemsHTML = "";
415
415
  payload.histories.forEach(function(history) {
416
- const date = new Date(Date.parse(history.created_at));
417
- const dateString = dateFormatter().format(date);
416
+ let dateString = "";
417
+ if (history.created_at) {
418
+ const timestamp = Date.parse(history.created_at);
419
+ if (!isNaN(timestamp)) {
420
+ dateString = dateFormatter().format(new Date(timestamp));
421
+ }
422
+ }
418
423
  const value = (history.deleted ? '<em class="super-settings-text-danger">' + escapeHTML(t("history.deleted")) + '</em>' : escapeHTML(history.value));
419
424
  itemsHTML += `<div class="super-settings-history-item">
420
425
  <div class="super-settings-history-time">${escapeHTML(dateString)}</div>
@@ -427,10 +432,10 @@
427
432
  if (payload.previous_page_params || payload.next_page_params) {
428
433
  let paginationHTML = `<div class="super-settings-align-center">`;
429
434
  if (payload.previous_page_params) {
430
- paginationHTML += `<div style="float:left;"><a href="#" class="js-show-history" title="${escapeHTML(t("history.newer"))}" data-offset="${payload.previous_page_params.offset}" data-limit="${payload.previous_page_params.limit}" data-key="${payload.previous_page_params.key}")>&#8592; ${escapeHTML(t("history.newer"))}</a></div>`;
435
+ paginationHTML += `<div style="float:left;"><a href="#" class="js-show-history" title="${escapeHTML(t("history.newer"))}" data-offset="${payload.previous_page_params.offset}" data-limit="${payload.previous_page_params.limit}" data-key="${escapeHTML(payload.previous_page_params.key)}">&#8592; ${escapeHTML(t("history.newer"))}</a></div>`;
431
436
  }
432
437
  if (payload.next_page_params) {
433
- paginationHTML += `<div style="float:right;"><a href="#" class="js-show-history" title="${escapeHTML(t("history.older"))}" data-offset="${payload.next_page_params.offset}" data-limit="${payload.next_page_params.limit}" data-key="${payload.next_page_params.key}")>${escapeHTML(t("history.older"))} &#8594;</a></div>`;
438
+ paginationHTML += `<div style="float:right;"><a href="#" class="js-show-history" title="${escapeHTML(t("history.older"))}" data-offset="${payload.next_page_params.offset}" data-limit="${payload.next_page_params.limit}" data-key="${escapeHTML(payload.next_page_params.key)}">${escapeHTML(t("history.older"))} &#8594;</a></div>`;
434
439
  }
435
440
  paginationHTML += '<div style="clear:both;"></div>';
436
441
  parent.querySelector(".super-settings-history-container").insertAdjacentHTML("afterend", paginationHTML);
@@ -71,8 +71,10 @@
71
71
 
72
72
  <% if color_scheme == :system %>
73
73
  @media (prefers-color-scheme: dark) {
74
+ <% elsif dark_mode_selector %>
75
+ <%= dark_mode_selector %> {
74
76
  <% end %>
75
- <% if color_scheme == :system || color_scheme == :dark %>
77
+ <% if color_scheme == :system || color_scheme == :dark || dark_mode_selector %>
76
78
  .super-settings {
77
79
  /* Canvas */
78
80
  --bg-page: #1a1d23;
@@ -128,6 +130,6 @@
128
130
  --icon-disabled-color: #4b5563;
129
131
  }
130
132
  <% end %>
131
- <% if color_scheme == :system %>
133
+ <% if color_scheme == :system || dark_mode_selector %>
132
134
  }
133
135
  <% end %>
@@ -8,14 +8,16 @@ module SuperSettings
8
8
  include Helper
9
9
 
10
10
  # @param layout [String, Symbol] path to an ERB template to use as the layout around the application UI. You can
11
- # pass the symbol +:default+ to use the default layout that ships with the gem.
11
+ # pass the symbol +:default+ to use the default layout that ships with the gem.
12
12
  # @param add_to_head [String] HTML code to add to the <head> element on the page.
13
13
  # @param api_base_url [String] the base URL for the REST API.
14
14
  # @param color_scheme [Symbol] whether to use dark mode for the application UI. If +nil+, the user's system
15
- # preference will be used.
15
+ # preference will be used.
16
+ # @param dark_mode_selector [String] a CSS selector that sets dark mode when it matches an element in the page.
17
+ # This is an alternative to using the color_scheme option.
16
18
  # @param read_only [Boolean] whether to render the application in read-only mode (edit controls hidden).
17
19
  # @param locale [String] the locale code for translations (default: "en").
18
- def initialize(layout: nil, add_to_head: nil, api_base_url: nil, color_scheme: nil, read_only: false, locale: nil)
20
+ def initialize(layout: nil, add_to_head: nil, api_base_url: nil, color_scheme: nil, dark_mode_selector: nil, read_only: false, locale: nil)
19
21
  if layout
20
22
  layout = File.expand_path(File.join("application", "layout.html.erb"), __dir__) if layout == :default
21
23
  @layout = ERB.new(File.read(layout)) if layout
@@ -27,6 +29,7 @@ module SuperSettings
27
29
 
28
30
  @api_base_url = api_base_url
29
31
  @color_scheme = color_scheme&.to_sym
32
+ @dark_mode_selector = dark_mode_selector
30
33
  @read_only = !!read_only
31
34
  @locale = locale || SuperSettings::MiniI18n::DEFAULT_LOCALE
32
35
  end
@@ -42,20 +45,12 @@ module SuperSettings
42
45
  html
43
46
  end
44
47
 
45
- # Render the edit form HTML for a single setting.
46
- #
47
- # @return [String] the rendered HTML
48
- def render_edit
49
- template = ERB.new(File.read(File.expand_path(File.join("application", "edit.html.erb"), __dir__)))
50
- html = template.result(binding)
51
- html = render_layout { html } if @layout
52
- html = html.html_safe if html.respond_to?(:html_safe)
53
- html
54
- end
55
-
56
48
  private
57
49
 
58
- def render_layout
50
+ # The block is not called directly here; it is yielded to from the `<%= yield %>` call
51
+ # in the layout template which is evaluated with this method's binding. The block argument
52
+ # must be declared so that Ruby doesn't warn that the block may be ignored.
53
+ def render_layout(&block)
59
54
  @layout&.result(binding)
60
55
  end
61
56
  end
@@ -21,13 +21,10 @@ module SuperSettings
21
21
  # @param value [Object]
22
22
  # @return [Boolean]
23
23
  def boolean(value)
24
- if value == false
25
- false
26
- elsif blank?(value)
27
- nil
28
- else
29
- !FALSE_VALUES.include?(value.to_s.downcase)
30
- end
24
+ return false if value == false
25
+ return nil if blank?(value)
26
+
27
+ !FALSE_VALUES.include?(value.to_s.downcase)
31
28
  end
32
29
 
33
30
  # Cast a value to a Time object.
@@ -12,6 +12,8 @@ module SuperSettings
12
12
 
13
13
  # Configuration for the controller.
14
14
  class Controller
15
+ VALID_COLOR_SCHEMES = [:light, :dark, :system].freeze
16
+
15
17
  # @api private
16
18
  attr_reader :enhancement
17
19
 
@@ -25,12 +27,13 @@ module SuperSettings
25
27
  def initialize
26
28
  @superclass = nil
27
29
  @web_ui_enabled = true
28
- @color_scheme = false
30
+ @color_scheme = nil
29
31
  @changed_by_block = nil
30
32
  @enhancement = nil
31
33
  @application_name = nil
32
34
  @application_logo = nil
33
35
  @application_link = nil
36
+ @dark_mode_selector = nil
34
37
  end
35
38
 
36
39
  def superclass
@@ -41,10 +44,10 @@ module SuperSettings
41
44
  end
42
45
  end
43
46
 
44
- # Optinal name of the application displayed in the view.
47
+ # Optional name of the application displayed in the view.
45
48
  attr_accessor :application_name
46
49
 
47
- # Optional mage URL for the application logo.
50
+ # Optional image URL for the application logo.
48
51
  attr_accessor :application_logo
49
52
 
50
53
  # Optional URL for a link back to the rest of the application.
@@ -69,11 +72,30 @@ module SuperSettings
69
72
  end
70
73
 
71
74
  # Set dark mode for the web UI. Possible values are :light, :dark, or :system.
72
- # The default value is :light.
75
+ # When the value is not set (or is invalid), theme selection is left dynamic.
73
76
  attr_writer :color_scheme
74
77
 
78
+ # Return the configured color scheme.
79
+ #
80
+ # @return [Symbol, nil]
75
81
  def color_scheme
76
- (@color_scheme ||= :light).to_sym
82
+ scheme = @color_scheme&.to_sym
83
+ VALID_COLOR_SCHEMES.include?(scheme) ? scheme : nil
84
+ end
85
+
86
+ # Set a CSS selector that sets dark mode. This is an alternative to using the color_scheme
87
+ # option and can be used if you have a custom implementation for dark mode in your application.
88
+ # Dark mode will be enabled in the settings web UI when the selector matches an element in the page.
89
+ attr_accessor :dark_mode_selector
90
+
91
+ # Return the selector used to enable dark mode from host page state.
92
+ #
93
+ # @return [String, nil]
94
+ def resolved_dark_mode_selector
95
+ return @dark_mode_selector if @dark_mode_selector
96
+ return "[data-theme=dark]" if color_scheme.nil?
97
+
98
+ nil
77
99
  end
78
100
 
79
101
  # Enhance the controller. You can define methods or call controller class methods like
@@ -184,21 +206,28 @@ module SuperSettings
184
206
  @model = Model.new
185
207
  @controller = Controller.new
186
208
  @deferred_configs = []
209
+ @called = false
187
210
  end
188
211
 
189
212
  # Defer the execution of a block that will be yielded to with the config object. This
190
213
  # is needed in a Rails environment during initialization so that all the frameworks can
191
- # load before loading the settings.
214
+ # load before loading the settings. If the deferred configuration has already been run
215
+ # (i.e. the application has finished initializing), the block is called immediately.
192
216
  #
193
217
  # @api private
194
218
  def defer(&block)
195
- @deferred_configs << block
219
+ if @called
220
+ block.call(self)
221
+ else
222
+ @deferred_configs << block
223
+ end
196
224
  end
197
225
 
198
226
  # Call the block deferred during initialization.
199
227
  #
200
228
  # @api private
201
229
  def call
230
+ @called = true
202
231
  while (block = @deferred_configs.shift)
203
232
  block&.call(self)
204
233
  end
@@ -19,8 +19,13 @@ module SuperSettings
19
19
 
20
20
  # Render the HTML application for managing settings.
21
21
  def root
22
- html = SuperSettings::Application.new(read_only: super_settings_read_only?, locale: I18n.locale).render
23
- render html: html.html_safe, layout: true
22
+ application = SuperSettings::Application.new(
23
+ read_only: super_settings_read_only?,
24
+ locale: I18n.locale,
25
+ color_scheme: SuperSettings.configuration.controller.color_scheme,
26
+ dark_mode_selector: SuperSettings.configuration.controller.resolved_dark_mode_selector
27
+ )
28
+ render html: application.render.html_safe, layout: true
24
29
  end
25
30
 
26
31
  # API endpoint for getting active settings. See SuperSettings::RestAPI for details.
@@ -70,7 +75,12 @@ module SuperSettings
70
75
 
71
76
  # API endpoint for getting settings that have changed since specified time. See SuperSettings::RestAPI for details.
72
77
  def updated_since
73
- render json: SuperSettings::RestAPI.updated_since(params[:time])
78
+ result = SuperSettings::RestAPI.updated_since(params[:time])
79
+ if result
80
+ render json: result
81
+ else
82
+ render json: {error: "Invalid time parameter"}, status: 400
83
+ end
74
84
  end
75
85
 
76
86
  # API endpoint for checking if the user is authorized to edit settings.
@@ -10,8 +10,8 @@ module SuperSettings
10
10
  initializer("SuperSettings") do
11
11
  Rails.configuration.middleware.unshift(SuperSettings::Context::RackMiddleware)
12
12
 
13
- if defined?(ActiveJob::Base.around_perform)
14
- ActiveJob::Base.around_perform do |job, block|
13
+ ActiveSupport.on_load(:active_job) do
14
+ around_perform do |job, block|
15
15
  SuperSettings.context(&block)
16
16
  end
17
17
  end
@@ -27,8 +27,8 @@ module SuperSettings
27
27
  end
28
28
 
29
29
  def initialize(base_url, headers: nil, params: nil, timeout: nil, user: nil, password: nil)
30
- base_url = "#{base_url}/" unless base_url.end_with?("/")
31
30
  @base_uri = URI(base_url)
31
+ @base_uri.path = "#{@base_uri.path}/" unless @base_uri.path.end_with?("/")
32
32
  @base_uri.query = query_string(params) if params
33
33
  @headers = headers ? DEFAULT_HEADERS.merge(headers) : DEFAULT_HEADERS
34
34
  @timeout = timeout || DEFAULT_TIMEOUT
@@ -53,34 +53,35 @@ module SuperSettings
53
53
 
54
54
  def send_request(request)
55
55
  set_headers(request)
56
- response_payload = nil
57
56
  attempts = 0
58
57
 
59
- with_connection do |http|
60
- http.start unless http.started?
61
- response = http.request(request)
62
-
63
- begin
64
- response.value # raises exception unless response is a success
65
- response_payload = JSON.parse(response.body)
66
- rescue Net::ProtocolError
67
- if [404, 410].include?(response.code.to_i)
68
- raise NotFoundError.new("#{response.code} #{response.message}")
69
- elsif response.code.to_i == 422
70
- raise InvalidRecordError.new("#{response.code} #{response.message}", errors: JSON.parse(response.body)["errors"])
71
- else
72
- raise Error.new("#{response.code} #{response.message}")
58
+ begin
59
+ with_connection do |http|
60
+ http.start unless http.started?
61
+ response = http.request(request)
62
+
63
+ begin
64
+ response.value # raises exception unless response is a success
65
+ JSON.parse(response.body)
66
+ rescue Net::ProtocolError
67
+ if [404, 410].include?(response.code.to_i)
68
+ raise NotFoundError.new("#{response.code} #{response.message}")
69
+ elsif response.code.to_i == 422
70
+ raise InvalidRecordError.new("#{response.code} #{response.message}", errors: JSON.parse(response.body)["errors"])
71
+ else
72
+ raise Error.new("#{response.code} #{response.message}")
73
+ end
74
+ rescue JSON::JSONError => e
75
+ raise Error.new(e.message)
73
76
  end
74
- rescue JSON::JSONError => e
75
- raise Error.new(e.message)
76
77
  end
77
- rescue IOError, Errno::ECONNRESET => connection_error
78
+ rescue IOError, Errno::ECONNRESET, Errno::EPIPE => connection_error
79
+ # Only retry idempotent requests; a POST may have already been processed by
80
+ # the server before the connection failed.
78
81
  attempts += 1
79
- retry if attempts <= 1
82
+ retry if attempts <= 1 && request.is_a?(Net::HTTP::Get)
80
83
  raise connection_error
81
84
  end
82
-
83
- response_payload
84
85
  end
85
86
 
86
87
  def with_connection(&block)