yiffspace 0.0.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.
Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +0 -0
  3. data/LICENSE +20 -0
  4. data/README.md +25 -0
  5. data/Rakefile +10 -0
  6. data/app/assets/config/yiff_space_manifest.js +1 -0
  7. data/app/assets/stylesheets/yiff_space/application.css +30 -0
  8. data/app/controllers/yiff_space/application_controller.rb +6 -0
  9. data/app/helpers/yiff_space/application_helper.rb +6 -0
  10. data/app/helpers/yiff_space/auth_helper.rb +6 -0
  11. data/app/models/yiff_space/application_record.rb +7 -0
  12. data/app/views/layouts/yiff_space/application.html.erb +17 -0
  13. data/app/views/yiff_space/error.html.erb +3 -0
  14. data/engines/auth/app/controllers/yiff_space/auth/application_controller.rb +11 -0
  15. data/engines/auth/app/controllers/yiff_space/auth/root_controller.rb +67 -0
  16. data/engines/auth/app/views/yiff_space/auth/root/permissions.html.erb +14 -0
  17. data/engines/auth/config/routes.rb +11 -0
  18. data/lib/tasks/yiff_space_tasks.rake +6 -0
  19. data/lib/yiffspace/auth/auth_info/anonymous.rb +64 -0
  20. data/lib/yiffspace/auth/auth_info.rb +74 -0
  21. data/lib/yiffspace/auth/client.rb +58 -0
  22. data/lib/yiffspace/auth/discord_info.rb +63 -0
  23. data/lib/yiffspace/auth/engine.rb +57 -0
  24. data/lib/yiffspace/auth/helper.rb +167 -0
  25. data/lib/yiffspace/auth/permissions.rb +85 -0
  26. data/lib/yiffspace/auth/set_client_name.rb +15 -0
  27. data/lib/yiffspace/auth/user_info/anonymous.rb +72 -0
  28. data/lib/yiffspace/auth/user_info.rb +91 -0
  29. data/lib/yiffspace/auth.rb +84 -0
  30. data/lib/yiffspace/config_builder.rb +189 -0
  31. data/lib/yiffspace/configuration/images.rb +22 -0
  32. data/lib/yiffspace/configuration.rb +30 -0
  33. data/lib/yiffspace/core_ext/action_dispatch/set_auth_client/scoped.rb +13 -0
  34. data/lib/yiffspace/core_ext/action_dispatch/set_auth_client.rb +13 -0
  35. data/lib/yiffspace/core_ext/active_record/all.rb +3 -0
  36. data/lib/yiffspace/core_ext/active_record/where_chain.rb +6 -0
  37. data/lib/yiffspace/core_ext/all.rb +8 -0
  38. data/lib/yiffspace/core_ext/arel/all.rb +4 -0
  39. data/lib/yiffspace/core_ext/arel/cross_join_lateral.rb +21 -0
  40. data/lib/yiffspace/core_ext/arel/left_join_lateral.rb +21 -0
  41. data/lib/yiffspace/core_ext/enumerable/all.rb +3 -0
  42. data/lib/yiffspace/core_ext/enumerable/parallel.rb +7 -0
  43. data/lib/yiffspace/core_ext/hash/all.rb +3 -0
  44. data/lib/yiffspace/core_ext/hash/to_open_hash.rb +7 -0
  45. data/lib/yiffspace/core_ext/open_hash.rb +3 -0
  46. data/lib/yiffspace/core_ext/string/all.rb +5 -0
  47. data/lib/yiffspace/core_ext/string/sql.rb +7 -0
  48. data/lib/yiffspace/core_ext/string/to_b.rb +7 -0
  49. data/lib/yiffspace/core_ext/string/truthy_falsy.rb +7 -0
  50. data/lib/yiffspace/extensions/action_dispatch/set_auth_client/scoped.rb +15 -0
  51. data/lib/yiffspace/extensions/action_dispatch/set_auth_client.rb +13 -0
  52. data/lib/yiffspace/extensions/active_record/where_chain.rb +107 -0
  53. data/lib/yiffspace/extensions/arel/nodes/cross_join_lateral.rb +17 -0
  54. data/lib/yiffspace/extensions/arel/nodes/left_join_lateral.rb +17 -0
  55. data/lib/yiffspace/extensions/arel/table/cross_join_lateral.rb +15 -0
  56. data/lib/yiffspace/extensions/arel/table/left_join_lateral.rb +15 -0
  57. data/lib/yiffspace/extensions/arel/visitors/postgresql/cross_join_lateral.rb +19 -0
  58. data/lib/yiffspace/extensions/arel/visitors/postgresql/left_join_lateral.rb +23 -0
  59. data/lib/yiffspace/extensions/enumerable/parallel.rb +39 -0
  60. data/lib/yiffspace/extensions/hash/to_open_hash.rb +15 -0
  61. data/lib/yiffspace/extensions/string/sql.rb +21 -0
  62. data/lib/yiffspace/extensions/string/to_b.rb +13 -0
  63. data/lib/yiffspace/extensions/string/truthy_falsy.rb +17 -0
  64. data/lib/yiffspace/images/avatar/base.rb +26 -0
  65. data/lib/yiffspace/images/avatar/discord.rb +37 -0
  66. data/lib/yiffspace/images/avatar/gravatar.rb +21 -0
  67. data/lib/yiffspace/images/avatar.rb +29 -0
  68. data/lib/yiffspace/images/banner/base.rb +26 -0
  69. data/lib/yiffspace/images/banner/discord.rb +37 -0
  70. data/lib/yiffspace/images/banner.rb +29 -0
  71. data/lib/yiffspace/images.rb +6 -0
  72. data/lib/yiffspace/utils/open_hash.rb +54 -0
  73. data/lib/yiffspace/utils/set_env_constraint.rb +18 -0
  74. data/lib/yiffspace/utils.rb +6 -0
  75. data/lib/yiffspace/version.rb +5 -0
  76. data/lib/yiffspace.rb +11 -0
  77. metadata +176 -0
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("active_support/concern")
4
+
5
+ module YiffSpace
6
+ module Auth
7
+ module Helper
8
+ extend(ActiveSupport::Concern)
9
+
10
+ module ClassMethods
11
+ def set_client_name(name) # rubocop:disable Naming/AccessorMethodName
12
+ before_action do |controller|
13
+ controller.request.env["yiffspace.auth.client_name"] = name
14
+ if controller.respond_to?(:yiffspace_client_name=)
15
+ controller.yiffspace_client_name = name
16
+ elsif controller.respond_to?(:client_name=)
17
+ controller.client_name = name
18
+ elsif controller.respond_to?(:helpers)
19
+ if controller.helpers.respond_to?(:yiffspace_client_name=)
20
+ controller.helpers.yiffspace_client_name = name
21
+ elsif controller.helpers.respond_to?(:client_name=)
22
+ controller.helpers.client_name = name
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ def auth_raw
30
+ session[auth_client_config.auth_session_key]
31
+ end
32
+
33
+ def auth
34
+ return AuthInfo::Anonymous.instance if auth_raw.blank?
35
+
36
+ AuthInfo.from_session(auth_raw)
37
+ end
38
+
39
+ def auth?
40
+ auth_raw.present? && !auth.anonymous?
41
+ end
42
+
43
+ def auth=(value)
44
+ value = nil if value.is_a?(AuthInfo::Anonymous)
45
+ session[auth_client_config.auth_session_key] = value&.to_session
46
+ end
47
+
48
+ def reset_auth!
49
+ session.delete(auth_client_config.auth_session_key)
50
+ end
51
+
52
+ def user_raw
53
+ session[auth_client_config.user_session_key]
54
+ end
55
+
56
+ def user
57
+ return UserInfo::Anonymous.instance if user_raw.blank?
58
+
59
+ UserInfo.from_session(user_raw)
60
+ end
61
+
62
+ def user?
63
+ user_raw.present? && !user.anonymous?
64
+ end
65
+
66
+ def user=(value)
67
+ value = nil if value.is_a?(UserInfo::Anonymous)
68
+ session[auth_client_config.user_session_key] = value&.to_session
69
+ end
70
+
71
+ def reset_user!
72
+ session.delete(auth_client_config.user_session_key)
73
+ end
74
+
75
+ def state
76
+ session[auth_client_config.state_session_key]
77
+ end
78
+
79
+ def state=(value)
80
+ session[auth_client_config.state_session_key] = value
81
+ end
82
+
83
+ def reset_state!
84
+ session.delete(auth_client_config.state_session_key)
85
+ end
86
+
87
+ def generate_state!
88
+ generator = auth_client_config.state_generator
89
+ self.state = generator.call(*(generator.arity.zero? ? [] : [self]))
90
+ end
91
+
92
+ def return_path
93
+ session[auth_client_config.return_path_session_key]
94
+ end
95
+
96
+ def return_path=(value)
97
+ return if value && (!value.start_with?("/") || value.start_with?("//"))
98
+
99
+ session[auth_client_config.return_path_session_key] = value
100
+ end
101
+
102
+ def reset_return_path!
103
+ session.delete(auth_client_config.return_path_session_key)
104
+ end
105
+
106
+ def full_reset!
107
+ reset_state!
108
+ reset_auth!
109
+ reset_user!
110
+ reset_return_path!
111
+ end
112
+
113
+ def require_auth(path)
114
+ redirect_to(path) unless auth?
115
+ end
116
+
117
+ def has_permission?(name)
118
+ return false unless auth?
119
+
120
+ auth.permissions.has?(name)
121
+ end
122
+
123
+ def url_helpers
124
+ YiffSpace::Auth::Engine.for(client_name).routes.url_helpers
125
+ end
126
+
127
+ # Returns the Auth::Client for the current request. In auth engine controllers this is
128
+ # resolved from the routing default set by Engine.for; in host app controllers it falls
129
+ # back to the default registered client. Override in your controller to choose a specific
130
+ # client when multiple are registered.
131
+ def auth_client_config
132
+ client_name = self.client_name
133
+ client_name.present? ? YiffSpace::Auth[client_name.to_sym] : YiffSpace::Auth.default
134
+ end
135
+
136
+ def client_name
137
+ respond_to?(:request, true) && request.env[CLIENT_NAME_ENV]
138
+ end
139
+
140
+ def client_name=(value)
141
+ request.env[CLIENT_NAME_ENV] = value.to_sym
142
+ end
143
+
144
+ module Scoped
145
+ extend(ActiveSupport::Concern)
146
+ include(Helper)
147
+
148
+ included do
149
+ private(*Helper.instance_methods(false))
150
+ private_class_method(*Helper::ClassMethods.instance_methods(false))
151
+ end
152
+
153
+ Helper.instance_methods(false).each do |name|
154
+ define_method("yiffspace_#{name}") { |*args, **kwargs, &block| send(name, *args, **kwargs, &block) }
155
+ end
156
+
157
+ module ClassMethods
158
+ include(Helper::ClassMethods)
159
+
160
+ Helper::ClassMethods.instance_methods(false).each do |name|
161
+ define_method("yiffspace_#{name}") { |*args, **kwargs, &block| send(name, *args, **kwargs, &block) }
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module Auth
5
+ class Permissions
6
+ attr_accessor(:values)
7
+
8
+ delegate(:each, to: :values)
9
+
10
+ include(Enumerable)
11
+
12
+ def initialize(perms)
13
+ @values = perms
14
+ @tree = {}
15
+
16
+ perms.each do |perm|
17
+ current = @tree
18
+ parts = perm.split(".")
19
+
20
+ parts.each do |part|
21
+ current[part] ||= {}
22
+ current = current[part]
23
+ end
24
+
25
+ # mark leaf
26
+ current[:__leaf__] = true
27
+ end
28
+ end
29
+
30
+ def has?(perm)
31
+ current = @tree
32
+ parts = perm.split(".")
33
+
34
+ parts.each do |part|
35
+ return false unless current[part]
36
+
37
+ current = current[part]
38
+ end
39
+
40
+ current[:__leaf__] == true
41
+ end
42
+
43
+ alias include? has?
44
+
45
+ def method_missing(name, *_args)
46
+ str = name.to_s
47
+
48
+ if str.end_with?("?")
49
+ key = str[0..-2]
50
+ return @tree[key]&.dig(:__leaf__) == true
51
+ end
52
+
53
+ if @tree.key?(str)
54
+ self.class.new_from_subtree(@tree[str])
55
+ else
56
+ # return empty node instead of raising
57
+ self.class.new([])
58
+ end
59
+ end
60
+
61
+ def respond_to_missing?(_name, _include_private = false)
62
+ true
63
+ end
64
+
65
+ def self.new_from_subtree(tree)
66
+ obj = allocate
67
+ obj.instance_variable_set(:@tree, tree)
68
+ obj.instance_variable_set(:@values, leaves_from_tree(tree))
69
+ obj
70
+ end
71
+
72
+ def self.leaves_from_tree(node, prefix = "")
73
+ result = []
74
+ node.each do |key, subtree|
75
+ next if key == :__leaf__
76
+
77
+ path = prefix.empty? ? key : "#{prefix}.#{key}"
78
+ result << path if subtree[:__leaf__]
79
+ result.concat(leaves_from_tree(subtree, path))
80
+ end
81
+ result
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module Auth
5
+ class SetClientName < Utils::SetEnvConstraint
6
+ def initialize(value)
7
+ super(CLIENT_NAME_ENV, value.to_sym)
8
+ end
9
+
10
+ def self.default
11
+ new(DEFAULT_CLIENT_NAME)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("singleton")
4
+
5
+ module YiffSpace
6
+ module Auth
7
+ class UserInfo
8
+ class Anonymous
9
+ include(::Singleton)
10
+
11
+ %i[id user discord avatar].each do |attr|
12
+ define_method(attr) { |*, **| raise(NotImplementedError, "#{attr} is not present on anonymous user") }
13
+ end
14
+
15
+ def username
16
+ "anonymous"
17
+ end
18
+
19
+ def display_name
20
+ "Anonymous"
21
+ end
22
+
23
+ def avatar
24
+ nil
25
+ end
26
+
27
+ def avatar_url
28
+ nil
29
+ end
30
+
31
+ def banner
32
+ nil
33
+ end
34
+
35
+ def banner_url
36
+ nil
37
+ end
38
+
39
+ def anonymous?
40
+ true
41
+ end
42
+
43
+ # this feels wrong, but it hopefully shouldn't break anything
44
+ def present?
45
+ false
46
+ end
47
+
48
+ def blank?
49
+ true
50
+ end
51
+
52
+ def serializable_hash(*)
53
+ nil
54
+ end
55
+
56
+ def to_session
57
+ serializable_hash
58
+ end
59
+
60
+ def self.from_json(*)
61
+ Anonymous.new
62
+ end
63
+
64
+ def self.from_session(data)
65
+ return nil if data.blank?
66
+
67
+ from_json(data)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module Auth
5
+ class UserInfo
6
+ attr_reader(:id, :user, :discord, :client_id)
7
+
8
+ # @param id String
9
+ # @param user OpenIDConnect::ResponseObject::UserInfo
10
+ # @param discord Hash
11
+ # @param client_id String
12
+ def initialize(id:, user:, discord:, client_id:)
13
+ raise(ArgumentError, "no id provided") if id.blank?
14
+ raise(ArgumentError, "no user provided") if user.blank?
15
+
16
+ @id = id
17
+ @user = user
18
+ @discord = DiscordInfo.from_json(discord)
19
+ @client_id = client_id
20
+ end
21
+
22
+ def anonymous?
23
+ false
24
+ end
25
+
26
+ # this feels wrong, but it hopefully shouldn't break anything
27
+ def present?
28
+ true
29
+ end
30
+
31
+ def blank?
32
+ false
33
+ end
34
+
35
+ def avatar(type = nil)
36
+ type.present? ? Images::Avatar.get_for(id, type) : Images::Avatar.default_for(id)
37
+ end
38
+
39
+ def avatar_url(type = nil)
40
+ avatar(type).url
41
+ end
42
+
43
+ def banner(type = nil)
44
+ type.present? ? Images::Banner.get_for(id, type) : Images::Banner.default_for(id)
45
+ end
46
+
47
+ def banner_url(type = nil)
48
+ banner(type).url
49
+ end
50
+
51
+ delegate(:username, to: :discord)
52
+
53
+ def display_name
54
+ discord.global_name
55
+ end
56
+
57
+ def serializable_hash(_options = {})
58
+ {
59
+ "id" => id,
60
+ "discord" => discord.serializable_hash,
61
+ "user" => ::YiffSpace::Auth.serialize_user(user, client_id: client_id),
62
+ }
63
+ end
64
+
65
+ def to_session
66
+ serializable_hash.without("discord").merge(discord: discord.to_session)
67
+ end
68
+
69
+ def self.from_json(data)
70
+ raise(ArgumentError, "invalid data") if data.blank?
71
+
72
+ data = JSON.parse(data) if data.is_a?(String)
73
+ data = ::YiffSpace::Utils::OpenHash.from(data)
74
+
75
+ user_data = ::YiffSpace::Utils::OpenHash.from(data.user)
76
+ new(
77
+ id: data.id,
78
+ discord: data.discord,
79
+ user: ::YiffSpace::Auth.unserialize_user(user_data),
80
+ client_id: user_data.client_id,
81
+ )
82
+ end
83
+
84
+ def self.from_session(data)
85
+ return nil if data.blank?
86
+
87
+ from_json(data)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("openid_connect")
4
+
5
+ module YiffSpace
6
+ module Auth
7
+ class SerializeError < StandardError; end
8
+
9
+ ExchangeResponse = Struct.new(:auth, :user)
10
+ CLIENT_NAME_ENV = "yiffspace.auth.client_name"
11
+ DEFAULT_CLIENT_NAME = :default
12
+
13
+ @clients = {}
14
+ @enable_debug_action = false
15
+
16
+ module_function
17
+
18
+ def register(name, &block)
19
+ client = Client.new(name)
20
+ block&.call(client)
21
+ @clients[name.to_sym] = client
22
+ client
23
+ end
24
+
25
+ def [](name)
26
+ @clients[name.to_sym] or raise(KeyError, "unknown auth client: #{name.inspect}")
27
+ end
28
+
29
+ def default
30
+ @clients[DEFAULT_CLIENT_NAME] || raise("no default client configured")
31
+ end
32
+
33
+ def find_by_client_id(client_id)
34
+ @clients.values.find { |c| c.oidc_client.identifier == client_id }
35
+ end
36
+
37
+ def serialize_token(token)
38
+ { attributes: token.raw_attributes.without("client"), client_id: token.client.identifier }
39
+ end
40
+
41
+ def unserialize_token(data)
42
+ raise(SerializeError, "no token data provided") if data.blank?
43
+ return data if data.is_a?(OpenIDConnect::AccessToken)
44
+
45
+ data = JSON.parse(data) if data.is_a?(String)
46
+ data = ::YiffSpace::Utils::OpenHash.from(data)
47
+ raise(SerializeError, "no client id for token, refusing to reconstruct") if data.client_id.nil?
48
+
49
+ client_config = find_by_client_id(data.client_id)
50
+ raise(SerializeError, "unknown client_id #{data.client_id.inspect}") unless client_config
51
+
52
+ OpenIDConnect::AccessToken.new(data.attributes.merge(client: client_config.oidc_client))
53
+ end
54
+
55
+ def serialize_user(user, client_id:)
56
+ { attributes: user.raw_attributes, client_id: client_id }
57
+ end
58
+
59
+ def unserialize_user(data)
60
+ raise(SerializeError, "no user data provided") if data.blank?
61
+ return data if data.is_a?(OpenIDConnect::ResponseObject::UserInfo)
62
+
63
+ data = JSON.parse(data) if data.is_a?(String)
64
+ data = ::YiffSpace::Utils::OpenHash.from(data)
65
+ raise(SerializeError, "no client id for user, refusing to reconstruct") if data.client_id.nil?
66
+
67
+ find_by_client_id(data.client_id) or raise(SerializeError, "unknown client_id #{data.client_id.inspect}")
68
+
69
+ OpenIDConnect::ResponseObject::UserInfo.new(data.attributes)
70
+ end
71
+
72
+ def enable_debug_action?
73
+ @enable_debug_action
74
+ end
75
+
76
+ def enable_debug_action!
77
+ @enable_debug_action = true
78
+ end
79
+
80
+ def disable_debug_action!
81
+ @enable_debug_action = false
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This is intended to only be used externally, it is NOT reusable, do not use it within this project, or attempt to use it multiple times!!
4
+ module YiffSpace
5
+ class ConfigBuilder
6
+ include(Singleton)
7
+
8
+ cattr_accessor(:list, default: [])
9
+ cattr_accessor(:env_set, default: {})
10
+ cattr_accessor(:unset, default: [])
11
+ cattr_accessor(:required, default: [])
12
+ cattr_accessor(:reviver_map, default: {})
13
+ cattr_accessor(:subconfigs, default: Hash.new { |h, k| h[k] = {} })
14
+ cattr_accessor(:env_name, default: "CONFIG")
15
+
16
+ def self.config(name, type = :string, env: true, required: false, blank: false, &block)
17
+ name = name.to_sym
18
+ remove_config(name) if list.include?(name)
19
+ list << name
20
+ self.required << name if required
21
+ if block.nil?
22
+ unset << name
23
+ block = -> { raise(NotImplementedError, "Config option #{name} is not set") }
24
+ end
25
+ define_method(name) do |*args|
26
+ env_or_value(name, args, blank: blank, &block)
27
+ end
28
+ if type == :boolean
29
+ define_method("#{name}?") do |*args|
30
+ public_send(name, *args)
31
+ end
32
+ end
33
+ reviver(name, type) if type && type != :string
34
+ env_set[name] = env
35
+ end
36
+
37
+ def self.remove_config(name, reviver: false)
38
+ name = name.to_sym
39
+ list.delete(name)
40
+ required.delete(name)
41
+ unset.delete(name)
42
+ reviver_map.delete(name) if reviver
43
+ env_set.delete(name)
44
+ remove_method(name)
45
+ remove_method("#{name}?") if method_defined?("#{name}?")
46
+ end
47
+
48
+ def env_or_value(name, args, blank: false, &)
49
+ value = env(name)
50
+ value = nil if !value.nil? && value.blank? && !blank
51
+ if value.nil?
52
+ instance_exec(*args, &)
53
+ else
54
+ reviver = reviver_map.fetch(name, proc(&:itself))
55
+ instance_exec(value, *args, &reviver)
56
+ end
57
+ end
58
+
59
+ def env(name)
60
+ ENV.fetch("#{self.class.env_name}_#{name.to_s.upcase}", nil)
61
+ end
62
+
63
+ def self.reviver(name, type = nil, &block)
64
+ if block
65
+ reviver_map[name] = block
66
+ return
67
+ end
68
+ method = case type
69
+ when :boolean
70
+ ->(v) { !v.match?(/\A(false|f|no|n|off|0)\z/i) }
71
+ when :integer
72
+ ->(v) { v.to_i }
73
+ when :symbol
74
+ ->(v) { v.to_sym }
75
+ when :array
76
+ ->(v) { v.split(/\s*,\s*/) }
77
+ else
78
+ raise(ArgumentError, "not sure how to revive #{type} for #{method}")
79
+ end
80
+ reviver_map[name] = method
81
+ end
82
+
83
+ def self.subconfig(prefix, parent: [], &block)
84
+ raise(ArgumentError, "block required") unless block
85
+
86
+ prefix = prefix.to_sym
87
+ path = parent + [prefix]
88
+
89
+ node = subconfigs
90
+ path.each do |p|
91
+ node[p] ||= {}
92
+ node = node[p]
93
+ end
94
+
95
+ define_method(prefix) { SubconfigProxy.new(self, path) } unless method_defined?(prefix)
96
+
97
+ collector = Module.new do
98
+ def self.collected
99
+ @collected ||= []
100
+ end
101
+
102
+ def self.config(name, type = :string, env: true, required: false, blank: false, &block)
103
+ collected << [:config, name, type, env, required, blank, block]
104
+ end
105
+
106
+ def self.reviver(name, type = nil, &block)
107
+ collected << [:reviver, name, type, block]
108
+ end
109
+
110
+ def self.subconfig(name, &block)
111
+ collected << [:subconfig, name, block]
112
+ end
113
+ end
114
+
115
+ collector.module_eval(&block)
116
+
117
+ collector.collected.each do |kind, name, *args|
118
+ case kind
119
+ when :config
120
+ full = (path + [name]).join("_").to_sym
121
+ config(full, args[0], env: args[1], required: args[2], blank: args[3], &args[4])
122
+ when :reviver
123
+ full = (path + [name]).join("_").to_sym
124
+ reviver(full, args[0], &args[1])
125
+ when :subconfig
126
+ subconfig(name, parent: path, &args[0])
127
+ end
128
+ end
129
+ end
130
+
131
+ def self.ensure_required_set!
132
+ unset = []
133
+ required.each do |name|
134
+ value = begin
135
+ instance.public_send(name)
136
+ rescue StandardError
137
+ nil
138
+ end
139
+ unset << name if value.blank?
140
+ end
141
+
142
+ raise("Missing required configuration options: #{unset.join(', ')}") if unset.any?
143
+ end
144
+
145
+ def present?(*names)
146
+ names.flatten.all? { |name| public_send(name).present? }
147
+ end
148
+
149
+ class SubconfigProxy
150
+ def initialize(owner, path)
151
+ @owner = owner
152
+ @path = path
153
+ end
154
+
155
+ def method_missing(name, *, &)
156
+ name = name.to_sym
157
+ full_path = @path + [name]
158
+
159
+ flat = full_path.join("_").to_sym
160
+ return @owner.public_send(flat, *, &) if @owner.respond_to?(flat)
161
+
162
+ return SubconfigProxy.new(@owner, full_path) if subconfig_path?(full_path)
163
+
164
+ super
165
+ end
166
+
167
+ def respond_to_missing?(name, include_private = false)
168
+ name = name.to_sym
169
+ flat = (@path + [name]).join("_").to_sym
170
+
171
+ @owner.respond_to?(flat, include_private) ||
172
+ subconfig_path?(@path + [name]) ||
173
+ super
174
+ end
175
+
176
+ private
177
+
178
+ def subconfig_path?(path)
179
+ node = ConfigBuilder.subconfigs
180
+ path.each do |p|
181
+ return false unless node.key?(p)
182
+
183
+ node = node[p]
184
+ end
185
+ true
186
+ end
187
+ end
188
+ end
189
+ end