yiffspace 0.0.22 → 0.0.24

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 55b429503eee52d6ce298af0e2ce3e65327f630c6f7fc0114e58d49a324c2fd3
4
- data.tar.gz: 017a1eaaf6a5ee571fa8e29cc3f7a2da617d1b6de78f9d5d834bb248a9212326
3
+ metadata.gz: 483249113d393f9851f4641c08e917c087a5c1db46cbbd44c8097c0760b883c5
4
+ data.tar.gz: 5e2daacda68d812268a384cc00ccc6df4cb05e267725feee663af3b1a5976e86
5
5
  SHA512:
6
- metadata.gz: 62391e56655903180b26837749b124f5c4a7f6ac4180815be23bcf83295f221af0f6433d824664caf8f1c10f4d81f0074630e66b3bf956c82472eecf9e4a2ea8
7
- data.tar.gz: 86671d5ef0e7c213ae10721e9024f0dafc590f4b464b2311c549e666f87d2368838efa56e54ead37d16e17d1918ad465b611b3df25f3130c9f2675ed4d49dda5
6
+ metadata.gz: d7df4e7e6aaabe1460202b67c70cde28dc4f6a195afa09050302b4858f532576f380b3f73ed93baea5d15530a429779269583adb3ff32f4420a12abe6962b5ca
7
+ data.tar.gz: 9e811ec4b770efe3df29d2664fe0b9adfde3841d501ba1614458dcdf7e3839b5e4a15d14ca035d64cb5ce3cdb17847d07b15d8dc8c98c2a6698d6d6a09863556
@@ -0,0 +1,41 @@
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
@@ -4,4 +4,5 @@ require_relative("active_record/all")
4
4
  require_relative("arel/all")
5
5
  require_relative("enumerable/all")
6
6
  require_relative("hash/all")
7
+ require_relative("object/all")
7
8
  require_relative("string/all")
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative("to_b")
4
+ require_relative("truthy_falsy")
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative("../../extensions/object/to_b")
4
+
5
+ class Object
6
+ include(YiffSpace::Extensions::Object::ToB)
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative("../../extensions/object/truthy_falsy")
4
+
5
+ class Object
6
+ include(YiffSpace::Extensions::Object::TruthyFalsy)
7
+ end
@@ -1,5 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative("sql")
4
- require_relative("to_b")
5
- require_relative("truthy_falsy")
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module Extensions
5
+ module Object
6
+ module ToB
7
+ def to_b
8
+ case self
9
+ when ::String
10
+ !match?(/\A(false|f|no|n|off|0)\z/i)
11
+ when ::TrueClass
12
+ true
13
+ when ::FalseClass
14
+ false
15
+ else
16
+ to_s.to_b
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module Extensions
5
+ module Object
6
+ module TruthyFalsy
7
+ def truthy?
8
+ case self
9
+ when ::String
10
+ match?(/\A(true|t|yes|y|on|1)\z/i)
11
+ when ::TrueClass
12
+ true
13
+ when ::FalseClass
14
+ false
15
+ else
16
+ to_s.truthy?
17
+ end
18
+ end
19
+
20
+ def falsy?
21
+ case self
22
+ when ::String
23
+ match?(/\A(false|f|no|n|off|0)\z/i)
24
+ when ::TrueClass
25
+ false
26
+ when ::FalseClass
27
+ true
28
+ else
29
+ to_s.falsy?
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -75,6 +75,35 @@ module YiffSpace
75
75
  get_user(id) || create_user(id)
76
76
  end
77
77
 
78
+ # Pages through every user in the tenant. Used by maintenance tasks (e.g. the
79
+ # dedupe_users rake task) - there's no discordId to filter by when scanning the
80
+ # whole user base for duplicates/orphans.
81
+ def list_users(page_size: 100)
82
+ users = []
83
+ page = 1
84
+ loop do
85
+ response = HTTParty.get("#{auth.server_url}/api/users", {
86
+ headers: { "Authorization" => "Bearer #{get_token}" },
87
+ query: { page: page, page_size: page_size },
88
+ })
89
+ raise("failed to list users: #{response.code} #{response.message}\n#{response.parsed_response.inspect}") if response.code != 200 || !response.parsed_response.is_a?(Array)
90
+
91
+ batch = response.parsed_response
92
+ users.concat(batch.map { |u| Auth::ApiUser.new(u) })
93
+ break if batch.length < page_size
94
+
95
+ page += 1
96
+ end
97
+ users
98
+ end
99
+
100
+ def delete_user(logto_id)
101
+ response = HTTParty.delete("#{auth.server_url}/api/users/#{logto_id}", { headers: { "Authorization" => "Bearer #{get_token}" } })
102
+ return true if [204, 404].include?(response.code)
103
+
104
+ raise("failed to delete user #{logto_id}: #{response.code} #{response.message}\n#{response.parsed_response.inspect}")
105
+ end
106
+
78
107
  def get_user_by_id(logto_id)
79
108
  response = HTTParty.get("#{auth.server_url}/api/users/#{logto_id}", { headers: { "Authorization" => "Bearer #{get_token}" } })
80
109
  return nil if response.code == 404
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module Utils
5
+ # get_or_create_user is check-then-act (query Logto, then create) with no locking
6
+ # of its own - two concurrent calls for a discord id that doesn't exist yet will
7
+ # both create a user, and the loser's discord-identity-attach step fails once the
8
+ # winner's identity claims it first. That leaves an identity-less "orphan" user
9
+ # behind in Logto. This scans the whole user base to find those orphans (and any
10
+ # other duplicate/ambiguous state) so they can be cleaned up.
11
+ module UserDeduper
12
+ # create_user sets a new user's avatar to the discord CDN URL for the discord id
13
+ # it was created for, before the identity-attach step runs - so even an orphan
14
+ # that never got its identity attached still reveals which discord id it was
15
+ # meant for.
16
+ AVATAR_ID_PATTERN = %r{cdn\.discordapp\.com/avatars/(\d+)/}
17
+
18
+ module_function
19
+
20
+ def discord_id_for(user)
21
+ user.data.identities&.discord&.userId || avatar_discord_id_for(user)
22
+ end
23
+
24
+ def avatar_discord_id_for(user)
25
+ match = user.data.avatar.to_s.match(AVATAR_ID_PATTERN)
26
+ match && match[1]
27
+ end
28
+
29
+ def linked?(user)
30
+ user.data.identities&.discord&.userId.present?
31
+ end
32
+
33
+ # Returns a hash with:
34
+ # - :conflicts - discord id => users, for ids where more than one user has the
35
+ # identity actually attached. Ambiguous; always needs manual review, never
36
+ # auto-deleted.
37
+ # - :deletable_orphans - [orphan, keeper] pairs, where orphan has no identity
38
+ # attached but its avatar points at a discord id claimed by exactly one other
39
+ # (linked) user. This is the confirmed race-loser case - safe to delete.
40
+ # - :unresolved_orphans - users with no identity attached and no corresponding
41
+ # linked user (still mid-race, or the winner can't be identified). Reported
42
+ # only, never auto-deleted.
43
+ def scan(management)
44
+ users = management.list_users
45
+ linked, unlinked = users.partition { |u| linked?(u) }
46
+
47
+ by_discord_id = linked.group_by { |u| u.data.identities.discord.userId }
48
+ conflicts = by_discord_id.select { |_id, group| group.length > 1 }
49
+
50
+ deletable_orphans = []
51
+ unresolved_orphans = []
52
+ unlinked.each do |user|
53
+ discord_id = avatar_discord_id_for(user)
54
+ keeper = discord_id && by_discord_id[discord_id]
55
+ if keeper&.length == 1
56
+ deletable_orphans << [user, keeper.first]
57
+ else
58
+ unresolved_orphans << user
59
+ end
60
+ end
61
+
62
+ { conflicts: conflicts, deletable_orphans: deletable_orphans, unresolved_orphans: unresolved_orphans }
63
+ end
64
+ end
65
+ end
66
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module YiffSpace
4
- VERSION = "0.0.22"
4
+ VERSION = "0.0.24"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yiffspace
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.22
4
+ version: 0.0.24
5
5
  platform: ruby
6
6
  authors:
7
7
  - Donovan_DMC
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-07-03 00:00:00.000000000 Z
10
+ date: 2026-07-11 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: abbrev
@@ -117,7 +117,7 @@ files:
117
117
  - engines/auth/app/controllers/yiff_space/auth/webhook_controller.rb
118
118
  - engines/auth/app/views/yiff_space/auth/root/permissions.html.erb
119
119
  - engines/auth/config/routes.rb
120
- - lib/tasks/yiff_space_tasks.rake
120
+ - lib/tasks/yiffspace_tasks.rake
121
121
  - lib/yiffspace.rb
122
122
  - lib/yiffspace/auth.rb
123
123
  - lib/yiffspace/auth/api_user.rb
@@ -159,10 +159,11 @@ files:
159
159
  - lib/yiffspace/core_ext/hash/all.rb
160
160
  - lib/yiffspace/core_ext/hash/to_open_hash.rb
161
161
  - lib/yiffspace/core_ext/logto/named_session_storage.rb
162
+ - lib/yiffspace/core_ext/object/all.rb
163
+ - lib/yiffspace/core_ext/object/to_b.rb
164
+ - lib/yiffspace/core_ext/object/truthy_falsy.rb
162
165
  - lib/yiffspace/core_ext/string/all.rb
163
166
  - lib/yiffspace/core_ext/string/sql.rb
164
- - lib/yiffspace/core_ext/string/to_b.rb
165
- - lib/yiffspace/core_ext/string/truthy_falsy.rb
166
167
  - lib/yiffspace/extensions/action_dispatch/set_auth_client.rb
167
168
  - lib/yiffspace/extensions/action_dispatch/set_auth_client/scoped.rb
168
169
  - lib/yiffspace/extensions/active_record/where_chain.rb
@@ -175,9 +176,9 @@ files:
175
176
  - lib/yiffspace/extensions/enumerable/parallel.rb
176
177
  - lib/yiffspace/extensions/hash/to_open_hash.rb
177
178
  - lib/yiffspace/extensions/logto/named_session_storage.rb
179
+ - lib/yiffspace/extensions/object/to_b.rb
180
+ - lib/yiffspace/extensions/object/truthy_falsy.rb
178
181
  - lib/yiffspace/extensions/string/sql.rb
179
- - lib/yiffspace/extensions/string/to_b.rb
180
- - lib/yiffspace/extensions/string/truthy_falsy.rb
181
182
  - lib/yiffspace/images.rb
182
183
  - lib/yiffspace/images/avatar.rb
183
184
  - lib/yiffspace/images/avatar/base.rb
@@ -231,6 +232,7 @@ files:
231
232
  - lib/yiffspace/utils/table_builder.rb
232
233
  - lib/yiffspace/utils/trace_logger.rb
233
234
  - lib/yiffspace/utils/user_attribute.rb
235
+ - lib/yiffspace/utils/user_deduper.rb
234
236
  - lib/yiffspace/utils/user_like.rb
235
237
  - lib/yiffspace/utils/user_resolvable.rb
236
238
  - lib/yiffspace/utils/user_to_id.rb
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # desc "Explaining what the task does"
4
- # task :yiffspace do
5
- # # Task goes here
6
- # end
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative("../../extensions/string/to_b")
4
-
5
- class String
6
- include(YiffSpace::Extensions::String::ToB)
7
- end
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative("../../extensions/string/truthy_falsy")
4
-
5
- class String
6
- include(YiffSpace::Extensions::String::TruthyFalsy)
7
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module YiffSpace
4
- module Extensions
5
- module String
6
- module ToB
7
- def to_b # rubocop:disable Naming/PredicateMethod
8
- !match?(/\A(false|f|no|n|off|0)\z/i)
9
- end
10
- end
11
- end
12
- end
13
- end
@@ -1,17 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module YiffSpace
4
- module Extensions
5
- module String
6
- module TruthyFalsy
7
- def truthy?
8
- match?(/\A(true|t|yes|y|on|1)\z/i)
9
- end
10
-
11
- def falsy?
12
- match?(/\A(false|f|no|n|off|0)\z/i)
13
- end
14
- end
15
- end
16
- end
17
- end