super_settings 2.6.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: 3c33c68388f12bb6216f928e778f5a4965bf11f263bc315f0a9ce9261f9d4637
4
- data.tar.gz: fc7e9bd62a5412843c0da1a2accf93812869ae85cf91c8b7accd0eb496c9cf00
3
+ metadata.gz: 290e32e0c4e0a4512fa7e4192a91fd9e654d1500a28bae270c8f21ec9c1a8478
4
+ data.tar.gz: 11ff70806b5849faae35e1d05f226af696f40a0f60334880fe0944d7770d48c1
5
5
  SHA512:
6
- metadata.gz: d85f6b31e1757e36fbfe6486851ee2ccfdf10b13bca742ed90910898761c2215ca053ee2a91ad204b65913d765b66084b03e802544985c3d3c263d692c54480d
7
- data.tar.gz: 17a61ebac8696d0d2b1ca09229b0826d8885853e858f3ebcb2956cad00d56cf35f9e0928afc4c4e0987d270be3b9b23ab52cf18c91c91874281e3ac2239e81ad
6
+ metadata.gz: 912971873912df17a2ff9eb3c2462356161fb19ee7bb2c127264f12b59cfff2282ad1084eb62c3e62ec91afed8a49de22ad3fc0cb30a40d2982e230a951765dc
7
+ data.tar.gz: b843b3b01bbd4ab7e8a91328c20134379bb6f5a218d7bef0a34f0b3868aed9b7f41746b1cbb3949e76f3b22290fd44be7ec0e4a1af360057e7235bb2ac0f4fc7
data/CHANGELOG.md CHANGED
@@ -4,6 +4,37 @@ 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
+
7
38
  ## 2.6.0
8
39
 
9
40
  ### Added
data/README.md CHANGED
@@ -468,7 +468,7 @@ This will work out of the box with the defaults for the storage engines when run
468
468
 
469
469
  - `REDIS_URL` - `redis://localhost:6379/0`
470
470
  - `REST_API_URL` - `http://localhost:3000/settings` (this is the default URL for the Rails application)
471
- - `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`)
472
472
  - `MONGODB_URL` - `mongodb://localhost:27017/super_settings`
473
473
 
474
474
  ## License
data/VERSION CHANGED
@@ -1 +1 @@
1
- 2.6.0
1
+ 2.6.1
@@ -29,7 +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.dark_mode_selector
32
+ dark_mode_selector = SuperSettings.configuration.controller.resolved_dark_mode_selector
33
33
  erb.result(binding).html_safe
34
34
  end
35
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
@@ -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);
@@ -45,20 +45,12 @@ module SuperSettings
45
45
  html
46
46
  end
47
47
 
48
- # Render the edit form HTML for a single setting.
49
- #
50
- # @return [String] the rendered HTML
51
- def render_edit
52
- template = ERB.new(File.read(File.expand_path(File.join("application", "edit.html.erb"), __dir__)))
53
- html = template.result(binding)
54
- html = render_layout { html } if @layout
55
- html = html.html_safe if html.respond_to?(:html_safe)
56
- html
57
- end
58
-
59
48
  private
60
49
 
61
- 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)
62
54
  @layout&.result(binding)
63
55
  end
64
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.
@@ -206,21 +206,28 @@ module SuperSettings
206
206
  @model = Model.new
207
207
  @controller = Controller.new
208
208
  @deferred_configs = []
209
+ @called = false
209
210
  end
210
211
 
211
212
  # Defer the execution of a block that will be yielded to with the config object. This
212
213
  # is needed in a Rails environment during initialization so that all the frameworks can
213
- # 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.
214
216
  #
215
217
  # @api private
216
218
  def defer(&block)
217
- @deferred_configs << block
219
+ if @called
220
+ block.call(self)
221
+ else
222
+ @deferred_configs << block
223
+ end
218
224
  end
219
225
 
220
226
  # Call the block deferred during initialization.
221
227
  #
222
228
  # @api private
223
229
  def call
230
+ @called = true
224
231
  while (block = @deferred_configs.shift)
225
232
  block&.call(self)
226
233
  end
@@ -75,7 +75,12 @@ module SuperSettings
75
75
 
76
76
  # API endpoint for getting settings that have changed since specified time. See SuperSettings::RestAPI for details.
77
77
  def updated_since
78
- 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
79
84
  end
80
85
 
81
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)
@@ -12,10 +12,6 @@ module SuperSettings
12
12
  NOT_DEFINED = Object.new.freeze
13
13
  private_constant :NOT_DEFINED
14
14
 
15
- # @private
16
- DRIFT_FACTOR = 10
17
- private_constant :DRIFT_FACTOR
18
-
19
15
  # Number of seconds that the cache will be considered fresh. The database will only be
20
16
  # checked for changed settings at most this often.
21
17
  attr_reader :refresh_interval
@@ -47,13 +43,15 @@ module SuperSettings
47
43
  value = NOT_DEFINED
48
44
  else
49
45
  setting = Setting.find_by_key(key)
50
- value = (setting ? setting.value : NOT_DEFINED)
46
+ value = (setting ? setting.value.freeze : NOT_DEFINED)
51
47
  # Guard against caching too many cache missees; at some point it's better to slam
52
48
  # the database rather than run out of memory.
53
49
  if setting || @cache.size < 100_000
54
50
  @lock.synchronize do
55
- # For case where one thread could be iterating over the cache while it's updated causing an error
56
- @cache = @cache.merge(key => value).freeze
51
+ # Don't overwrite an entry added by a concurrent refresh; it is at least as
52
+ # fresh as the value read here. A new hash is set so that one thread can
53
+ # iterate over the cache while it's updated without causing an error.
54
+ @cache = @cache.merge(key => value).freeze unless @cache.include?(key)
57
55
  end
58
56
  end
59
57
  end
@@ -91,8 +89,7 @@ module SuperSettings
91
89
  def to_h
92
90
  ensure_cache_up_to_date!
93
91
  hash = {}
94
- @cache.each do |key, data|
95
- value, _ = data
92
+ @cache.each do |key, value|
96
93
  hash[key] = value unless value == NOT_DEFINED
97
94
  end
98
95
  hash
@@ -150,8 +147,6 @@ module SuperSettings
150
147
  return if @refreshing
151
148
 
152
149
  @next_check_at = Time.now + @refresh_interval
153
- return if @cache.empty?
154
-
155
150
  @refreshing = true
156
151
  end
157
152
 
@@ -199,7 +194,7 @@ module SuperSettings
199
194
  return if Coerce.blank?(setting.key)
200
195
 
201
196
  @lock.synchronize do
202
- @cache = @cache.merge(setting.key => setting.value)
197
+ @cache = @cache.merge(setting.key => setting.value.freeze).freeze
203
198
  end
204
199
  end
205
200
 
@@ -220,7 +215,7 @@ module SuperSettings
220
215
  changed_settings = {}
221
216
  start_time = Time.now
222
217
  Setting.updated_since(last_refresh_time - 1).each do |setting|
223
- value = (setting.deleted? ? NOT_DEFINED : setting.value)
218
+ value = (setting.deleted? ? NOT_DEFINED : setting.value.freeze)
224
219
  changed_settings[setting.key] = value
225
220
  end
226
221
  set_cache_values(start_time) { @cache.merge(changed_settings) }
@@ -249,13 +244,5 @@ module SuperSettings
249
244
  @cache = block.call.freeze
250
245
  end
251
246
  end
252
-
253
- # Recursively freeze a hash.
254
- def deep_freeze_hash(hash)
255
- hash.each_value do |value|
256
- deep_freeze_hash(value) if value.is_a?(Hash)
257
- end
258
- hash.freeze
259
- end
260
247
  end
261
248
  end
@@ -78,21 +78,21 @@ module SuperSettings
78
78
  # Load every JSON file from the locales directory, keyed by filename
79
79
  # stem (e.g. "en").
80
80
  def load_all_locales
81
- if development_mode?
82
- @mutex.synchronize { @cache = {} }
83
- end
84
-
85
- return @cache unless @cache.empty?
81
+ return @cache unless @cache.empty? || development_mode?
86
82
 
87
83
  @mutex.synchronize do
88
- return @cache unless @cache.empty?
84
+ return @cache unless @cache.empty? || development_mode?
89
85
 
86
+ # Build into a new hash so that other threads never see a partially
87
+ # loaded cache.
88
+ cache = {}
90
89
  Dir.glob(File.join(locales_dir, "*.json")).each do |path|
91
90
  code = File.basename(path, ".json").downcase
92
- @cache[code] = JSON.parse(File.read(path))
91
+ cache[code] = JSON.parse(File.read(path))
93
92
  rescue JSON::ParserError
94
93
  # Skip malformed locale files
95
94
  end
95
+ @cache = cache
96
96
  end
97
97
 
98
98
  @cache
@@ -103,7 +103,8 @@ module SuperSettings
103
103
  end
104
104
 
105
105
  def development_mode?
106
- ENV.fetch("RACK_ENV", ENV.fetch("RAILS_ENV", "development")) == "development"
106
+ env = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || ENV["APP_ENV"] || "development"
107
+ env == "development"
107
108
  end
108
109
  end
109
110
  end
@@ -52,8 +52,10 @@ module SuperSettings
52
52
  def call(env)
53
53
  if @path_prefix.empty? || "#{env["SCRIPT_NAME"]}#{env["PATH_INFO"]}".start_with?(@path_prefix)
54
54
  handle_request(env)
55
- else
55
+ elsif @app
56
56
  @app.call(env)
57
+ else
58
+ [404, {"content-type" => "text/plain"}, ["Not found"]]
57
59
  end
58
60
  end
59
61
 
@@ -129,7 +131,16 @@ module SuperSettings
129
131
 
130
132
  def handle_request(env)
131
133
  request = Rack::Request.new(env)
132
- path = request.path[@path_prefix.length, request.path.length]
134
+ script_name = env["SCRIPT_NAME"].to_s
135
+ full_path = "#{script_name}#{env["PATH_INFO"]}"
136
+ path = if !@path_prefix.empty? && full_path.start_with?(@path_prefix)
137
+ full_path[@path_prefix.length, full_path.length]
138
+ elsif @path_prefix.empty? && !script_name.empty?
139
+ # The application is mounted at SCRIPT_NAME, which the router has already consumed.
140
+ env["PATH_INFO"].to_s
141
+ else
142
+ full_path
143
+ end
133
144
  if request.get?
134
145
  if (path == "/" || path == "") && web_ui_enabled?
135
146
  return handle_root_request(request)
@@ -197,10 +208,10 @@ module SuperSettings
197
208
  [200, headers, [application.render]]
198
209
  end
199
210
 
200
- if [401, 403].include?(response.first)
201
- if SuperSettings.authentication_url
202
- response = [302, {"location" => SuperSettings.authentication_url}, []]
203
- end
211
+ # Only unauthenticated requests are redirected to the login page. An authenticated
212
+ # user who is denied access would just be bounced back here in a redirect loop.
213
+ if response.first == 401 && SuperSettings.authentication_url
214
+ response = [302, {"location" => SuperSettings.authentication_url}, []]
204
215
  end
205
216
 
206
217
  response
@@ -225,7 +236,11 @@ module SuperSettings
225
236
 
226
237
  def handle_update_request(request)
227
238
  check_authorization(request, write_required: true) do |user|
228
- result = SuperSettings::RestAPI.update(post_params(request)["settings"], changed_by(user))
239
+ params = post_params(request)
240
+ settings = params["settings"] if params
241
+ next json_response(400, error: "Invalid request") unless valid_settings_params?(settings)
242
+
243
+ result = SuperSettings::RestAPI.update(settings, changed_by(user))
229
244
  if result[:success]
230
245
  json_response(200, result)
231
246
  else
@@ -253,7 +268,12 @@ module SuperSettings
253
268
 
254
269
  def handle_updated_since_request(request)
255
270
  check_authorization(request) do |user|
256
- json_response(200, RestAPI.updated_since(request.params["time"]))
271
+ result = RestAPI.updated_since(request.params["time"])
272
+ if result
273
+ json_response(200, result)
274
+ else
275
+ json_response(400, error: "Invalid time parameter")
276
+ end
257
277
  end
258
278
  end
259
279
 
@@ -323,12 +343,24 @@ module SuperSettings
323
343
  nil
324
344
  end
325
345
 
346
+ # Returns the request parameters merged with any JSON request body. Returns nil
347
+ # if the request body is not valid JSON.
326
348
  def post_params(request)
327
349
  if request.content_type.to_s.match?(/\Aapplication\/json/i) && request.body
328
- request.params.merge(JSON.parse(request.body.read))
350
+ body = JSON.parse(request.body.read)
351
+ return nil unless body.is_a?(Hash)
352
+
353
+ request.params.merge(body)
329
354
  else
330
355
  request.params
331
356
  end
357
+ rescue JSON::ParserError
358
+ nil
359
+ end
360
+
361
+ # The settings parameter in an update request must be an array of hashes.
362
+ def valid_settings_params?(settings)
363
+ settings.is_a?(Array) && settings.all? { |setting| setting.is_a?(Hash) }
332
364
  end
333
365
  end
334
366
  end
@@ -163,7 +163,7 @@ module SuperSettings
163
163
  end
164
164
 
165
165
  payload[:histories] = histories.collect do |history|
166
- history_values = {value: history.value, changed_by: history.changed_by_display, created_at: history.created_at.utc.iso8601(6)}
166
+ history_values = {value: history.value, changed_by: history.changed_by_display, created_at: history.created_at&.utc&.iso8601(6)}
167
167
  history_values[:deleted] = true if history.deleted?
168
168
  history_values
169
169
  end
@@ -209,9 +209,15 @@ module SuperSettings
209
209
  # ...
210
210
  # ]
211
211
  #
212
- # @return [Hash] hash with settings array
212
+ # @return [Hash, nil] hash with settings array or nil if the time is missing or unparseable
213
213
  def updated_since(time)
214
- time = Coerce.time(time)
214
+ time = begin
215
+ Coerce.time(time)
216
+ rescue ArgumentError
217
+ nil
218
+ end
219
+ return nil if time.nil?
220
+
215
221
  settings = Setting.updated_since(time).reject(&:deleted?)
216
222
  {settings: settings.collect(&:as_json)}
217
223
  end
@@ -30,6 +30,10 @@ module SuperSettings
30
30
  class InvalidRecordError < StandardError
31
31
  end
32
32
 
33
+ # Exception raised if the storage engine could not persist valid changes.
34
+ class PersistenceError < StandardError
35
+ end
36
+
33
37
  include Attributes
34
38
 
35
39
  # The changed_by attribute is used to temporarily store an identifier for the user
@@ -164,12 +168,23 @@ module SuperSettings
164
168
  def bulk_update(params, changed_by = nil)
165
169
  all_valid, settings = update_settings(params, changed_by)
166
170
  if all_valid
167
- storage.with_connection do
168
- transaction do |_changes|
169
- settings.each do |setting|
170
- setting.save!
171
+ begin
172
+ storage.with_connection do
173
+ transaction do |_changes|
174
+ settings.each do |setting|
175
+ setting.save!
176
+ end
171
177
  end
172
178
  end
179
+ rescue InvalidRecordError, PersistenceError => e
180
+ # Validation failures detected by save! are already recorded on the setting that
181
+ # failed. A storage level failure isn't attributable to any single setting, so
182
+ # record it on all of them; otherwise callers would get a failure with no
183
+ # explanation of what went wrong.
184
+ if settings.none? { |setting| setting.errors.any? }
185
+ settings.each { |setting| setting.send(:add_base_error, e.message) }
186
+ end
187
+ return [false, settings]
173
188
  end
174
189
  clear_last_updated_cache
175
190
  end
@@ -214,7 +229,7 @@ module SuperSettings
214
229
  Thread.current[:super_settings_transaction] = changes
215
230
 
216
231
  begin
217
- @storage.transaction(&block)
232
+ storage.transaction(&block)
218
233
 
219
234
  clear_last_updated_cache
220
235
 
@@ -382,7 +397,7 @@ module SuperSettings
382
397
  # @param val [String]
383
398
  def description=(val)
384
399
  val = val&.to_s
385
- val = nil if val&.empty?
400
+ val = nil if val && val.empty?
386
401
  will_change!(:description, val) unless description == val
387
402
  @record.description = val
388
403
  end
@@ -484,12 +499,12 @@ module SuperSettings
484
499
  raise InvalidRecordError.new(errors.values.join("; "))
485
500
  end
486
501
 
502
+ return if @changes.empty?
503
+
487
504
  timestamp = Time.now
488
505
  self.created_at ||= timestamp
489
506
  self.updated_at = timestamp if updated_at.nil? || !changed?(:updated_at)
490
507
 
491
- return if @changes.empty?
492
-
493
508
  self.class.storage.with_connection do
494
509
  self.class.transaction do
495
510
  record_value_change
@@ -664,7 +679,7 @@ module SuperSettings
664
679
 
665
680
  def raw_value=(val)
666
681
  val = val&.to_s
667
- val = nil if val&.empty?
682
+ val = nil if val && val.empty?
668
683
  will_change!(:raw_value, val) unless raw_value == val
669
684
  @raw_value = val
670
685
  @record.raw_value = val
@@ -706,6 +721,17 @@ module SuperSettings
706
721
  attribute_errors << "#{attribute.tr("_", " ")} #{message}"
707
722
  end
708
723
 
724
+ # Record an error that applies to the record as a whole rather than to one attribute.
725
+ # The message is used verbatim since there is no attribute name to prefix it with.
726
+ def add_base_error(message)
727
+ base_errors = @errors["base"]
728
+ unless base_errors
729
+ base_errors = []
730
+ @errors["base"] = base_errors
731
+ end
732
+ base_errors << message
733
+ end
734
+
709
735
  def call_after_save_callbacks
710
736
  self.class.after_save_blocks.each do |block|
711
737
  block.call(self)
@@ -103,22 +103,37 @@ module SuperSettings
103
103
  def save!
104
104
  # Check if another record with the same key exists. If it does, then we need to update
105
105
  # that record instead and delete the current one.
106
- duplicate = @model.class.find_by(key: @model.key)
107
- if duplicate.nil? || duplicate == @model
108
- @model.save!
109
- else
110
- duplicate.raw_value = @model.raw_value
111
- duplicate.value_type = @model.value_type
112
- duplicate.description = @model.description
113
- duplicate.deleted = @model.deleted
114
-
115
- @model.transaction do
116
- if @model.persisted?
117
- @model.reload.update!(deleted: true)
106
+ attempts = 0
107
+ begin
108
+ # Each attempt runs in a savepoint so a duplicate key failure can be rolled back
109
+ # and retried; otherwise the surrounding transaction would be left in an aborted
110
+ # state on PostgreSQL.
111
+ @model.class.transaction(requires_new: true) do
112
+ duplicate = @model.class.find_by(key: @model.key)
113
+ if duplicate.nil? || duplicate == @model
114
+ @model.save!
115
+ else
116
+ duplicate.raw_value = @model.raw_value
117
+ duplicate.value_type = @model.value_type
118
+ duplicate.description = @model.description
119
+ duplicate.deleted = @model.deleted
120
+
121
+ if @model.persisted?
122
+ begin
123
+ @model.reload.update!(deleted: true)
124
+ rescue ActiveRecord::RecordNotFound
125
+ end
126
+ end
127
+ duplicate.save!
128
+ @model = duplicate
118
129
  end
119
- duplicate.save!
120
130
  end
121
- @model = duplicate
131
+ rescue ActiveRecord::RecordNotUnique
132
+ # A record with the same key was inserted concurrently; retry so it is
133
+ # found as a duplicate and merged.
134
+ attempts += 1
135
+ retry if attempts <= 1
136
+ raise
122
137
  end
123
138
  end
124
139
 
@@ -11,6 +11,11 @@ module SuperSettings
11
11
  # This class can be used as the base for any storage class where the settings are all stored
12
12
  # together in a single JSON payload.
13
13
  #
14
+ # Writes are serialized within a process, but there is no coordination between processes.
15
+ # If multiple processes write settings at the same time, the last write wins and can
16
+ # overwrite changes made by another process. Storage backends based on this class are best
17
+ # suited for setups where settings are updated from a single process at a time.
18
+ #
14
19
  # Subclasses must implement the following methods:
15
20
  # - self.all
16
21
  # - self.last_updated_at
@@ -18,6 +23,12 @@ module SuperSettings
18
23
  class JSONStorage < StorageAttributes
19
24
  include Transaction
20
25
 
26
+ # Mutex used to serialize the read-modify-write cycle in save_all within a process.
27
+ # Note that this cannot protect against concurrent writes from multiple processes;
28
+ # in that situation the last writer wins and can overwrite another process's changes.
29
+ SAVE_MUTEX = Mutex.new
30
+ private_constant :SAVE_MUTEX
31
+
21
32
  class HistoryStorage < HistoryAttributes
22
33
  class << self
23
34
  def create!(attributes)
@@ -65,50 +76,52 @@ module SuperSettings
65
76
  end
66
77
 
67
78
  def save_all(changes)
68
- existing = {}
69
- parse_settings(settings_json_payload).each do |setting|
70
- existing[setting.key] = setting
71
- end
79
+ SAVE_MUTEX.synchronize do
80
+ existing = {}
81
+ parse_settings(settings_json_payload).each do |setting|
82
+ existing[setting.key] = setting
83
+ end
72
84
 
73
- history_items = []
74
- changes.each do |record|
75
- if record.is_a?(HistoryStorage)
76
- history_items << record
77
- else
78
- existing[record.key] = record
85
+ history_items = []
86
+ changes.each do |record|
87
+ if record.is_a?(HistoryStorage)
88
+ history_items << record
89
+ else
90
+ existing[record.key] = record
91
+ end
79
92
  end
80
- end
81
93
 
82
- settings = existing.values.sort_by(&:key)
94
+ settings = existing.values.sort_by(&:key)
83
95
 
84
- changed_histories = {}
85
- history_items.each do |history_item|
86
- setting = existing[history_item.key]
87
- next unless setting
96
+ changed_histories = {}
97
+ history_items.each do |history_item|
98
+ setting = existing[history_item.key]
99
+ next unless setting
88
100
 
89
- history = changed_histories[history_item.key]
90
- unless history
91
- history = setting.history.dup
92
- changed_histories[history_item.key] = history
101
+ history = changed_histories[history_item.key]
102
+ unless history
103
+ history = setting.history.dup
104
+ changed_histories[history_item.key] = history
105
+ end
106
+ history.unshift(history_item)
93
107
  end
94
- history.unshift(history_item)
95
- end
96
108
 
97
- settings_json = JSON.dump(settings.collect(&:as_json))
98
- save_settings_json(settings_json)
99
-
100
- changed_histories.each do |setting_key, setting_history|
101
- ordered_history = setting_history.sort_by { |history_item| history_item.created_at }.reverse
102
- payload = ordered_history.collect do |history_item|
103
- {
104
- value: history_item.value,
105
- changed_by: history_item.changed_by,
106
- created_at: history_item.created_at.iso8601(6),
107
- deleted: history_item.deleted?
108
- }
109
+ settings_json = JSON.dump(settings.collect(&:as_json))
110
+ save_settings_json(settings_json)
111
+
112
+ changed_histories.each do |setting_key, setting_history|
113
+ ordered_history = setting_history.sort_by { |history_item| history_item.created_at }.reverse
114
+ payload = ordered_history.collect do |history_item|
115
+ {
116
+ value: history_item.value,
117
+ changed_by: history_item.changed_by,
118
+ created_at: history_item.created_at.iso8601(6),
119
+ deleted: history_item.deleted?
120
+ }
121
+ end
122
+ history_json = JSON.dump(payload)
123
+ save_history_json(setting_key, history_json)
109
124
  end
110
- history_json = JSON.dump(payload)
111
- save_history_json(setting_key, history_json)
112
125
  end
113
126
  end
114
127
 
@@ -65,9 +65,12 @@ module SuperSettings
65
65
  if @mongodb.nil? || @url_hash != @url.hash
66
66
  @mutex.synchronize do
67
67
  unless @url_hash == @url.hash
68
+ client = Mongo::Client.new(@url)
69
+ create_indexes!(client)
70
+ # Only record the URL hash after the client is set up so that a
71
+ # failure here will be retried on the next call.
72
+ @mongodb = client
68
73
  @url_hash = @url.hash
69
- @mongodb = Mongo::Client.new(@url)
70
- create_indexes!(@mongodb)
71
74
  end
72
75
  end
73
76
  end
@@ -100,8 +103,12 @@ module SuperSettings
100
103
  key: key,
101
104
  deleted: false
102
105
  }
103
- record = settings_collection.find(query).projection(history: 0).first
104
- new(record) if record
106
+ attributes = settings_collection.find(query).projection(history: 0).first
107
+ if attributes
108
+ record = new(attributes)
109
+ record.persisted = true
110
+ record
111
+ end
105
112
  end
106
113
 
107
114
  def last_updated_at
@@ -123,7 +123,7 @@ module SuperSettings
123
123
  end
124
124
 
125
125
  def last_updated_at
126
- result = with_redis { |redis| redis.zrevrange(UPDATED_KEY, 0, 1, withscores: true).first }
126
+ result = with_redis { |redis| redis.zrevrange(UPDATED_KEY, 0, 0, withscores: true).first }
127
127
  return nil unless result
128
128
 
129
129
  time_at_microseconds(result[1])
@@ -69,6 +69,9 @@ module SuperSettings
69
69
  end
70
70
  end
71
71
 
72
+ BUCKET_MUTEX = Mutex.new
73
+ private_constant :BUCKET_MUTEX
74
+
72
75
  @bucket = nil
73
76
  @bucket_hash = nil
74
77
 
@@ -115,20 +118,32 @@ module SuperSettings
115
118
  private
116
119
 
117
120
  def s3_bucket
118
- if configuration.hash != @bucket_hash
119
- @bucket_hash = configuration.hash
121
+ # The mutex ensures the bucket and the configuration hash it was built from are
122
+ # always published together. The bucket and the hash are derived from a single
123
+ # read of the configuration attributes so that a concurrent configuration change
124
+ # cannot cache a bucket built from mixed values under the settled configuration
125
+ # hash; a torn read produces a hash that will not match the settled configuration,
126
+ # so the bucket gets rebuilt on the next call.
127
+ BUCKET_MUTEX.synchronize do
128
+ config = configuration
120
129
  options = {
121
- endpoint: configuration.endpoint,
122
- access_key_id: configuration.access_key_id,
123
- secret_access_key: configuration.secret_access_key,
124
- region: configuration.region
130
+ endpoint: config.endpoint,
131
+ access_key_id: config.access_key_id,
132
+ secret_access_key: config.secret_access_key,
133
+ region: config.region
125
134
  }
126
- options[:force_path_style] = true if configuration.endpoint
127
- options.compact!
135
+ bucket_name = config.bucket
136
+ config_hash = [options, bucket_name, config.path].hash
137
+
138
+ if config_hash != @bucket_hash
139
+ options[:force_path_style] = true if options[:endpoint]
140
+ options.compact!
128
141
 
129
- @bucket = Aws::S3::Resource.new(options).bucket(configuration.bucket)
142
+ @bucket = Aws::S3::Resource.new(options).bucket(bucket_name)
143
+ @bucket_hash = config_hash
144
+ end
145
+ @bucket
130
146
  end
131
- @bucket
132
147
  end
133
148
 
134
149
  def s3_object(filename)
@@ -107,6 +107,7 @@ module SuperSettings
107
107
  self.class.settings.delete(@original_key)
108
108
  end
109
109
  self.class.settings[key] = attributes
110
+ @original_key = nil
110
111
  set_persisted!
111
112
  true
112
113
  end
@@ -20,10 +20,12 @@ module SuperSettings
20
20
 
21
21
  yield(changes)
22
22
 
23
- if save_all(changes) != false
24
- changes.each do |object|
25
- object.persisted = true if object.respond_to?(:persisted=)
26
- end
23
+ if save_all(changes) == false
24
+ raise SuperSettings::Setting::PersistenceError.new("Settings could not be saved")
25
+ end
26
+
27
+ changes.each do |object|
28
+ object.persisted = true if object.respond_to?(:persisted=)
27
29
  end
28
30
  ensure
29
31
  Thread.current[transaction_key] = nil
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "time"
4
5
 
5
6
  # This is the main interface to the access settings.
6
7
  module SuperSettings
@@ -37,7 +37,5 @@ Gem::Specification.new do |spec|
37
37
 
38
38
  spec.require_paths = ["lib"]
39
39
 
40
- spec.add_development_dependency "bundler"
41
-
42
40
  spec.required_ruby_version = ">= 2.7"
43
41
  end
metadata CHANGED
@@ -1,28 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: super_settings
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.6.0
4
+ version: 2.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: bundler
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: '0'
19
- type: :development
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - ">="
24
- - !ruby/object:Gem::Version
25
- version: '0'
11
+ dependencies: []
26
12
  email:
27
13
  - bbdurand@gmail.com
28
14
  executables: []