yiffspace 0.0.24 → 0.1.0

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/lib/yiffspace/concerns/active_record_extensions.rb +3 -3
  4. data/lib/yiffspace/configuration.rb +0 -25
  5. data/lib/yiffspace/{railtie.rb → engine.rb} +9 -7
  6. data/lib/yiffspace/extensions/active_record/where_chain.rb +30 -30
  7. data/lib/yiffspace/include/active_record_extensions.rb +5 -0
  8. data/lib/yiffspace/include/all.rb +12 -0
  9. data/lib/yiffspace/include/api_methods.rb +5 -0
  10. data/lib/yiffspace/include/attribute_matchers.rb +5 -0
  11. data/lib/yiffspace/include/attribute_methods.rb +5 -0
  12. data/lib/yiffspace/include/concurrency_methods.rb +5 -0
  13. data/lib/yiffspace/include/conditional_includes.rb +5 -0
  14. data/lib/yiffspace/include/current_methods.rb +5 -0
  15. data/lib/yiffspace/include/has_bit_flags.rb +5 -0
  16. data/lib/yiffspace/include/user_class_methods.rb +5 -0
  17. data/lib/yiffspace/include/user_methods.rb +5 -0
  18. data/lib/yiffspace/include/user_name_methods.rb +5 -0
  19. data/lib/yiffspace/include/user_resolvable_methods.rb +5 -0
  20. data/lib/yiffspace/version.rb +1 -1
  21. data/lib/yiffspace.rb +5 -6
  22. metadata +15 -50
  23. data/app/helpers/yiff_space/auth_helper.rb +0 -6
  24. data/engines/auth/app/controllers/yiff_space/auth/application_controller.rb +0 -11
  25. data/engines/auth/app/controllers/yiff_space/auth/root_controller.rb +0 -49
  26. data/engines/auth/app/controllers/yiff_space/auth/webhook_controller.rb +0 -73
  27. data/engines/auth/app/views/yiff_space/auth/root/permissions.html.erb +0 -14
  28. data/engines/auth/config/routes.rb +0 -12
  29. data/lib/tasks/yiffspace_tasks.rake +0 -41
  30. data/lib/yiffspace/auth/api_user.rb +0 -43
  31. data/lib/yiffspace/auth/auth_info/anonymous.rb +0 -60
  32. data/lib/yiffspace/auth/auth_info.rb +0 -78
  33. data/lib/yiffspace/auth/client.rb +0 -56
  34. data/lib/yiffspace/auth/discord_info.rb +0 -65
  35. data/lib/yiffspace/auth/engine.rb +0 -58
  36. data/lib/yiffspace/auth/helper.rb +0 -174
  37. data/lib/yiffspace/auth/permissions.rb +0 -87
  38. data/lib/yiffspace/auth/set_client_name.rb +0 -15
  39. data/lib/yiffspace/auth/user_info/anonymous.rb +0 -72
  40. data/lib/yiffspace/auth/user_info.rb +0 -87
  41. data/lib/yiffspace/auth.rb +0 -44
  42. data/lib/yiffspace/core_ext/action_dispatch/set_auth_client/scoped.rb +0 -13
  43. data/lib/yiffspace/core_ext/action_dispatch/set_auth_client.rb +0 -13
  44. data/lib/yiffspace/core_ext/logto/named_session_storage.rb +0 -7
  45. data/lib/yiffspace/extensions/action_dispatch/set_auth_client/scoped.rb +0 -15
  46. data/lib/yiffspace/extensions/action_dispatch/set_auth_client.rb +0 -13
  47. data/lib/yiffspace/extensions/logto/named_session_storage.rb +0 -22
  48. data/lib/yiffspace/logto_management_client.rb +0 -136
  49. data/lib/yiffspace/serializers/anonymous_auth_info_serializer.rb +0 -23
  50. data/lib/yiffspace/serializers/anonymous_user_info_serializer.rb +0 -23
  51. data/lib/yiffspace/serializers/auth_info_serializer.rb +0 -23
  52. data/lib/yiffspace/serializers/discord_info_serializer.rb +0 -23
  53. data/lib/yiffspace/serializers/permissions_serializer.rb +0 -23
  54. data/lib/yiffspace/serializers/user_info_serializer.rb +0 -23
  55. data/lib/yiffspace/utils/user_deduper.rb +0 -66
@@ -1,14 +0,0 @@
1
- <h1 class="title">Permissions</h1>
2
- <% if logged_in? %>
3
- <% if auth.permissions.any? %>
4
- <ul class="permissions-list">
5
- <% auth.permissions.each do |perm| %>
6
- <li class="permission"><%= perm %></li>
7
- <% end %>
8
- </ul>
9
- <% else %>
10
- <p class="message">You have no permissions.</p>
11
- <% end %>
12
- <% else %>
13
- <p class="message">You are not logged in.</p>
14
- <% end %>
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- YiffSpace::Auth::Engine.routes.draw do
4
- constraints(YiffSpace::Auth::SetClientName.default) do
5
- post(:webhook, controller: :webhook)
6
- get(:cb, controller: :root)
7
- get(:logout, controller: :root)
8
- get(:permissions, controller: :root)
9
- get(:debug, controller: :root) if YiffSpace::Auth.enable_debug_action?
10
- root(action: :show, controller: :root, as: :auth)
11
- end
12
- end
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- namespace(:yiffspace) do
4
- desc("Report and interactively remove duplicate/orphaned Logto users left behind by " \
5
- "get_or_create_user races. Prompts for confirmation before deleting each one. " \
6
- "Pass AUTH_CLIENT=name to use a registered auth client other than :default " \
7
- "(e.g. AUTH_CLIENT=yiffyapi_manage).")
8
- task(dedupe_users: :environment) do
9
- client_name = (ENV["AUTH_CLIENT"] || YiffSpace::Auth::DEFAULT_CLIENT_NAME).to_sym
10
- management = YiffSpace::Auth[client_name].logto_management
11
-
12
- puts("Scanning users via auth client #{client_name.inspect}...")
13
- result = YiffSpace::Utils::UserDeduper.scan(management)
14
-
15
- puts("\n#{result[:conflicts].size} discord id(s) with more than one linked user (ambiguous - not handled by this task, review manually):")
16
- result[:conflicts].each do |discord_id, group|
17
- puts(" discordId=#{discord_id}: #{group.map { |u| "#{u.id} (#{u.name})" }.join(', ')}")
18
- end
19
-
20
- puts("\n#{result[:unresolved_orphans].size} orphaned user(s) with no matching linked account (not handled by this task, review manually):")
21
- result[:unresolved_orphans].each do |user|
22
- puts(" #{user.id} (name=#{user.name}, avatar=#{user.data.avatar})")
23
- end
24
-
25
- puts("\n#{result[:deletable_orphans].size} confirmed duplicate(s) - orphan left by a losing get_or_create_user race:")
26
- if result[:deletable_orphans].empty?
27
- puts(" none found")
28
- else
29
- result[:deletable_orphans].each do |orphan, keeper|
30
- print(" delete #{orphan.id} (name=#{orphan.name}, avatar=#{orphan.data.avatar}), keeping #{keeper.id} (name=#{keeper.name}) for discordId=#{YiffSpace::Utils::UserDeduper.discord_id_for(keeper)}? [y/N] ")
31
- answer = $stdin.gets&.strip&.downcase
32
- if answer == "y"
33
- management.delete_user(orphan.id)
34
- puts(" deleted #{orphan.id}")
35
- else
36
- puts(" skipped")
37
- end
38
- end
39
- end
40
- end
41
- end
@@ -1,43 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module YiffSpace
4
- module Auth
5
- class ApiUser
6
- attr_reader(:data)
7
-
8
- def initialize(data)
9
- @data = Utils::OpenHash.from(data)
10
- end
11
-
12
- delegate(:id, :name, to: :data)
13
-
14
- def discord_id
15
- data.identities.discord.userId
16
- end
17
-
18
- def created_at
19
- Time.zone.at(data.createdAt)
20
- end
21
-
22
- def updated_at
23
- Time.zone.at(data.updatedAt)
24
- end
25
-
26
- def last_sign_in_at
27
- data.lastSignInAt.nil? ? nil : Time.zone.at(data.lastSignInAt)
28
- end
29
-
30
- def discord
31
- DiscordInfo.new(data.identities.discord.details.rawData)
32
- end
33
-
34
- def avatar
35
- Images::Avatar.get_for(discord_id, :discord)
36
- end
37
-
38
- def banner
39
- Images::Banner.get_for(discord_id, :discord)
40
- end
41
- end
42
- end
43
- end
@@ -1,60 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require("singleton")
4
-
5
- module YiffSpace
6
- module Auth
7
- class AuthInfo
8
- class Anonymous
9
- include(::Singleton)
10
-
11
- %i[id token].each do |attr|
12
- define_method(attr) { |*, **| raise(NotImplementedError, "#{attr} is not present on anonymous auth") }
13
- end
14
-
15
- def roles
16
- []
17
- end
18
-
19
- def permissions
20
- Permissions.new([])
21
- end
22
-
23
- def has_permission?(*)
24
- false
25
- end
26
-
27
- def anonymous?
28
- true
29
- end
30
-
31
- # this feels wrong, but it hopefully shouldn't break anything
32
- def present?
33
- false
34
- end
35
-
36
- def blank?
37
- true
38
- end
39
-
40
- def serializable_hash(*)
41
- nil
42
- end
43
-
44
- def to_session
45
- serializable_hash
46
- end
47
-
48
- def self.from_json(*)
49
- Anonymous.instance
50
- end
51
-
52
- def self.from_session(data)
53
- return nil if data.blank?
54
-
55
- from_json(data)
56
- end
57
- end
58
- end
59
- end
60
- end
@@ -1,78 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module YiffSpace
4
- module Auth
5
- class AuthInfo
6
- attr_reader(:id, :token, :roles, :permissions, :client_id)
7
-
8
- # @param id String
9
- # @param roles Array(String)
10
- # @param permissions Array(String)
11
- # @param token LogtoClient::AccessTokenClaims
12
- # @param client_id String
13
- def initialize(id:, roles:, permissions:, token:, client_id:)
14
- raise(ArgumentError, "no id provided") if id.blank?
15
- raise(ArgumentError, "no token provided") if token.blank?
16
- raise(ArgumentError, "no client id provided") if client_id.blank?
17
-
18
- @id = id
19
- @token = token
20
- @roles = Array(roles)
21
- @permissions = Permissions.new(permissions, separator: YiffSpace::Auth.get_by_id(client_id).permissions_separator)
22
- @client_id = client_id
23
- end
24
-
25
- def anonymous?
26
- false
27
- end
28
-
29
- # this feels wrong, but it hopefully shouldn't break anything
30
- def present?
31
- true
32
- end
33
-
34
- def blank?
35
- false
36
- end
37
-
38
- def has_permission?(name)
39
- permissions.include?(name.to_s)
40
- end
41
-
42
- def serializable_hash(*)
43
- {
44
- "id" => id,
45
- "token" => token.as_json,
46
- "roles" => roles,
47
- "permissions" => permissions.values,
48
- "client_id" => client_id,
49
- }
50
- end
51
-
52
- def to_session
53
- serializable_hash
54
- end
55
-
56
- def self.from_json(data)
57
- raise(ArgumentError, "invalid data") if data.blank?
58
-
59
- data = JSON.parse(data) if data.is_a?(String)
60
- data = ::YiffSpace::Utils::OpenHash.from(data)
61
-
62
- new(
63
- id: data.id,
64
- token: data.token,
65
- roles: data.roles,
66
- permissions: data.permissions,
67
- client_id: data.client_id,
68
- )
69
- end
70
-
71
- def self.from_session(data)
72
- return nil if data.blank?
73
-
74
- from_json(data)
75
- end
76
- end
77
- end
78
- end
@@ -1,56 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative("../core_ext/logto/named_session_storage")
4
- require("httparty")
5
-
6
- module YiffSpace
7
- module Auth
8
- class Client
9
- attr_accessor(:client_id, :client_secret, :scopes, :resource,
10
- :redirect_uri, :server_url, :auth_session_key, :user_session_key,
11
- :update_discord_images, :permissions_separator, :logto_webhook_secret)
12
-
13
- attr_reader(:name)
14
-
15
- def initialize(name)
16
- @name = name.to_sym
17
- @scopes = %i[openid offline_access profile roles identities custom_data]
18
- @redirect_uri = "http://127.0.0.1:3000/auth/cb"
19
- @server_url = "https://auth.yiff.space"
20
- @auth_session_key = :"yiffspace_auth_#{name}"
21
- @user_session_key = :"yiffspace_user_#{name}"
22
- @update_discord_images = true
23
- @permissions_separator = ":"
24
- @logto_webhook_secret = nil
25
- end
26
-
27
- def logto(controller)
28
- LogtoClient.new(
29
- config: LogtoClient::Config.new(
30
- endpoint: server_url,
31
- app_id: client_id,
32
- app_secret: client_secret,
33
- scopes: scopes,
34
- resources: [resource].compact,
35
- ),
36
- # Allow the client to redirect to other hosts (i.e. your Logto tenant)
37
- navigate: ->(uri) { controller.redirect_to(uri, allow_other_host: true) },
38
- # Controller has access to the session object
39
- storage: LogtoClient::NamedSessionStorage.new("yiffspace", controller.session, app_id: @name),
40
- )
41
- end
42
-
43
- def logto_management
44
- @logto_management ||= LogtoManagementClient.new(self)
45
- end
46
-
47
- def fetch_discord_user(id)
48
- response = HTTParty.get("https://discord.com/api/v10/users/#{id}", { headers: { "Authorization" => "Bot #{YiffSpace.config.discord_bot_token}" } })
49
- return nil if response.code == 404
50
- raise("failed to fetch discord user: #{response.code} #{response.message}\n#{response.parsed_response.inspect}") if response.code != 200
51
-
52
- DiscordInfo.new(response.parsed_response)
53
- end
54
- end
55
- end
56
- end
@@ -1,65 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module YiffSpace
4
- module Auth
5
- class DiscordInfo
6
- # locale, mfa_enabled, and premium_type are not present for legacy users backfilled from api data,
7
- # we don't need that data either way
8
- ATTRIBUTES = %i[id flags avatar banner username global_name public_flags discriminator].freeze
9
- attr_reader(:data, *ATTRIBUTES)
10
-
11
- def initialize(options = {}, **kwargs)
12
- @data = options.merge(kwargs).with_indifferent_access
13
- ATTRIBUTES.each do |attr|
14
- instance_variable_set("@#{attr}", @data[attr])
15
- end
16
-
17
- return unless YiffSpace.config.auth.update_discord_images
18
-
19
- last_avatar = Rails.cache.fetch("yiffspace:auth:discord_avatar_update:#{id}")
20
- last_banner = Rails.cache.fetch("yiffspace:auth:discord_banner_update:#{id}")
21
-
22
- if last_avatar != avatar
23
- Rails.cache.write("yiffspace:auth:discord_avatar_update:#{id}", avatar, expires_in: 30.days)
24
- update_avatar
25
- end
26
-
27
- return unless last_banner != banner
28
-
29
- Rails.cache.write("yiffspace:auth:discord_banner_update:#{id}", banner, expires_in: 30.days)
30
- update_banner
31
- end
32
-
33
- def update_avatar
34
- Images::Avatar.get_for(id, :discord).update(avatar)
35
- end
36
-
37
- def update_banner
38
- Images::Banner.get_for(id, :discord).update(banner)
39
- end
40
-
41
- def serializable_hash(*)
42
- ATTRIBUTES.to_h { |key| [key.to_s, instance_variable_get("@#{key}")] }
43
- end
44
-
45
- def to_session
46
- serializable_hash
47
- end
48
-
49
- def self.from_json(data)
50
- raise(ArgumentError, "invalid data") if data.blank?
51
-
52
- data = JSON.parse(data) if data.is_a?(String)
53
- data = ::YiffSpace::Utils::OpenHash.from(data)
54
-
55
- new(data)
56
- end
57
-
58
- def self.from_session(data)
59
- return nil if data.blank?
60
-
61
- from_json(data)
62
- end
63
- end
64
- end
65
- end
@@ -1,58 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require("rails")
4
-
5
- module YiffSpace
6
- module Auth
7
- class Engine < ::Rails::Engine
8
- isolate_namespace(YiffSpace::Auth)
9
- config.root = File.expand_path("../../../engines/auth", __dir__)
10
- shared_root = File.expand_path("../../../app", __dir__)
11
- shared_controllers = "#{shared_root}/controllers"
12
- shared_helpers = "#{shared_root}/helpers"
13
- shared_models = "#{shared_root}/models"
14
- shared_views = "#{shared_root}/views"
15
- shared_assets = "#{shared_root}/assets"
16
-
17
- paths["app/controllers"] << shared_controllers
18
- paths["app/helpers"] << shared_helpers
19
- paths["app/models"] << shared_models
20
- paths["app/views"] << shared_views
21
- paths["app/assets"] << shared_assets
22
-
23
- config.autoload_paths += [shared_controllers, shared_helpers, shared_models]
24
- config.eager_load_paths += [shared_controllers, shared_helpers, shared_models]
25
-
26
- initializer("yiffspace.auth.asset_paths") do |app|
27
- if app.config.respond_to?(:assets)
28
- app.config.assets.paths << "#{shared_assets}/config"
29
- app.config.assets.precompile += %w[yiffspace/application.css]
30
- end
31
- end
32
-
33
- class << self
34
- def for(name)
35
- @instances ||= {}
36
- @instances[name.to_sym] ||= begin
37
- subclass = Class.new(self)
38
- subclass.engine_name("yiffspace_auth_#{name}")
39
- # Inherit isolation settings that aren't copied from the parent class
40
- subclass.instance_variable_set(:@isolated, true)
41
- subclass.routes.default_scope = { module: "yiff_space/auth" }
42
- subclass.routes.draw do
43
- constraints(SetClientName.new(name)) do
44
- post(:webhook, controller: :webhook, action: :create)
45
- get(:cb, controller: :root)
46
- get(:logout, controller: :root)
47
- get(:permissions, controller: :root)
48
- get(:debug, controller: :root) if ::YiffSpace::Auth.enable_debug_action?
49
- root(action: :show, controller: :root, as: :auth)
50
- end
51
- end
52
- subclass
53
- end
54
- end
55
- end
56
- end
57
- end
58
- end
@@ -1,174 +0,0 @@
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 full_reset!
76
- reset_auth!
77
- reset_user!
78
- end
79
-
80
- def require_auth(path)
81
- redirect_to(path) unless logged_in?
82
- end
83
-
84
- def logged_in?
85
- auth? && user?
86
- end
87
-
88
- def has_permission?(name)
89
- return false unless logged_in?
90
-
91
- auth.permissions.has?(name)
92
- end
93
-
94
- DIRTY_FLAG_KEY = "yiffspace:auth:dirty:%s"
95
-
96
- # Checks the dirty flag written by the Logto webhook handler. If set, re-fetches
97
- # the user's current roles and permissions from the Logto Management API and
98
- # rewrites the session — without waiting for the access token to expire.
99
- # Call this as a before_action in any controller that needs instant revocation.
100
- def sync_auth_if_dirty!
101
- return unless auth?
102
-
103
- flag_key = format(DIRTY_FLAG_KEY, auth.id)
104
- return unless Rails.cache.exist?(flag_key)
105
-
106
- Rails.cache.delete(flag_key)
107
-
108
- management = auth_client_config.logto_management
109
- api_user = management.get_user_by_id(auth.id)
110
-
111
- if api_user.nil? || api_user.data["isSuspended"]
112
- full_reset!
113
- return
114
- end
115
-
116
- roles = management.get_user_roles(auth.id)
117
- permissions = roles.flat_map { |role| management.get_role_scopes(role["id"]) }
118
- .pluck("name")
119
- .uniq
120
-
121
- self.auth = AuthInfo.new(
122
- id: auth.id,
123
- token: auth.token,
124
- roles: roles.pluck("name"),
125
- permissions: permissions,
126
- client_id: auth.client_id,
127
- )
128
- end
129
-
130
- def url_helpers
131
- YiffSpace::Auth::Engine.for(client_name).routes.url_helpers
132
- end
133
-
134
- # Returns the Auth::Client for the current request. In auth engine controllers this is
135
- # resolved from the routing default set by Engine.for; in host app controllers it falls
136
- # back to the default registered client. Override in your controller to choose a specific
137
- # client when multiple are registered.
138
- def auth_client_config
139
- client_name = self.client_name
140
- client_name.present? ? YiffSpace::Auth[client_name.to_sym] : YiffSpace::Auth.default
141
- end
142
-
143
- def client_name
144
- respond_to?(:request, true) && request.env[CLIENT_NAME_ENV]
145
- end
146
-
147
- def client_name=(value)
148
- request.env[CLIENT_NAME_ENV] = value.to_sym
149
- end
150
-
151
- module Scoped
152
- extend(ActiveSupport::Concern)
153
- include(Helper)
154
-
155
- included do
156
- private(*Helper.instance_methods(false))
157
- private_class_method(*Helper::ClassMethods.instance_methods(false))
158
- end
159
-
160
- Helper.instance_methods(false).each do |name|
161
- define_method("yiffspace_#{name}") { |*args, **kwargs, &block| send(name, *args, **kwargs, &block) }
162
- end
163
-
164
- module ClassMethods
165
- include(Helper::ClassMethods)
166
-
167
- Helper::ClassMethods.instance_methods(false).each do |name|
168
- define_method("yiffspace_#{name}") { |*args, **kwargs, &block| send(name, *args, **kwargs, &block) }
169
- end
170
- end
171
- end
172
- end
173
- end
174
- end
@@ -1,87 +0,0 @@
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, separator: ":")
13
- @values = perms
14
- @tree = {}
15
- @separator = separator
16
-
17
- perms.each do |perm|
18
- current = @tree
19
- parts = perm.split(separator)
20
-
21
- parts.each do |part|
22
- current[part] ||= {}
23
- current = current[part]
24
- end
25
-
26
- # mark leaf
27
- current[:__leaf__] = true
28
- end
29
- end
30
-
31
- def has?(perm)
32
- current = @tree
33
- parts = perm.split(@separator)
34
-
35
- parts.each do |part|
36
- return false unless current[part]
37
-
38
- current = current[part]
39
- end
40
-
41
- current[:__leaf__] == true
42
- end
43
-
44
- alias include? has?
45
-
46
- def method_missing(name, *_args)
47
- str = name.to_s
48
-
49
- if str.end_with?("?")
50
- key = str[0..-2]
51
- return @tree[key]&.dig(:__leaf__) == true
52
- end
53
-
54
- if @tree.key?(str)
55
- self.class.new_from_subtree(@tree[str], @separator)
56
- else
57
- # return empty node instead of raising
58
- self.class.new([], separator: @separator)
59
- end
60
- end
61
-
62
- def respond_to_missing?(_name, _include_private = false)
63
- true
64
- end
65
-
66
- def self.new_from_subtree(tree, separator)
67
- obj = allocate
68
- obj.instance_variable_set(:@tree, tree)
69
- obj.instance_variable_set(:@values, leaves_from_tree(tree))
70
- obj.instance_variable_set(:@separator, separator)
71
- obj
72
- end
73
-
74
- def self.leaves_from_tree(node, prefix = "")
75
- result = []
76
- node.each do |key, subtree|
77
- next if key == :__leaf__
78
-
79
- path = prefix.empty? ? key : "#{prefix}.#{key}"
80
- result << path if subtree[:__leaf__]
81
- result.concat(leaves_from_tree(subtree, path))
82
- end
83
- result
84
- end
85
- end
86
- end
87
- end