super_settings 0.0.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +9 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +313 -0
  5. data/VERSION +1 -0
  6. data/app/helpers/super_settings/settings_helper.rb +32 -0
  7. data/app/views/layouts/super_settings/settings.html.erb +20 -0
  8. data/config/routes.rb +13 -0
  9. data/db/migrate/20210414004553_create_super_settings.rb +34 -0
  10. data/lib/super_settings/application/api.js +88 -0
  11. data/lib/super_settings/application/helper.rb +119 -0
  12. data/lib/super_settings/application/images/edit.svg +1 -0
  13. data/lib/super_settings/application/images/info.svg +1 -0
  14. data/lib/super_settings/application/images/plus.svg +1 -0
  15. data/lib/super_settings/application/images/slash.svg +1 -0
  16. data/lib/super_settings/application/images/trash.svg +1 -0
  17. data/lib/super_settings/application/index.html.erb +169 -0
  18. data/lib/super_settings/application/layout.html.erb +22 -0
  19. data/lib/super_settings/application/layout_styles.css +193 -0
  20. data/lib/super_settings/application/scripts.js +718 -0
  21. data/lib/super_settings/application/styles.css +122 -0
  22. data/lib/super_settings/application.rb +38 -0
  23. data/lib/super_settings/attributes.rb +24 -0
  24. data/lib/super_settings/coerce.rb +66 -0
  25. data/lib/super_settings/configuration.rb +144 -0
  26. data/lib/super_settings/controller_actions.rb +81 -0
  27. data/lib/super_settings/encryption.rb +76 -0
  28. data/lib/super_settings/engine.rb +70 -0
  29. data/lib/super_settings/history_item.rb +26 -0
  30. data/lib/super_settings/local_cache.rb +306 -0
  31. data/lib/super_settings/rack_middleware.rb +210 -0
  32. data/lib/super_settings/rest_api.rb +195 -0
  33. data/lib/super_settings/setting.rb +599 -0
  34. data/lib/super_settings/storage/active_record_storage.rb +123 -0
  35. data/lib/super_settings/storage/http_storage.rb +279 -0
  36. data/lib/super_settings/storage/redis_storage.rb +293 -0
  37. data/lib/super_settings/storage/test_storage.rb +158 -0
  38. data/lib/super_settings/storage.rb +254 -0
  39. data/lib/super_settings/version.rb +5 -0
  40. data/lib/super_settings.rb +213 -0
  41. data/lib/tasks/super_settings.rake +9 -0
  42. data/super_settings.gemspec +35 -0
  43. metadata +113 -0
@@ -0,0 +1,122 @@
1
+ #settings-table td p {
2
+ margin-top: 0;
3
+ margin-bottom: 0.5rem;
4
+ }
5
+
6
+ #settings-table td p:last-of-type {
7
+ margin-bottom: 0;
8
+ }
9
+
10
+ #settings-table input[type=text], #settings-table input[type=number], #settings-table input[type=date], #settings-table input[type=time], #settings-table textarea {
11
+ width: 100%;
12
+ }
13
+
14
+ .super-settings-edit-row {
15
+ background-color: #f2fdf2 !important;
16
+ }
17
+
18
+ #settings-table tr[data-deleted] td {
19
+ background-color: #ffd1d8 !important;
20
+ color: darkred;
21
+ text-decoration: line-through;
22
+ }
23
+
24
+ #settings-table tr[data-newrecord] .js-show-history {
25
+ display: none;
26
+ }
27
+
28
+ .super-settings-key {
29
+ overflow-wrap: break-word;
30
+ max-width: 30rem;
31
+ min-width: 15rem;
32
+ }
33
+
34
+ .super-settings-value {
35
+ overflow-wrap: break-word;
36
+ max-width: 30rem;
37
+ min-width: 15rem;
38
+ }
39
+
40
+ .super-settings-value-type {
41
+ width: 7rem;
42
+ }
43
+
44
+ .super-settings-description {
45
+ min-width: 20rem;
46
+ }
47
+
48
+ .super-settings-controls {
49
+ width: 6rem;
50
+ white-space: nowrap;
51
+ text-align: right;
52
+ text-decoration: none !important;
53
+ }
54
+
55
+ .super-settings-history-key {
56
+ font-weight: normal;
57
+ color: royalblue;
58
+ }
59
+
60
+ .super-settings-modal {
61
+ z-index: 1000000000000;
62
+ display: none;
63
+ padding-top: 5rem;
64
+ position: fixed;
65
+ left: 0;
66
+ top: 0;
67
+ width: 100%;
68
+ height: 100%;
69
+ overflow: auto;
70
+ background-color: rgba(0,0,0,0.4);
71
+ }
72
+
73
+ .super-settings-modal-dialog {
74
+ margin: auto;
75
+ background-color: #fff;
76
+ position: relative;
77
+ outline: 0;
78
+ padding: 2em;
79
+ box-shadow: 3px 3px 7px 3px rgba(0, 0, 0, 0.6);
80
+ border-radius: 4px;
81
+ width: 80%;
82
+ height: 80%;
83
+ }
84
+
85
+ .super-settings-modal-content {
86
+ height: 100%;
87
+ overflow: auto;
88
+ }
89
+
90
+ .super-settings-modal-close {
91
+ display: block;
92
+ position: absolute;
93
+ top: 0;
94
+ right: 0;
95
+ border: 0;
96
+ padding: 1ex;
97
+ background-color: inherit;
98
+ font-size: 1.5rem;
99
+ font-weight: 500;
100
+ }
101
+
102
+ .super-settings-sr-only {
103
+ position: absolute;
104
+ width: 1px;
105
+ height: 1px;
106
+ padding: 0;
107
+ margin: -1px;
108
+ overflow: hidden;
109
+ clip: rect(0,0,0,0);
110
+ border: 0;
111
+ }
112
+
113
+ .super-settings-text-nowrap {
114
+ white-space: nowrap !important;
115
+ }
116
+
117
+ .super-settings-sticky-top {
118
+ position: sticky;
119
+ top: 0;
120
+ padding: 1rem 0;
121
+ background-color: white;
122
+ }
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "application/helper"
4
+
5
+ module SuperSettings
6
+ # Simple class for rendering ERB templates for the HTML application.
7
+ class Application
8
+ include Helper
9
+
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.
12
+ # @param add_to_head [String] HTML code to add to the <head> element on the page.
13
+ def initialize(layout = nil, add_to_head = nil)
14
+ if layout
15
+ layout = File.expand_path(File.join("application", "layout.html.erb"), __dir__) if layout == :default
16
+ @layout = ERB.new(File.read(layout))
17
+ @add_to_head = add_to_head
18
+ end
19
+ end
20
+
21
+ # Render the specified ERB file in the lib/application directory distributed with the gem.
22
+ def render(erb_file)
23
+ template = ERB.new(File.read(File.expand_path(File.join("application", erb_file), __dir__)))
24
+ html = template.result(binding)
25
+ if @layout
26
+ render_layout { html }
27
+ else
28
+ html
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def render_layout
35
+ @layout.result(binding)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperSettings
4
+ # Interface to expose mass setting attributes on an object. Setting attributes with a
5
+ # hash will simply call the attribute writers for each key in the hash.
6
+ module Attributes
7
+ class UnknownAttributeError < StandardError
8
+ end
9
+
10
+ def initialize(attributes = nil)
11
+ self.attributes = attributes if attributes
12
+ end
13
+
14
+ def attributes=(values)
15
+ values.each do |name, value|
16
+ if respond_to?("#{name}=", true)
17
+ send("#{name}=", value)
18
+ else
19
+ raise UnknownAttributeError.new("unknown attribute #{name.to_s.inspect} for #{self.class}")
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperSettings
4
+ # Utility functions for coercing values to other data types.
5
+ class Coerce
6
+ # rubocop:disable Lint/BooleanSymbol
7
+ FALSE_VALUES = [
8
+ false, 0,
9
+ "0", :"0",
10
+ "f", :f,
11
+ "F", :F,
12
+ "false", :false,
13
+ "FALSE", :FALSE,
14
+ "off", :off,
15
+ "OFF", :OFF
16
+ ].to_set.freeze
17
+ # rubocop:enable Lint/BooleanSymbol
18
+
19
+ class << self
20
+ # Cast variations of booleans (i.e. "true", "false", 1, 0, etc.) to actual boolean objects.
21
+ # @param value [Object]
22
+ # @return [Boolean]
23
+ def boolean(value)
24
+ if value == false
25
+ false
26
+ elsif blank?(value)
27
+ nil
28
+ else
29
+ !FALSE_VALUES.include?(value)
30
+ end
31
+ end
32
+
33
+ # Cast a value to a Time object.
34
+ def time(value)
35
+ value = nil if value.nil? || value.to_s.empty?
36
+ return nil if value.nil?
37
+ time = if value.is_a?(Numeric)
38
+ Time.at(value)
39
+ elsif value.respond_to?(:to_time)
40
+ value.to_time
41
+ else
42
+ Time.parse(value.to_s)
43
+ end
44
+ if time.respond_to?(:in_time_zone) && Time.respond_to?(:zone)
45
+ time = time.in_time_zone(Time.zone)
46
+ end
47
+ time
48
+ end
49
+
50
+ # @return true if the value is nil or empty.
51
+ def blank?(value)
52
+ return true if value.nil?
53
+ if value.respond_to?(:empty?)
54
+ value.empty?
55
+ else
56
+ value.to_s.empty?
57
+ end
58
+ end
59
+
60
+ # @return true if the value is not nil and not empty.
61
+ def present?(value)
62
+ !blank?(value)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ module SuperSettings
6
+ # Configuration for the gem when run as a Rails engine. Default values and behaviors
7
+ # on the controller and model can be overridden with the configuration.
8
+ #
9
+ # The configuration is a singleton instance.
10
+ class Configuration
11
+ include Singleton
12
+
13
+ # Configuration for the controller.
14
+ class Controller
15
+ # @api private
16
+ attr_reader :enhancement
17
+
18
+ # Superclass for the controller. This should normally be set to one of your existing
19
+ # base controller classes since these probably have authentication methods, etc. defined
20
+ # on them. If this is not defined, the superclass will be `SuperSettings::ApplicationController`.
21
+ # It can be set to either a class or a class name. Setting to a class name is preferrable
22
+ # since it will be compatible with class reloading in a development environment.
23
+ attr_writer :superclass
24
+
25
+ def superclass
26
+ if @superclass.is_a?(String)
27
+ @superclass.constantize
28
+ else
29
+ @superclass
30
+ end
31
+ end
32
+
33
+ # Optinal name of the application displayed in the view.
34
+ attr_accessor :application_name
35
+
36
+ # Optional mage URL for the application logo.
37
+ attr_accessor :application_logo
38
+
39
+ # Optional URL for a link back to the rest of the application.
40
+ attr_accessor :application_link
41
+
42
+ # Javascript to inject into the settings application HTML page. This can be used, for example,
43
+ # to set authorization credentials stored client side to access the settings API.
44
+ attr_accessor :javascript
45
+
46
+ # Enhance the controller. You can define methods or call controller class methods like
47
+ # `before_action`, etc. in the block. These will be applied to the engine controller.
48
+ # This is essentially the same a monkeypatching the controller class.
49
+ def enhance(&block)
50
+ @enhancement = block
51
+ end
52
+
53
+ # Define how the `changed_by` attibute on the setting history will be filled from the controller.
54
+ # The block will be evaluated in the context of the controller when the settings are changed.
55
+ # The value returned by the block will be stored in the changed_by attribute. For example, if
56
+ # your base controller class defines a method `current_user` and you'd like the name to be stored
57
+ # in the history, you could call `define_changed_by { current_user.name }`
58
+ def define_changed_by(&block)
59
+ @changed_by_block = block
60
+ end
61
+
62
+ # Return the value of `define_changed_by` block.
63
+ #
64
+ # @api private
65
+ def changed_by(controller)
66
+ if defined?(@changed_by_block) && @changed_by_block
67
+ controller.instance_eval(&@changed_by_block)
68
+ end
69
+ end
70
+ end
71
+
72
+ # Configuration for the models.
73
+ class Model
74
+ # Specify the cache implementation to use for caching the last updated timestamp for reloading
75
+ # changed records. Defaults to `Rails.cache`
76
+ attr_accessor :cache
77
+
78
+ attr_writer :storage
79
+
80
+ # Specify the storage engine to use for persisting settings. The value can either be specified
81
+ # as a full class name or an underscored class name for a storage classed defined in the
82
+ # `SuperSettings::Storage` namespace. The default storage engine is `SuperSettings::Storage::ActiveRecord`.
83
+ def storage
84
+ if defined?(@storage) && @storage
85
+ @storage
86
+ else
87
+ :active_record
88
+ end
89
+ end
90
+
91
+ # @return [Class]
92
+ # @api private
93
+ def storage_class
94
+ if storage.is_a?(Class)
95
+ storage
96
+ else
97
+ class_name = storage.to_s.camelize
98
+ if Storage.const_defined?("#{class_name}Storage")
99
+ Storage.const_get("#{class_name}Storage")
100
+ else
101
+ class_name.constantize
102
+ end
103
+ end
104
+ end
105
+ end
106
+
107
+ # Return the model specific configuration object.
108
+ attr_reader :model
109
+
110
+ # Return the controller specific configuration object.
111
+ attr_reader :controller
112
+
113
+ # Set the secret used for encrypting secret settings. Defaults to the value of the
114
+ # SUPER_SETTINGS_SECRET environment variable. An array can be provided if you need to
115
+ # roll the secret with the first value being the current one.
116
+ attr_accessor :secret
117
+
118
+ # Set the number of seconds that settings will be cached locally before the database
119
+ # is checked for updates. Defaults to 5 seconds.
120
+ attr_accessor :refresh_interval
121
+
122
+ def initialize
123
+ @model = Model.new
124
+ @controller = Controller.new
125
+ end
126
+
127
+ # Defer the execution of a block that will be yielded to with the config object. This
128
+ # is needed in a Rails environment during initialization so that all the frameworks can
129
+ # load before loading the settings.
130
+ #
131
+ # @api private
132
+ def defer(&block)
133
+ @block = block
134
+ end
135
+
136
+ # Call the block deferred during initialization.
137
+ #
138
+ # @api private
139
+ def call
140
+ @block&.call(self)
141
+ @block = nil
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module SuperSettings
6
+ # Module used to build the SuperSettings::SettingsController for Rails applications.
7
+ # This controller is defined at runtime since it is assumed that the superclass will
8
+ # be one of the application's own base controller classes since the application will
9
+ # want to define authentication and authorization criteria.
10
+ #
11
+ # The controller is built by extending the class defined by the Configuration object and
12
+ # then mixing in this module.
13
+ module ControllerActions
14
+ def self.included(base)
15
+ base.layout "super_settings/settings"
16
+ base.helper SettingsHelper
17
+ base.protect_from_forgery with: :exception, if: :protect_from_forgery?
18
+ end
19
+
20
+ # Render the HTML application for managing settings.
21
+ def root
22
+ html = SuperSettings::Application.new.render("index.html.erb")
23
+ render html: html.html_safe, layout: true
24
+ end
25
+
26
+ # API endpoint for getting active settings. See SuperSettings::RestAPI for details.
27
+ def index
28
+ render json: SuperSettings::RestAPI.index
29
+ end
30
+
31
+ # API endpoint for getting a setting. See SuperSettings::RestAPI for details.
32
+ def show
33
+ setting = SuperSettings::RestAPI.show(params[:key])
34
+ if setting
35
+ render json: setting
36
+ else
37
+ render json: nil, status: 404
38
+ end
39
+ end
40
+
41
+ # API endpoint for updating settings. See SuperSettings::RestAPI for details.
42
+ def update
43
+ changed_by = Configuration.instance.controller.changed_by(self)
44
+ result = SuperSettings::RestAPI.update(params[:settings], changed_by)
45
+ if result[:success]
46
+ render json: result
47
+ else
48
+ render json: result, status: 422
49
+ end
50
+ end
51
+
52
+ # API endpoint for getting the history of a setting. See SuperSettings::RestAPI for details.
53
+ def history
54
+ setting_history = SuperSettings::RestAPI.history(params[:key], offset: params[:offset], limit: params[:limit])
55
+ if setting_history
56
+ render json: setting_history
57
+ else
58
+ render json: nil, status: 404
59
+ end
60
+ end
61
+
62
+ # API endpoint for getting the last time a setting was changed. See SuperSettings::RestAPI for details.
63
+ def last_updated_at
64
+ render json: SuperSettings::RestAPI.last_updated_at
65
+ end
66
+
67
+ # API endpoint for getting settings that have changed since specified time. See SuperSettings::RestAPI for details.
68
+ def updated_since
69
+ render json: SuperSettings::RestAPI.updated_since(params[:time])
70
+ end
71
+
72
+ protected
73
+
74
+ # Return true if CSRF protection needs to be enabled for the request.
75
+ # By default it is only enabled on stateful requests that include Basic authorization
76
+ # or cookies in the request so that stateless REST API calls are allowed.
77
+ def protect_from_forgery?
78
+ request.cookies.present? || request.authorization.to_s.split(" ", 2).first&.match?(/\ABasic/i)
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperSettings
4
+ module Encryption
5
+ SALT = "0c54a781"
6
+ private_constant :SALT
7
+
8
+ # Error thrown when the secret is invalid
9
+ class InvalidSecretError < StandardError
10
+ def initialize
11
+ super("Cannot decrypt. Invalid secret provided.")
12
+ end
13
+ end
14
+
15
+ class << self
16
+ # Set the secret key used for encrypting secret values. If this is not set,
17
+ # the value will be loaded from the `SUPER_SETTINGS_SECRET` environment
18
+ # variable. If that value is not set, arguments will not be encrypted.
19
+ #
20
+ # You can set multiple secrets by passing an array if you need to roll your secrets.
21
+ # The left most value in the array will be used as the encryption secret, but
22
+ # all the values will be tried when decrypting. That way if you have existing keys
23
+ # that were encrypted with a different secret, you can still make it available
24
+ # when decrypting. If you are using the environment variable, separate the keys
25
+ # with spaces.
26
+ #
27
+ # @param value [String] One or more secrets to use for encrypting arguments.
28
+ # @return [void]
29
+ def secret=(value)
30
+ @encryptors = make_encryptors(value)
31
+ end
32
+
33
+ # Encrypt a value for use with secret settings.
34
+ # @api private
35
+ def encrypt(value)
36
+ return nil if Coerce.blank?(value)
37
+ encryptor = encryptors.first
38
+ return value if encryptor.nil?
39
+ encryptor.encrypt(value)
40
+ end
41
+
42
+ # Decrypt a value for use with secret settings.
43
+ # @api private
44
+ def decrypt(value)
45
+ return nil if Coerce.blank?(value)
46
+ return value if encryptors.empty? || encryptors == [nil]
47
+ encryptors.each do |encryptor|
48
+ begin
49
+ return encryptor.decrypt(value) if encryptor
50
+ rescue OpenSSL::Cipher::CipherError
51
+ # Not the right key, try the next one
52
+ end
53
+ end
54
+ raise InvalidSecretError
55
+ end
56
+
57
+ # @return [Boolean] true if the value is encrypted in the storage engine.
58
+ def encrypted?(value)
59
+ SecretKeys::Encryptor.encrypted?(value)
60
+ end
61
+
62
+ private
63
+
64
+ def encryptors
65
+ if !defined?(@encryptors) || @encryptors.empty?
66
+ @encryptors = make_encryptors(ENV["SUPER_SETTINGS_SECRET"].to_s.split)
67
+ end
68
+ @encryptors
69
+ end
70
+
71
+ def make_encryptors(secrets)
72
+ Array(secrets).map { |val| val.nil? ? nil : SecretKeys::Encryptor.from_password(val, SALT) }
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperSettings
4
+ # Engine that is loaded in a Rails environment. The engine will take care of applying any
5
+ # settings overriding behavior in the Configuration as well as eager loading the settings
6
+ # into memory.
7
+ class Engine < Rails::Engine
8
+ isolate_namespace ::SuperSettings
9
+
10
+ config.after_initialize do
11
+ # Call the deferred initialization block.
12
+ configuration = Configuration.instance
13
+ configuration.call
14
+
15
+ SuperSettings.refresh_interval = configuration.refresh_interval unless configuration.refresh_interval.nil?
16
+
17
+ reloader = if defined?(Rails.application.reloader.to_prepare)
18
+ Rails.application.reloader
19
+ elsif defined?(ActiveSupport::Reloader.to_prepare)
20
+ ActiveSupport::Reloader
21
+ elsif defined?(ActionDispatch::Reloader.to_prepare)
22
+ ActionDispatch::Reloader
23
+ end
24
+
25
+ create_controller = lambda do
26
+ klass = Class.new(configuration.controller.superclass || ::ApplicationController)
27
+ if defined?(SuperSettings::SettingsController)
28
+ SuperSettings.send(:remove_const, :SettingsController)
29
+ end
30
+ SuperSettings.const_set(:SettingsController, klass)
31
+ klass.include(ControllerActions)
32
+ if configuration.controller.enhancement
33
+ klass.class_eval(&configuration.controller.enhancement)
34
+ end
35
+ end
36
+
37
+ # Setup the controller.
38
+ ActiveSupport.on_load(:action_controller) do
39
+ create_controller.call
40
+ if reloader && !Rails.configuration.cache_classes
41
+ reloader.to_prepare(&create_controller)
42
+ end
43
+ end
44
+
45
+ model_load_block = proc do
46
+ Setting.cache = (configuration.model.cache || Rails.cache)
47
+ Setting.storage = configuration.model.storage_class
48
+
49
+ if configuration.secret.present?
50
+ SuperSettings.secret = configuration.secret
51
+ configuration.secret = nil
52
+ end
53
+
54
+ if !SuperSettings.loaded?
55
+ begin
56
+ SuperSettings.load_settings
57
+ rescue => e
58
+ Rails.logger&.warn(e)
59
+ end
60
+ end
61
+ end
62
+
63
+ if configuration.model.storage.to_s == "active_record"
64
+ ActiveSupport.on_load(:active_record, &model_load_block)
65
+ else
66
+ model_load_block.call
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperSettings
4
+ # Model for each item in a setting's history. When a setting is changed, the system
5
+ # will track the value it is changed to, who it was changed by, and when.
6
+ class HistoryItem
7
+ include Attributes
8
+
9
+ attr_accessor :key, :value, :changed_by, :created_at
10
+ attr_writer :deleted
11
+
12
+ def deleted?
13
+ # Stupid strict mode...
14
+ !!(defined?(@deleted) && @deleted)
15
+ end
16
+
17
+ # The method could be overriden to change how the changed_by attribute is displayed.
18
+ # For instance, you could store a user id in the changed_by column and add an association
19
+ # on this model `belongs_to :user, class_name: "User", foreign_key: :changed_by` and then
20
+ # define this method as `user.name`.
21
+ # @return [String]
22
+ def changed_by_display
23
+ changed_by
24
+ end
25
+ end
26
+ end