super_settings 0.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
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