yiffspace-user 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: db2366c67818c97d4f8ce3ecb53816f6a84e0fb2a3b9faf952445a385d2f6a51
4
+ data.tar.gz: bff3d69f13b880865ff6f812fff8eb8da65108f6ed3bd9d87cf5d7cb4c570ce2
5
+ SHA512:
6
+ metadata.gz: c616f03209b48f1bc85bfa134c2ac969b9eda29c0fee241124dde30757c68521f92e7e201161f2d0c86ed327bfc4508393583e07c2c81b9e986f22c1d0bcc8cc
7
+ data.tar.gz: 503228eec1c7c14fe14be6bb53aaac465b3e0cdb93dfc1fa99f9093e5ef9c2223264273d6eca95f6cc19f6ec9eb2994031e3ec2673a66c459bdd76ba19321a13
data/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ ## 0.0.1
2
+
3
+ - Initial release, extracted from the `yiffspace` gem's User/Current/Resolvable
4
+ concerns and utils. `YiffSpace::Utils::Current`/`UserLike`/`UserResolvable`/
5
+ `UserAttribute`/`UserToId` are now `YiffSpace::User::Current`/`Like`/
6
+ `Resolvable`/`Attribute`/`ToId`.
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Donovan_DMC
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # YiffSpace::User
2
+
3
+ The User/Current/Resolvable concerns and utils - `YiffSpace::User::Current`,
4
+ `YiffSpace::User::Resolvable`, `YiffSpace::User::Like`, `YiffSpace::User::Attribute`,
5
+ `YiffSpace::User::ToId`, plus the `Concerns::User*`/`Concerns::Current*` model
6
+ concerns - for https://yiff.space and related projects.
7
+
8
+ ## Installation
9
+
10
+ Add this line to your application's Gemfile:
11
+
12
+ ```ruby
13
+ gem "yiffspace-user"
14
+ ```
15
+
16
+ And then execute:
17
+
18
+ ```bash
19
+ $ bundle install
20
+ ```
21
+
22
+ ## Contributing
23
+
24
+ Go away
25
+
26
+ ## License
27
+
28
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("bundler/gem_tasks")
4
+
5
+ # CI releases from an already-tagged, already-pushed commit checked out at a detached HEAD, so
6
+ # the default release task's git tag/push step has nothing to do and fails trying to push a
7
+ # branch ref that does not exist in that checkout.
8
+ Rake::Task["release"].clear
9
+ task("release" => "release:rubygem_push")
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module Concerns
5
+ module CurrentMethods
6
+ extend(ActiveSupport::Concern)
7
+
8
+ module ClassMethods
9
+ def search_current(params, ...)
10
+ search(params, YiffSpace.config.current_class.user, ...)
11
+ end
12
+
13
+ def with_current(instance, method, *args, **, &)
14
+ attr = args.grep(Symbol)
15
+ other = args - attr
16
+ hashes = other.grep(Hash)
17
+ params = other.grep(ActionController::Parameters)
18
+ other -= hashes + params
19
+ other.compact_blank!
20
+ options = [attr.index_with(YiffSpace.config.current_class.user), *hashes, *params.map(&:to_hash)].inject(:merge)
21
+ instance.send(method, *other, **options, **, &)
22
+ end
23
+
24
+ def new_with_current(...)
25
+ with_current(self, :new, ...)
26
+ end
27
+
28
+ def create_with_current(...)
29
+ with_current(self, :create, ...)
30
+ end
31
+
32
+ def create_with_current!(...)
33
+ with_current(self, :create!, ...)
34
+ end
35
+ end
36
+
37
+ def update_with_current(...)
38
+ self.class.with_current(self, :update, ...)
39
+ end
40
+
41
+ def update_with_current!(...)
42
+ self.class.with_current(self, :update!, ...)
43
+ end
44
+
45
+ def destroy_with_current(*attrs)
46
+ attrs.each do |attr|
47
+ send("#{attr}=", YiffSpace.config.current_class.user)
48
+ end
49
+ destroy
50
+ end
51
+
52
+ def destroy_with_current!(*attrs)
53
+ attrs.each do |attr|
54
+ send("#{attr}=", YiffSpace.config.current_class.user)
55
+ end
56
+ destroy!
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module Concerns
5
+ # methods to include into the user class for use with the like/resolvable classes
6
+ module UserClassMethods
7
+ extend(ActiveSupport::Concern)
8
+
9
+ def resolvable(ip_addr = nil)
10
+ YiffSpace.config.user_resolvable_class.new(self, ip_addr || "127.0.0.1")
11
+ end
12
+
13
+ def resolve
14
+ self
15
+ end
16
+
17
+ def ==(other)
18
+ return super if other.is_a?(YiffSpace.config.user_class)
19
+
20
+ other.is_a?(YiffSpace.config.user_resolvable_class) && super(other.user)
21
+ end
22
+
23
+ def ===(other)
24
+ other == YiffSpace.config.user_like_class || super
25
+ end
26
+
27
+ def is_a?(other)
28
+ other == YiffSpace.config.user_like_class || super
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module Concerns
5
+ module UserMethods
6
+ extend(ActiveSupport::Concern)
7
+
8
+ module ClassMethods
9
+ def belongs_to_user(*, **)
10
+ User::Attribute.new(self, *, **, db: true)
11
+ end
12
+
13
+ def resolvable(*, **)
14
+ User::Attribute.new(self, *, **, db: false)
15
+ end
16
+
17
+ def with_user(instance, method, user, *args, **, &)
18
+ default = { new: :creator, create: :creator, create!: :creator, update: :updater, update!: :updater, destroy: :destroyer, destroy!: :destroyer }.fetch(method)
19
+ attrs = args.grep(Symbol)
20
+ attrs = [default] if default && attrs.empty?
21
+ other = args - attrs
22
+ hashes = other.grep(Hash)
23
+ params = other.grep(ActionController::Parameters)
24
+ other -= hashes + params
25
+ other.compact_blank!
26
+ options = [attrs.index_with(user), *hashes, *params.map(&:to_hash)].inject(:merge)
27
+ if %i[destroy destroy!].include?(method)
28
+ options.each do |attr, value|
29
+ instance.send("#{attr}=", value)
30
+ end
31
+ instance.send(method)
32
+ else
33
+ instance.send(method, *other, **options, **, &)
34
+ end
35
+ end
36
+
37
+ def new_with(...)
38
+ with_user(self, :new, ...)
39
+ end
40
+
41
+ def create_with(...)
42
+ new_with(...).tap(&:save)
43
+ end
44
+
45
+ def create_with!(...)
46
+ new_with(...).tap(&:save!)
47
+ end
48
+ end
49
+
50
+ def update_with(...)
51
+ self.class.with_user(self, :update, ...)
52
+ end
53
+
54
+ def update_with!(...)
55
+ self.class.with_user(self, :update!, ...)
56
+ end
57
+
58
+ def destroy_with(...)
59
+ self.class.with_user(self, :destroy, ...)
60
+ end
61
+
62
+ def destroy_with!(...)
63
+ self.class.with_user(self, :destroy!, ...)
64
+ end
65
+
66
+ private
67
+
68
+ def put(attr, value, overwrite: false)
69
+ if respond_to?(:"#{attr}_id")
70
+ send(:"#{attr}=", value) if overwrite || send(:"#{attr}_id").blank?
71
+ elsif respond_to?(attr)
72
+ send(:"#{attr}=", value) if overwrite || send(attr).blank?
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module Concerns
5
+ module UserNameMethods
6
+ extend(ActiveSupport::Concern)
7
+
8
+ module ClassMethods
9
+ def name_to_id(name)
10
+ normalized_name = normalize_name(name)
11
+ Utils::Cache.fetch("uni:#{normalized_name}", expires_in: 4.hours) do
12
+ ::User.where("lower(name) = ?", normalized_name).pick(:id)
13
+ end
14
+ end
15
+
16
+ # @param arguments [Array<String, Array<String>>] a list of names
17
+ # @return [Hash{String => Integer, nil}] a hash of normalized names to user IDs
18
+ def bulk_name_to_id(*arguments)
19
+ names = arguments.flatten.map { |n| normalize_name(n) }
20
+ results = names.index_with { |name| Utils::Cache.fetch("uni:#{name}") }.compact_blank
21
+ missing = names - results.keys
22
+ fetched = ::User.where("lower(name) IN (?)", missing).select(:id, :name).to_h { |u| [normalize_name(u.name), u.id] }
23
+ not_found = (missing - fetched.keys).index_with { nil }
24
+ fetched.each { |name, id| Utils::Cache.write("uni:#{name}", id, expires_in: 4.hours) }
25
+ { **results, **fetched, **not_found }
26
+ end
27
+
28
+ def name_or_id_to_id(name)
29
+ return name[1..].to_i if name =~ /\A!\d+\z/
30
+
31
+ ::User.name_to_id(name)
32
+ end
33
+
34
+ def name_or_id_to_id_forced(name)
35
+ return name.to_i if name =~ /\A\d+\z/
36
+
37
+ ::User.name_to_id(name)
38
+ end
39
+
40
+ def id_to_name(user_id)
41
+ RequestStore[:id_name_cache] ||= {}
42
+ return RequestStore[:id_name_cache][user_id] if RequestStore[:id_name_cache].key?(user_id)
43
+
44
+ name = Utils::Cache.fetch("uin:#{user_id}", expires_in: 4.hours) do
45
+ ::User.where(id: user_id).pick(:name) || YiffSpace.config.anonymous_user_name.call
46
+ end
47
+ RequestStore[:id_name_cache][user_id] = name
48
+ name
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module Concerns
5
+ module UserResolvableMethods
6
+ def resolvable(ip_addr = nil)
7
+ attr = YiffSpace.config.last_ip_addr_attribute
8
+ User::Resolvable.new(self, ip_addr || (attr && respond_to?(attr) ? send(attr) : nil) || YiffSpace.config.default_ip_address)
9
+ end
10
+
11
+ def resolve
12
+ self
13
+ end
14
+
15
+ def is?(user_or_id)
16
+ id == u2id(user_or_id)
17
+ end
18
+
19
+ def ==(other)
20
+ return super if other.is_a?(YiffSpace.config.user_class)
21
+
22
+ other.is_a?(YiffSpace.config.user_resolvable_class) && super(other.user)
23
+ end
24
+
25
+ def ===(other)
26
+ other == YiffSpace.config.user_like_class || super
27
+ end
28
+
29
+ def is_a?(other)
30
+ other == YiffSpace.config.user_like_class || super
31
+ end
32
+
33
+ include(YiffSpace::User::ToId)
34
+
35
+ private(:u2id)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("active_job")
4
+
5
+ module YiffSpace
6
+ module Serializers
7
+ class UserResolvableSerializer < ActiveJob::Serializers::ObjectSerializer
8
+ def serialize(arg)
9
+ super("user" => arg.user&.id, "ip_addr" => arg.ip_addr)
10
+ end
11
+
12
+ def deserialize(arg)
13
+ YiffSpace.config.user_resolvable_class.new(arg["user"].blank? ? nil : YiffSpace.config.user_class.find(arg["user"]), arg["ip_addr"])
14
+ end
15
+
16
+ def klass
17
+ YiffSpace.config.user_resolvable_class
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module User
5
+ # User class must have id_to_name & name_to_id methods, and global u2id method must exist
6
+ class Attribute
7
+ class CircularAliasError < StandardError; end
8
+ class CircularCloneError < StandardError; end
9
+ class DuplicateAttributeError < StandardError; end
10
+ class CloneError < StandardError; end
11
+ attr_reader(:klass, :attribute, :db, :ip, :optional, :clones, :overwrite, :aliases, :ignore_nil, :ar_options)
12
+
13
+ # noinspection RubyUnusedLocalVariable
14
+ def initialize(klass, attribute, db:, ip: !db, optional: !db, clones: [], clone: [], overwrite: false, aliases: [], alias: [], ignore_nil: false, **ar_options)
15
+ Attribute.class_setup(klass)
16
+ @klass = klass
17
+ @attribute = attribute.to_sym
18
+ @db = db
19
+ # noinspection RubySimplifyBooleanInspection
20
+ @ip = ip == true ? :"#{attribute}_ip_addr" : ip
21
+ @optional = optional
22
+ @clones = Array(clones.presence || clone).map(&:to_sym)
23
+ @overwrite = overwrite
24
+ @aliases = Array(aliases.presence || binding.local_variable_get(:alias)).map(&:to_sym)
25
+ @ignore_nil = ignore_nil
26
+ @ar_options = ar_options
27
+ if ar_options.any? && !db # rubocop:disable Style/IfUnlessModifier
28
+ Utils::TraceLogger.warn("UserAttribute", "Unexpected extra options for non-db attribute: #{ar_options.inspect}", ignore: [%r{/concerns/user_methods\.rb}, %r{/logical/user_attribute\.rb}])
29
+ end
30
+ @ar_options[:optional] = optional
31
+ @ar_options[:class_name] = "User"
32
+ validate_options!
33
+ klass.user_attributes[attribute] = self
34
+ define_methods
35
+ end
36
+
37
+ def validate_options!
38
+ raise(DuplicateAttributeError, "user attribute #{attribute} is already defined on #{klass}") if klass.user_attributes.key?(attribute)
39
+
40
+ validate_aliases!
41
+ validate_clones!
42
+ end
43
+
44
+ def validate_aliases!
45
+ return if aliases.empty?
46
+ raise(CircularAliasError, "aliases must not contain attribute (#{klass}.#{attribute}") if aliases.include?(attribute)
47
+ end
48
+
49
+ def validate_clones!
50
+ return if clones.empty?
51
+ raise(CircularCloneError, "clones must not contain attribute (#{klass}.#{attribute}") if clones.include?(attribute)
52
+ # TODO: implement clone validation logic
53
+ end
54
+
55
+ def validate_value!(value, type, record: nil)
56
+ return if value.nil?
57
+
58
+ case type
59
+ when :user
60
+ return if (optional || ignore_nil) && value.nil?
61
+ return if value.is_a?(::User)
62
+
63
+ if value.is_a?(::UserResolvable)
64
+ return if value.user.is_a?(::User)
65
+
66
+ raise(ArgumentError, "Expected User for UserResolvable.user in #{klass.name}##{attribute}, got #{value.user.inspect} (#{value.user.class.name})")
67
+ end
68
+ value_error("is invalid", "UserLike", attribute, value, record: record)
69
+ when :id
70
+ value_error("is invalid", "Integer", "#{attribute}_id", value, record: record) unless value.is_a?(Integer) || (value.is_a?(String) && /\A\d+\Z/.match?(value))
71
+ when :name
72
+ value_error("is invalid", "String", "#{attribute}_name", value, record: record) unless value.is_a?(String)
73
+ when :ip_addr
74
+ return if value.is_a?(IPAddr)
75
+
76
+ begin
77
+ IPAddr.new(value)
78
+ rescue IPAddr::InvalidAddressError
79
+ value_error("is invalid", "IPAddr", ip, value, record: record)
80
+ end
81
+ else
82
+ raise(ArgumentError, "Not sure how to validate #{type}")
83
+ end
84
+ end
85
+
86
+ def value_error(message, expected, attr, value, record: nil)
87
+ raise(ArgumentError, "Expected #{expected} for #{klass.name}.#{attr}=, got #{value.inspect} (#{value.class.name})") unless record.respond_to?(:errors) && record.errors.is_a?(ActiveModel::Errors)
88
+
89
+ record.errors.add(attr, message)
90
+ end
91
+
92
+ def define_methods # rubocop:disable Metrics/MethodLength
93
+ ua = self
94
+ klass.define_singleton_method(:"#{attribute}_attribute") { ua }
95
+ klass.belongs_to(attribute, **ar_options) if db
96
+ if (klass < ActiveModel::Validations) && ip && !optional
97
+ # klass.validates(attribute, presence: { message: "must exist" }) unless optional
98
+ klass.validates(ip, presence: true, if: -> { send("#{ua.attribute}_id").present? })
99
+ end
100
+ klass.define_method(attribute) do
101
+ if ua.db
102
+ value = super()
103
+ else
104
+ value = instance_variable_get(:"@#{ua.attribute}")
105
+ value = ua.get_clone_value(self) if value.blank?
106
+ end
107
+ return if value.blank?
108
+
109
+ if ua.ip && (ip_addr = send(ua.ip))
110
+ ::UserResolvable.new(value, ip_addr)
111
+ else
112
+ value
113
+ end
114
+ end
115
+ klass.define_method("#{attribute}=") do |value|
116
+ ua.validate_value!(value, :user, record: self)
117
+ if ua.clones.any?
118
+ ua.clones.each do |cattr|
119
+ ua.clone_value(self, cattr, value)
120
+ end
121
+ end
122
+
123
+ if value.is_a?(::UserResolvable)
124
+ send("#{ua.ip}=", value.ip_addr) if ua.ip
125
+ value = value.user
126
+ end
127
+
128
+ if ua.db
129
+ super(value)
130
+ else
131
+ instance_variable_set(:"@#{ua.attribute}", value)
132
+ end
133
+ end
134
+ klass.define_method("#{attribute}_id") do
135
+ if ua.db
136
+ read_attribute("#{ua.attribute}_id")
137
+ else
138
+ value = instance_variable_get(:"@#{ua.attribute}")&.id
139
+ value = ua.get_clone_value(self, :id) if value.blank?
140
+ value
141
+ end
142
+ end
143
+ klass.define_method("#{attribute}_id=") do |value|
144
+ ua.validate_value!(value, :id, record: self)
145
+ if ua.db
146
+ write_attribute("#{ua.attribute}_id", value)
147
+ Utils::TraceLogger.warn("UserAttribute", "#{ua.klass.name}.#{ua.attribute}_id= should not be used when clone is enabled (#{ua.clones.join(', ')})", ignore: [%r{/concerns/user_methods\.rb}, %r{/logical/user_attribute\.rb}]) if ua.clones.any?
148
+ else
149
+ value = ::User.find(value) if value.is_a?(Integer) || value.is_a?(String)
150
+ ua.clones.each do |cattr|
151
+ ua.clone_direct(self, cattr, value)
152
+ end
153
+ instance_variable_set(:"@#{ua.attribute}", value)
154
+ end
155
+ end
156
+ klass.define_method("#{attribute}_name") do
157
+ value = send("#{ua.attribute}_id")
158
+ return "Anonymous" if value.blank?
159
+
160
+ ::User.id_to_name(value)
161
+ end
162
+ klass.define_method("#{attribute}_name=") do |value|
163
+ ua.validate_value!(value, :name, record: self)
164
+ value = ::User.name_to_id(value)
165
+ return send("#{ua.attribute}_id=", value) if value.blank?
166
+
167
+ send("#{ua.attribute}=", ::User.find(value))
168
+ end
169
+ if ip
170
+ klass.define_method(ip) do
171
+ if ua.db
172
+ read_attribute(ua.ip)
173
+ else
174
+ value = instance_variable_get(:"@#{ua.ip}")
175
+ value = ua.get_clone_value(self, :ip_addr) if value.blank?
176
+ value
177
+ end
178
+ end
179
+ klass.define_method("#{ip}=") do |value|
180
+ ua.validate_value!(value, :ip_addr, record: self)
181
+ if ua.db
182
+ write_attribute(ua.ip, value)
183
+ else
184
+ value = value.to_s.strip if value.is_a?(String)
185
+ ua.clones.each do |cattr|
186
+ ua.clone_ip(self, cattr, value)
187
+ end
188
+ instance_variable_set(:"@#{ua.ip}", value)
189
+ end
190
+ end
191
+ end
192
+ klass.instance_exec do
193
+ scope("for_#{ua.attribute}", ->(value) { where(ua.db && ua.ar_options.key?(:foreign_key) ? ua.ar_options[:foreign_key] : "#{ua.attribute}_id" => u2id(value)) }) unless respond_to?("for_#{ua.attribute}")
194
+ scope("for_#{ua.attribute}_id", ->(value) { where(ua.db && ua.ar_options.key?(:foreign_key) ? ua.ar_options[:foreign_key] : "#{ua.attribute}_id" => value) }) unless respond_to?("for_#{ua.attribute}_id")
195
+ scope("for_#{ua.attribute}_name", ->(value) { where(ua.db && ua.ar_options.key?(:foreign_key) ? ua.ar_options[:foreign_key] : "#{ua.attribute}_id" => YiffSpace.config.user_class.name_to_id(value)) }) unless respond_to?("for_#{ua.attribute}_name")
196
+ end
197
+
198
+ aliases.each do |alias_attr|
199
+ create_alias(alias_attr)
200
+ end
201
+ end
202
+
203
+ def get_clone_value(record, type = nil)
204
+ attrs = klass.user_attributes.select { |_attr, options| options.clones.include?(attribute) }
205
+ value = nil
206
+ attrs.each_key do |cattr|
207
+ case type
208
+ when :id
209
+ value = record.send("#{cattr}_id")
210
+ when :ip_addr
211
+ value = record.send(attrs[cattr].ip) if attrs[cattr].ip
212
+ else
213
+ value = record.send(cattr)
214
+ end
215
+ break if value.present?
216
+ end
217
+ value
218
+ end
219
+
220
+ def clone_value(record, attr, value)
221
+ return record.send("#{attr}=", value) if overwrite
222
+
223
+ if record.respond_to?(:"#{attr}_id")
224
+ record.send(:"#{attr}=", value) if record.send(:"#{attr}_id").blank?
225
+ elsif record.respond_to?(attr)
226
+ record.send(:"#{attr}=", value) if record.send(attr).blank?
227
+ else
228
+ raise(CloneError, "Not sure how to clone to #{attr}, #{record.class} does not respond_to? #{attr}_id or #{attr}")
229
+ end
230
+ end
231
+
232
+ def clone_ip(record, attr, value)
233
+ ip_attr = klass.user_attributes.find { |uattr, _value| uattr == attr }&.second&.ip
234
+ raise(CloneError, "Not sure how to clone ip for #{attr}, no ip attribute defined") unless ip_attr
235
+
236
+ clone_direct(record, ip_attr, value)
237
+ end
238
+
239
+ def clone_direct(record, attr, value)
240
+ return record.send("#{attr}=", value) if overwrite
241
+
242
+ raise(CloneError, "Not sure how to clone to #{attr}, #{record.class} does not respond_to? #{attr}") unless record.respond_to?(attr)
243
+
244
+ record.send(:"#{attr}=", value) if record.send(attr).blank?
245
+ end
246
+
247
+ def create_alias(attr)
248
+ ua = self
249
+ klass.class_eval do
250
+ define_method(attr) { send(ua.attribute) }
251
+ define_method("#{attr}=") { |value| send("#{ua.attribute}=", value) }
252
+ define_method("#{attr}_id") { send("#{ua.attribute}_id") }
253
+ define_method("#{attr}_id=") { |value| send("#{ua.attribute}_id=", value) }
254
+ define_method("#{attr}_name") { send("#{ua.attribute}_name") }
255
+ define_method("#{attr}_name=") { |value| send("#{ua.attribute}_name=", value) }
256
+ if ua.ip
257
+ define_method("#{attr}_ip_addr") { send(ua.ip) }
258
+ define_method("#{attr}_ip_addr=") { |value| send("#{ua.ip}=", value) }
259
+ end
260
+ end
261
+ end
262
+
263
+ def self.class_setup(klass)
264
+ return if klass.method_defined?(:user_attributes)
265
+
266
+ klass.class_eval do
267
+ class_attribute(:user_attributes, default: {})
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module User
5
+ class Current < ActiveSupport::CurrentAttributes
6
+ attribute(:user, :ip_addr, :request)
7
+
8
+ def initialize
9
+ super
10
+ reset
11
+ end
12
+
13
+ after_reset do
14
+ attributes[:user] = YiffSpace.config.anonymous_user_getter.call
15
+ attributes[:ip_addr] = YiffSpace.config.default_ip_address
16
+ end
17
+
18
+ def user
19
+ value = super
20
+ return value if value.is_a?(YiffSpace.config.user_resolvable_class) || !value.is_a?(YiffSpace.config.user_class)
21
+ return YiffSpace.config.user_resolvable_class.new(value, ip_addr) if ip_addr.present?
22
+
23
+ value
24
+ end
25
+
26
+ def user=(value)
27
+ if value.is_a?(YiffSpace.config.user_resolvable_class)
28
+ self.ip_addr = value.ip_addr
29
+ value = value.user
30
+ end
31
+ super
32
+ end
33
+
34
+ def self.scoped(user, ip_addr = YiffSpace.config.default_ip_address, &)
35
+ set(user: user, ip_addr: ip_addr, &)
36
+ end
37
+
38
+ def self.as_system(&)
39
+ scoped(YiffSpace.config.system_user_getter.call, &)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("rails")
4
+
5
+ module YiffSpace
6
+ module User
7
+ # No app/ of its own - this exists to register the UserResolvableSerializer with ActiveJob.
8
+ class Engine < ::Rails::Engine
9
+ config.root = File.expand_path("../../..", __dir__)
10
+
11
+ initializer("yiffspace.user.serializers") do
12
+ ActiveSupport.on_load(:active_job) do
13
+ ActiveJob::Serializers.add_serializers(Serializers::UserResolvableSerializer)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module User
5
+ module Like
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("active_support/core_ext/module/delegation")
4
+ require("active_support/core_ext/string/inflections")
5
+
6
+ module YiffSpace
7
+ module User
8
+ class Resolvable
9
+ attr_reader(:user, :ip_addr)
10
+
11
+ def initialize(user, ip_addr, validate: true)
12
+ if user.is_a?(YiffSpace.config.user_resolvable_class)
13
+ Utils::TraceLogger.warn(self.class.name.demodulize, "Unexpectedly received #{user.class.name} for user argument")
14
+ user = recursive_resolve(user)
15
+ end
16
+ @user = user
17
+ ip_addr = ip_addr.to_s if ip_addr.is_a?(IPAddr)
18
+ @ip_addr = ip_addr
19
+ return unless validate
20
+ raise(ArgumentError, "Expected User for user argument, got #{user.inspect} (#{user.class.name})") unless user.is_a?(YiffSpace.config.user_class)
21
+ raise(ArgumentError, "Expected String for ip_addr argument, got #{ip_addr.inspect} (#{ip_addr.class.name})") unless ip_addr.is_a?(String)
22
+ end
23
+
24
+ alias resolve user
25
+
26
+ def resolvable(ip_addr = nil)
27
+ @ip_addr = ip_addr if ip_addr.present?
28
+ self
29
+ end
30
+
31
+ delegate(:recursive_resolve, to: :class)
32
+ delegate(:to_param, :serializable_hash, :as_json, :to_json, to: :user)
33
+ delegate_missing_to(:user, allow_nil: true)
34
+
35
+ # needed to allow passing the class around when creating records, active record attempts to call several methods on the provided record's class
36
+ class << self
37
+ delegate_missing_to(:User, allow_nil: false)
38
+ end
39
+
40
+ def self.recursive_resolve(resolvable)
41
+ result = resolvable
42
+ result = result.resolve while result.is_a?(YiffSpace.config.user_resolvable_class)
43
+ result
44
+ end
45
+
46
+ def ==(other)
47
+ return other == user if other.is_a?(YiffSpace.config.user_class)
48
+
49
+ other.is_a?(YiffSpace.config.user_resolvable_class) && user == other.user
50
+ end
51
+
52
+ def ===(other)
53
+ (other == YiffSpace.config.user_like_class && user.is_a?(YiffSpace.config.user_class)) || super
54
+ end
55
+
56
+ def is_a?(other)
57
+ (other == YiffSpace.config.user_like_class && user.is_a?(YiffSpace.config.user_class)) || super
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module User
5
+ module ToId
6
+ def u2id(value, klass = YiffSpace.config.user_class)
7
+ value.is_a?(klass) || (klass == YiffSpace.config.user_class && value.is_a?(YiffSpace.config.user_resolvable_class)) ? value.id : value
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module User
5
+ VERSION = "0.0.1"
6
+ end
7
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("yiffspace/core")
4
+ require("zeitwerk")
5
+
6
+ loader = Zeitwerk::Loader.for_gem_extension(YiffSpace)
7
+ loader.ignore("#{__dir__}/user/engine.rb")
8
+ loader.setup
9
+
10
+ # Require the engine eagerly so it registers with Rails before the host app's
11
+ # active_support.initialize_per_engine_zeitwerk_loaders initializer runs.
12
+ require_relative("user/engine") if defined?(Rails)
13
+
14
+ module YiffSpace
15
+ class Configuration
16
+ # The application's User model class. Used by User::Current, Search::QueryDSL, etc.
17
+ # Falls back to ::User at call time when nil.
18
+ attr_writer(:user_class)
19
+
20
+ # The application's User model class. Used by User::Resolvable
21
+ # Falls back to ::UserLike at call time when nil.
22
+ attr_writer(:user_like_class)
23
+
24
+ # The application's UserResolvable class. Used by User::Current.
25
+ # Falls back to ::UserResolvable at call time when nil.
26
+ attr_writer(:user_resolvable_class)
27
+
28
+ # The CurrentAttributes class used to access the current user in model concerns.
29
+ # Defaults to YiffSpace::User::Current.
30
+ attr_writer(:current_class)
31
+
32
+ # The default IP address assigned to User::Current when none is present.
33
+ attr_accessor(:default_ip_address)
34
+
35
+ # The `last_ip_addr` attribute of the User model, used by the UserResolvableMethods concern
36
+ attr_accessor(:last_ip_addr_attribute)
37
+
38
+ # The anonymous user's name, can be a proc
39
+ attr_reader(:anonymous_user_name)
40
+
41
+ # Override the proc used to fetch the anonymous user. Must respond to #call.
42
+ # Default: -> { (user_class || ::User).anonymous }
43
+ attr_writer(:anonymous_user_getter)
44
+
45
+ # Override the proc used to fetch the system user. Must respond to #call.
46
+ # Default: -> { (user_class || ::User).system }
47
+ attr_writer(:system_user_getter)
48
+
49
+ def user_class
50
+ @user_class || ::User
51
+ end
52
+
53
+ def user_like_class
54
+ @user_like_class || User::Like
55
+ end
56
+
57
+ def user_resolvable_class
58
+ @user_resolvable_class || User::Resolvable
59
+ end
60
+
61
+ # Returns the configured current class, defaulting to YiffSpace::User::Current.
62
+ def current_class
63
+ @current_class || User::Current
64
+ end
65
+
66
+ # Lazily built: calls user_class (or ::User) at invocation time, not config time.
67
+ def anonymous_user_getter
68
+ @anonymous_user_getter ||= -> { user_class.anonymous }
69
+ end
70
+
71
+ # Lazily built: calls user_class (or ::User) at invocation time, not config time.
72
+ def system_user_getter
73
+ @system_user_getter ||= -> { user_class.system }
74
+ end
75
+
76
+ def anonymous_user_name=(value)
77
+ @anonymous_user_name = value.is_a?(Proc) ? value : -> { value }
78
+ end
79
+ end
80
+ end
81
+
82
+ YiffSpace.configure do |config|
83
+ config.default_ip_address = "127.0.0.1"
84
+ config.last_ip_addr_attribute = :last_ip_addr
85
+ config.anonymous_user_name = "Anonymous"
86
+ end
metadata ADDED
@@ -0,0 +1,121 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: yiffspace-user
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Donovan_DMC
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-09-04 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: request_store
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '1.7'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '1.7'
40
+ - !ruby/object:Gem::Dependency
41
+ name: yiffspace-core
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 0.2.0
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 0.2.0
54
+ - !ruby/object:Gem::Dependency
55
+ name: zeitwerk
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '2.6'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '2.6'
68
+ description: The User/Current/Resolvable concerns and utils, for https://yiff.space
69
+ and related projects
70
+ email:
71
+ - hewwo@yiff.rocks
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - CHANGELOG.md
77
+ - LICENSE
78
+ - README.md
79
+ - Rakefile
80
+ - lib/yiffspace/concerns/current_methods.rb
81
+ - lib/yiffspace/concerns/user_class_methods.rb
82
+ - lib/yiffspace/concerns/user_methods.rb
83
+ - lib/yiffspace/concerns/user_name_methods.rb
84
+ - lib/yiffspace/concerns/user_resolvable_methods.rb
85
+ - lib/yiffspace/serializers/user_resolvable_serializer.rb
86
+ - lib/yiffspace/user.rb
87
+ - lib/yiffspace/user/attribute.rb
88
+ - lib/yiffspace/user/current.rb
89
+ - lib/yiffspace/user/engine.rb
90
+ - lib/yiffspace/user/like.rb
91
+ - lib/yiffspace/user/resolvable.rb
92
+ - lib/yiffspace/user/to_id.rb
93
+ - lib/yiffspace/user/version.rb
94
+ homepage: https://yiff.space
95
+ licenses:
96
+ - MIT
97
+ metadata:
98
+ allowed_push_host: https://rubygems.org
99
+ homepage_uri: https://yiff.space
100
+ source_code_uri: https://github.com/YiffSpace/Gem/tree/master/yiffspace-user
101
+ changelog_uri: https://github.com/YiffSpace/Gem/blob/master/yiffspace-user/CHANGELOG.md
102
+ rubygems_mfa_required: 'true'
103
+ rdoc_options: []
104
+ require_paths:
105
+ - lib
106
+ required_ruby_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: 3.4.1
111
+ required_rubygems_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '0'
116
+ requirements: []
117
+ rubygems_version: 3.6.2
118
+ specification_version: 4
119
+ summary: The User/Current/Resolvable concerns and utils, for https://yiff.space and
120
+ related projects
121
+ test_files: []