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 +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +1 -1
- data/VERSION +1 -1
- data/app/helpers/super_settings/settings_helper.rb +1 -1
- data/lib/super_settings/application/helper.rb +1 -1
- data/lib/super_settings/application/scripts.js +9 -4
- data/lib/super_settings/application.rb +4 -12
- data/lib/super_settings/coerce.rb +4 -7
- data/lib/super_settings/configuration.rb +9 -2
- data/lib/super_settings/controller_actions.rb +6 -1
- data/lib/super_settings/engine.rb +2 -2
- data/lib/super_settings/http_client.rb +23 -22
- data/lib/super_settings/local_cache.rb +8 -21
- data/lib/super_settings/mini_i18n.rb +9 -8
- data/lib/super_settings/rack_application.rb +41 -9
- data/lib/super_settings/rest_api.rb +9 -3
- data/lib/super_settings/setting.rb +35 -9
- data/lib/super_settings/storage/active_record_storage.rb +29 -14
- data/lib/super_settings/storage/json_storage.rb +49 -36
- data/lib/super_settings/storage/mongodb_storage.rb +11 -4
- data/lib/super_settings/storage/redis_storage.rb +1 -1
- data/lib/super_settings/storage/s3_storage.rb +25 -10
- data/lib/super_settings/storage/test_storage.rb +1 -0
- data/lib/super_settings/storage/transaction.rb +6 -4
- data/lib/super_settings.rb +1 -0
- data/super_settings.gemspec +0 -2
- metadata +2 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 290e32e0c4e0a4512fa7e4192a91fd9e654d1500a28bae270c8f21ec9c1a8478
|
|
4
|
+
data.tar.gz: 11ff70806b5849faae35e1d05f226af696f40a0f60334880fe0944d7770d48c1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
417
|
-
|
|
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}"
|
|
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)}">← ${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}"
|
|
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"))} →</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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
#
|
|
56
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
setting
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
@model.
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
94
|
+
settings = existing.values.sort_by(&:key)
|
|
83
95
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
96
|
+
changed_histories = {}
|
|
97
|
+
history_items.each do |history_item|
|
|
98
|
+
setting = existing[history_item.key]
|
|
99
|
+
next unless setting
|
|
88
100
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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,
|
|
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
|
-
|
|
119
|
-
|
|
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:
|
|
122
|
-
access_key_id:
|
|
123
|
-
secret_access_key:
|
|
124
|
-
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
|
-
|
|
127
|
-
options.
|
|
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
|
-
|
|
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)
|
|
@@ -20,10 +20,12 @@ module SuperSettings
|
|
|
20
20
|
|
|
21
21
|
yield(changes)
|
|
22
22
|
|
|
23
|
-
if save_all(changes)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
data/lib/super_settings.rb
CHANGED
data/super_settings.gemspec
CHANGED
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.
|
|
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: []
|