Compare commits

...

212 Commits

Author SHA1 Message Date
e8c56bb3bc Merge pull request 'Updated links for Xivapi sources' (#54) from xivapi-link-changes into master
Reviewed-on: #54
2026-01-05 12:03:09 +00:00
8e0dcc6792 Updated links for Xivapi sources 2026-01-05 12:01:53 +00:00
ac4174f6e0 Fix moderator status on broadcast (#51)
Co-authored-by: cake <admin@cakeandbanana.nl>
Reviewed-on: #51
Co-authored-by: cake <cake@noreply.git.lightless-sync.org>
Co-committed-by: cake <cake@noreply.git.lightless-sync.org>
2026-01-04 05:14:37 +00:00
9fffaf7df2 fix-prune (#50)
Co-authored-by: Tsubasahane <wozaiha@gmail.com>
Co-authored-by: cake <admin@cakeandbanana.nl>
Reviewed-on: #50
Co-authored-by: cake <cake@noreply.git.lightless-sync.org>
Co-committed-by: cake <cake@noreply.git.lightless-sync.org>
2026-01-04 05:14:22 +00:00
ca0c548373 AddBan changes for ban admin panel rework (#52)
Co-authored-by: cake <admin@cakeandbanana.nl>
Reviewed-on: #52
Co-authored-by: cake <cake@noreply.git.lightless-sync.org>
Co-committed-by: cake <cake@noreply.git.lightless-sync.org>
2026-01-04 05:12:00 +00:00
1716750347 Location Sharing (#49)
Authored-by: Tsubasahane <wozaiha@gmail.com>
Reviewed-on: #49
Co-authored-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org>
Co-committed-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org>
2025-12-31 17:32:00 +00:00
d39a922482 Merge pull request 'Trying a different method of setting access time.' (#48) from cache-test-fix into master
Reviewed-on: #48
2025-12-23 17:14:08 +00:00
6216664e18 Merge branch 'master' into cache-test-fix 2025-12-23 17:12:45 +00:00
54992234d5 Merge pull request 'disable-chat-groups' (#45) from disable-chat-groups into master
Reviewed-on: #45
2025-12-23 16:55:33 +00:00
90e81fb955 Merge branch 'master' into disable-chat-groups 2025-12-23 16:55:21 +00:00
defnotken
4012b33f98 Trying a different method of setting access time. 2025-12-23 10:36:38 -06:00
33fb6ba2eb Merge pull request 'Done same for user' (#47) from fix-profile-hide into master
Reviewed-on: #47
2025-12-22 20:31:36 +00:00
cake
d1526fa49e Done same for user 2025-12-22 21:20:32 +01:00
5b5f8edc3b Merge pull request 'Removed code that would prevent changes on disabled profile.' (#46) from fix-profile-hide into master
Reviewed-on: #46
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-12-22 14:53:52 +00:00
cake
8515364ffd Removed code that would prevent changes on disabled profile. 2025-12-22 15:46:52 +01:00
defnotken
4ec6169ebd fix context 2025-12-21 21:02:59 -06:00
defnotken
ec26abaeb5 Revert "Merge pull request 'revert ce9b94a534c1239b4b89c4ccf05354c477da49d9' (#44) from defnotken-patch-1 into master"
This reverts commit 76bdbe28da, reversing
changes made to ce9b94a534.
2025-12-21 21:00:53 -06:00
3350f2ebf6 Merge branch 'master' into disable-chat-groups 2025-12-22 02:57:55 +00:00
defnotken
670e0e01f1 Merge branch 'disable-chat-groups' of https://git.lightless-sync.org/Lightless-Sync/LightlessServer into disable-chat-groups 2025-12-21 20:48:22 -06:00
defnotken
12d51dc689 Generate migration 2025-12-21 20:48:07 -06:00
defnotken
860bc955a4 Generate migration 2025-12-21 20:43:24 -06:00
76bdbe28da Merge pull request 'revert ce9b94a534c1239b4b89c4ccf05354c477da49d9' (#44) from defnotken-patch-1 into master
Reviewed-on: #44
2025-12-22 02:27:14 +00:00
f4753505aa revert ce9b94a534
revert Merge pull request 'add capability to disable chat feature for groups/syncshells' (#43) from disable-chat-groups into master

Reviewed-on: #43
2025-12-22 02:25:40 +00:00
ce9b94a534 Merge pull request 'add capability to disable chat feature for groups/syncshells' (#43) from disable-chat-groups into master
Reviewed-on: #43
2025-12-22 02:18:00 +00:00
Abelfreyja
e58210e770 add capability to disable chat feature for groups/syncshells 2025-12-22 10:24:34 +09:00
6d49389bd4 Merge pull request 'bump-api' (#42) from bump-api into master
Reviewed-on: #42
2025-12-19 15:38:53 +00:00
defnotken
9a964b4933 Merge branch 'bump-api' of https://git.lightless-sync.org/Lightless-Sync/LightlessServer into bump-api 2025-12-19 09:37:49 -06:00
defnotken
2ca0fe0697 bumping tha api 2025-12-19 09:37:15 -06:00
10bcf5dd1f Merge pull request 'migration-fix' (#41) from migration-fix into master
Reviewed-on: #41
2025-12-19 12:04:12 +00:00
defnotken
dda22594f1 db migration add for chat changes 2025-12-19 05:52:52 -06:00
defnotken
802077371b fixed db migration for chat 2025-12-19 05:52:00 -06:00
6321f385da Discord-bot + server updates (#39)
Co-authored-by: defnotken <itsdefnotken@gmail.com>
Reviewed-on: #39
Co-authored-by: defnotken <defnotken@noreply.git.lightless-sync.org>
Co-committed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-12-19 10:38:51 +00:00
cake
7cd463d4b7 bump api server 2025-12-19 07:12:09 +01:00
dda45e8e73 Merge pull request 'Chat adjustment for release' (#38) from chat-adjustments into master
Reviewed-on: #38
2025-12-18 21:19:55 +00:00
cake
7f01cc3661 bump api 2025-12-18 22:19:19 +01:00
azyges
67fe2a1f0f just chat improvements :sludge: 2025-12-17 03:46:23 +09:00
azyges
1734f0e32f Merge branch 'master' of https://git.lightless-sync.org/Lightless-Sync/LightlessServer into chat-adjustments 2025-12-17 00:14:40 +09:00
2fb41d0b9f Merge pull request 'Reduced content length of message, commented out the pending report.' (#37) from chat-changes into master
Reviewed-on: #37
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-12-16 12:00:08 +00:00
cake
6294b1d496 Reduced report chat messages to 1 min of polling 2025-12-16 07:11:35 +01:00
cake
143037165a Reduced amount of chats, commented out the pending report. 2025-12-16 07:06:17 +01:00
azyges
989d079601 chat zone config, add more default zones 2025-12-14 15:55:32 +09:00
azyges
03ba9493fc prevent report dodging by leaving channel, allow reports to work after leaving the channel too 2025-12-14 15:13:22 +09:00
c731b265ff Merge pull request 'Auto pruning of Syncshell, Added group count in DTO of group. Changed database to add prune days. Metrics added for auto prune enabled syncshells.' (#36) from auto-prune-syncshell into master
Reviewed-on: #36
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-12-09 05:37:42 +01:00
cake
5feb91ed42 updated api 2025-12-09 05:32:32 +01:00
cake
918c5e7d5d Added disabled in the group profile dto. 2025-12-07 21:53:01 +01:00
cake
7748fa6eac Back to an hourly action 2025-12-07 02:21:48 +01:00
cake
16c55d634d Set minutes to 1 for testing. 2025-12-07 02:18:34 +01:00
cake
7c66d4c9cf Update lightless api 2025-12-06 23:39:31 +01:00
cake
e21f9c36a4 Revert DTO, Changed messagepackprotocol again. 2025-12-06 23:39:24 +01:00
cake
c9d3cb0d50 Updated submodule 2025-12-06 22:58:36 +01:00
cake
2dc7681904 Changed resolver a bit for messagepack 2025-12-06 21:47:57 +01:00
cake
fb6ff4fb0e Update 2025-12-06 20:16:09 +01:00
cake
903429e148 Merge branch 'auto-prune-syncshell' of https://git.lightless-sync.org/Lightless-Sync/LightlessServer into auto-prune-syncshell 2025-12-06 18:29:59 +01:00
cake
d7e5117e6b Update 2025-12-06 18:02:45 +01:00
cake
727f27c2d1 Fixed some problems. 2025-12-06 18:02:39 +01:00
3218e800d6 Merge branch 'master' into auto-prune-syncshell 2025-12-06 03:16:40 +01:00
cake
7e7a5808f4 Update submodule. 2025-12-05 22:01:58 +01:00
cake
091bfbbc29 Auto-pruning of syncshell, added metrics for pruning, return of count of users in fullgroupdto. 2025-12-05 22:01:48 +01:00
00bcbbf8f4 Western Canadians will now filter to NA-East where servers are located in Canada.
Co-authored-by: defnotken <itsdefnotken@gmail.com>
Reviewed-on: #35
2025-11-20 19:33:53 +01:00
63211b2e8b Merge pull request 'Fix Fk' (#34) from fkfix into master
Reviewed-on: #34
2025-11-17 18:34:18 +01:00
defnotken
a1280d58bf Fix Fk 2025-11-17 09:34:33 -06:00
34f0223a85 revert revert regex 2025-11-13 15:50:19 +01:00
69f06f5868 Merge pull request 'revert regex' (#33) from revert-regex into master
Reviewed-on: #33
2025-11-13 15:22:20 +01:00
066f56e5a2 Merge branch 'master' into revert-regex 2025-11-13 15:22:05 +01:00
defnotken
287f72b6ad revert regex 2025-11-13 08:21:37 -06:00
ef13566b7a Merge pull request 'Fix chat stuff' (#32) from chat into master
Reviewed-on: #32
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-11-12 01:53:11 +01:00
084b4711b0 Merge branch 'master' into chat 2025-11-12 00:36:29 +01:00
azyges
9d496ee8e9 fix server message oops 2025-11-12 07:22:16 +09:00
azyges
0632c24a08 Merge branch 'chat' of https://git.lightless-sync.org/Lightless-Sync/LightlessServer into chat 2025-11-12 04:40:03 +09:00
azyges
8821f1d450 adjustments and add rate limit 2025-11-12 04:39:32 +09:00
3b0e80f92b Merge pull request 'Updated Lodestone URL regex' (#30) from lodestone-auth-regex-adjust into master
Reviewed-on: #30
2025-11-11 19:11:43 +01:00
586b5d0dd5 Chat Support for Server.
Reviewed-on: #31
2025-11-11 19:09:28 +01:00
defnotken
6858431c2d Merge branch 'master' into chat 2025-11-11 12:05:59 -06:00
cake
50c9268e76 updated submodule 2025-11-11 18:59:50 +01:00
625caa1e6a Refactor Discord Bot to make sense..
Reviewed-on: #29
2025-11-11 18:48:58 +01:00
defnotken
10cb6e9c2c update reference 2025-11-11 11:47:44 -06:00
defnotken
72b5b21624 Working User Info Changes. 2025-11-11 11:29:09 -06:00
defnotken
ac37020429 Lets try banners 2025-11-11 11:16:12 -06:00
defnotken
3b93ebb9e5 more logging 2025-11-11 11:04:39 -06:00
defnotken
44dc35ff99 logging image 2025-11-11 10:59:02 -06:00
defnotken
fac5b3caef Log warning 2025-11-11 10:54:24 -06:00
defnotken
f4ac99ba05 reeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee 2025-11-11 10:50:54 -06:00
defnotken
a63742df83 Lets try this again 2025-11-11 10:44:57 -06:00
defnotken
47fec97e81 Testing user info part 2 2025-11-11 10:29:54 -06:00
defnotken
32a9e93217 Testing profile in userinfo 2025-11-11 09:53:59 -06:00
azyges
cf5135f598 add generated world, territory registries and serverside verification for only legit territories and worlds defined by server 2025-11-08 07:38:35 +09:00
azyges
7cfe29e511 clean up structs and seperate zone definitions 2025-11-05 01:40:48 +09:00
0f95f26c1c Implemented match group instead of tinkering with the URL string
We're using regex already anyways, so might as well take advantage of matching groups. Group 1 will always be the country code and group 2 always the ID
2025-11-01 22:47:05 +01:00
8e36b062fd Updated Lodestone URL regex
Made it match the lodestone URL scheme exactly, with optional trailing "/" and nothing before or after the URL
2025-11-01 22:29:09 +01:00
defnotken
47b2a7a9b9 boolz 2025-10-29 18:26:41 -05:00
defnotken
7520bc1fa0 nullable 2025-10-29 18:09:06 -05:00
defnotken
700428eccb I'm blind 2025-10-29 17:56:09 -05:00
defnotken
9f97ab1eb4 Fixing some group controller stuff 2025-10-29 17:26:43 -05:00
defnotken
de0a37985a Update API 2025-10-29 17:23:32 -05:00
defnotken
1db5b7056d Initial Bot Cleanup + Profile Toggling 2025-10-29 17:23:07 -05:00
azyges
96627e3b85 bump submodule 2025-10-29 07:55:39 +09:00
azyges
dceaceb941 chat 2025-10-29 07:50:41 +09:00
ee69df8081 Merge pull request 'Fixed some issues on group/user profiles' (#25) from syncshells-images-combined into master
Reviewed-on: #25
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-27 20:19:27 +01:00
f29c874515 Merge branch 'master' into syncshells-images-combined 2025-10-27 20:19:03 +01:00
cake
b84d6c35d6 Moved notification on groups on new ones, Fixed new creation of profiles. 2025-10-27 19:08:25 +01:00
bd03fa6762 Syncshells Fix -
Reviewed-on: #24
written by: Abel / Cake
2025-10-26 19:48:18 +01:00
cake
8cde3b4933 Fixed image update from dto 2025-10-26 18:59:20 +01:00
defnotken
0c357aaf7c Merge remote-tracking branch 'origin/fix-images' into syncshells-images-combined 2025-10-26 12:31:34 -05:00
cake
ae09d79577 fix image results 2025-10-26 17:43:49 +01:00
azyges
cc24dc067e fix moderators + profiles 2025-10-27 00:59:56 +09:00
6ac56d38c0 Merge pull request 'lets try this' (#23) from sql-thing into master
Reviewed-on: #23
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-21 23:26:14 +02:00
defnotken
b7f7381dec lets try this 2025-10-21 16:16:45 -05:00
1ce7a718bb Merge pull request 'Banner Support for profiles, Some cleanup/refactoring. Country for metrics.' (#22) from server-changes into master
Reviewed-on: #22
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-21 22:50:54 +02:00
CakeAndBanana
46bb7a4a98 submodule update 2025-10-21 22:48:57 +02:00
CakeAndBanana
8752ce0e62 removal concept 2025-10-21 22:39:05 +02:00
CakeAndBanana
db0115316d Made new imageloadresult instead of null 2025-10-20 20:51:55 +02:00
CakeAndBanana
00d4632510 Reworked image handling, added banner for profiles. Made functions to start on xivauth. Refactored some code. 2025-10-20 20:46:20 +02:00
CakeAndBanana
e61e0db36b Removed logging 2025-10-20 15:52:47 +02:00
CakeAndBanana
8d82365d0e Made logging from information to warning 2025-10-20 15:43:29 +02:00
CakeAndBanana
b142329d09 Added some logging for country 2025-10-20 04:05:27 +02:00
CakeAndBanana
8a329ccbaa Added IP check on loopback 2025-10-20 03:27:47 +02:00
CakeAndBanana
23ee3f98b0 Removed some random characters 2025-10-20 03:15:37 +02:00
CakeAndBanana
f8e711f3c0 Redone array of labels for geoip 2025-10-20 03:12:27 +02:00
CakeAndBanana
73e7bb67bb Fixed metric for country 2025-10-20 03:07:07 +02:00
CakeAndBanana
70500b21e6 Add another label on guage metric 2025-10-20 03:03:59 +02:00
CakeAndBanana
698a9eddf7 Added new jwt claim for country, Moved models to correct folder instead of inside Lightlesshub.Groups 2025-10-20 02:30:40 +02:00
9cab73e8c8 Merge pull request 'Fallback Policy change' (#20) from authorization-shard into master
Reviewed-on: #20
2025-10-19 23:11:31 +02:00
5240beddf4 Merge branch 'master' into authorization-shard 2025-10-19 23:01:59 +02:00
cb4998e960 Merge pull request 'Reworked syncshell profile and user profile calls.' (#21) from syncshell-profiles-attempt-two into master
Reviewed-on: #21
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-19 21:41:38 +02:00
CakeAndBanana
884ad25c33 update submodule 2025-10-19 21:21:19 +02:00
CakeAndBanana
3926f3be89 remove blankspace 2025-10-19 21:04:22 +02:00
CakeAndBanana
d28198a9c8 fix migrations 2025-10-19 21:04:12 +02:00
CakeAndBanana
7cc6918b12 updated submodule 2025-10-19 20:58:40 +02:00
CakeAndBanana
dba7536a7f Added tags on call for user profile calls, added disabled on syncshell profiles. reworked the calls 2025-10-19 20:58:07 +02:00
CakeAndBanana
f35c0c4c2a updated submodule 2025-10-19 18:40:49 +02:00
CakeAndBanana
ad00f7b078 Changes in database for tags to be array integers instead of strings 2025-10-19 18:36:08 +02:00
CakeAndBanana
c30190704f Changed get/set profile with more safe handling 2025-10-19 17:53:20 +02:00
CakeAndBanana
bab81aaf51 Added null checks 2025-10-19 17:39:36 +02:00
CakeAndBanana
4fdc2a5c29 FIx to attempt to get group 2025-10-19 17:30:16 +02:00
CakeAndBanana
bbcf98576e Fixed so it can search on alias better 2025-10-19 17:22:25 +02:00
defnotken
1ac92f6da2 move shard controller. 2025-10-18 19:44:02 -05:00
defnotken
e7e4a4527a Testing something 2025-10-18 18:51:58 -05:00
583f1a8957 Merge pull request 'Fixed some issues with profiles on groups' (#15) from fix-profiles-syncshell into master
Reviewed-on: #15
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-17 00:09:46 +02:00
defnotken
2ebdd6e0c7 Merge branch 'master' into fix-profiles-syncshell 2025-10-16 16:32:31 -05:00
CakeAndBanana
2407259769 update submodule 2025-10-16 23:15:51 +02:00
03af0b853c Lightfinder-profiles
Reviewed-on: #18
2025-10-16 22:25:51 +02:00
azyges
53f663fcbf zzz 2025-10-17 00:32:43 +09:00
azyges
47a94cb79f shivering my timbers 2025-10-17 00:20:26 +09:00
f933b40368 LightfinderMetrics
Reviewed-on: #14
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-16 00:12:56 +02:00
CakeAndBanana
b670cb69dd Updated submodule for dto 2025-10-15 23:25:54 +02:00
CakeAndBanana
50f3b0d644 Added default values for nsfw and disabled on set profile for groups 2025-10-15 23:24:43 +02:00
CakeAndBanana
3a6203844e Added support for nsfw and disabled for groups 2025-10-15 23:22:07 +02:00
CakeAndBanana
80086f6817 Merge branch 'fix-profiles-syncshell' of https://git.lightless-sync.org/Lightless-Sync/LightlessServer into fix-profiles-syncshell 2025-10-15 23:09:12 +02:00
7e565ff85e CDN on shards
Reviewed-on: #17
2025-10-15 21:32:58 +02:00
defnotken
49177e639e conflicts 2025-10-15 14:15:30 -05:00
defnotken
b36b1fb8f9 Add nsfw and disabled in db 2025-10-15 12:39:46 -05:00
azyges
79483205f1 cdn download support on shards + clean up 2025-10-16 01:15:31 +09:00
CakeAndBanana
707c565ea9 Inverted boolean for syncshells 2025-10-15 04:02:27 +02:00
CakeAndBanana
6beda853f7 Added metric of broadcasted syncshells 2025-10-15 03:58:00 +02:00
CakeAndBanana
23dc6d7ef4 Merge branch 'metrics-lightfinder' of https://git.lightless-sync.org/Lightless-Sync/LightlessServer into metrics-lightfinder 2025-10-15 02:47:27 +02:00
CakeAndBanana
f686f7a6da Added lightfinder metric on startup 2025-10-15 02:47:18 +02:00
CakeAndBanana
280cc2ebbb Fix submodule 2025-10-14 20:21:25 +02:00
CakeAndBanana
7909850ad5 Changed cancellation token. 2025-10-14 19:01:58 +02:00
CakeAndBanana
f60994fa58 update 2025-10-14 19:00:56 +02:00
CakeAndBanana
96f230cd21 merge 2025-10-14 19:00:45 +02:00
0fe1a43fb2 Merge branch 'master' into metrics-lightfinder 2025-10-14 18:56:37 +02:00
CakeAndBanana
43b9c6f90e Added lightfinder users in metrics 2025-10-14 18:45:57 +02:00
CakeAndBanana
59f3739b9c Fixed some issues with profiles on groups 2025-10-14 18:19:46 +02:00
aadfaca629 .gitmodules updated
Specified branch to omit errors on pulling submodules
2025-10-13 00:07:54 +02:00
729d781fa3 Merge pull request 'Server - LightFinder Rework' (#13) from server-lightfinder-rewrite into master
Reviewed-on: #13
2025-10-12 15:14:21 +02:00
defnotken
be95f24dcd conflictrs 2025-10-10 15:06:53 -05:00
defnotken
a1f9526c23 quick quick quick cleanup 2025-10-09 18:03:30 -05:00
defnotken
0450255d6d Update Submodule Endpoint 2025-10-09 18:00:06 -05:00
azyges
b6907a2704 cdn downloads support 2025-10-10 07:37:33 +09:00
azyges
479b80a5a0 lightfinder changes:
- removed all ability to provide your cid to the server through params, cid is gained from JWT claims
- improved verification of who owns a cid, which includes locking a cid to a uid
- locks and persisting entries of broadcasting are cleaned up on disconnection
- method identification logic was rewritten to fit these changes
2025-10-08 08:40:56 +09:00
d4d6e21381 Merge pull request 'Removal of cancellation tokens' (#11) from removal-cancellation-tokens into master
Reviewed-on: #11
2025-10-07 06:16:59 +02:00
CakeAndBanana
3d9fc4fba0 Removal of cancellation tokens 2025-10-05 19:05:32 +02:00
58f5f3ad85 Merge pull request '1.12.0-server' (#10) from 1.12.0-server into master
Reviewed-on: #10
2025-10-04 21:23:28 +02:00
defnotken
43219dd1e9 Allow kdb 2025-10-04 14:09:32 -05:00
defnotken
1655f99021 Update Submodule 2025-10-04 13:51:25 -05:00
azyges
610461fa99 adjusting notifications 2025-10-02 09:21:21 +09:00
azyges
d2dabddeb7 validate incoming cid's 2025-10-01 08:41:56 +09:00
azyges
ed13ee8921 lightfinder config, securing methods with stricter checking and added pair request notifications 2025-09-29 05:31:58 +09:00
azyges
6bc9da1519 adjust connection method 2025-09-26 23:41:17 +09:00
azyges
b9abdcfff7 update migrations 2025-09-26 22:36:51 +09:00
azyges
48cf492fa1 remove nullability 2025-09-26 20:25:08 +09:00
azyges
2b05223a4b added methods to update vanity colors and submodule bump 2025-09-26 18:53:47 +09:00
azyges
f5d621e354 expose vanity and colors and update from bot 2025-09-26 18:00:46 +09:00
CakeAndBanana
7271e007cd Renamed hub 2025-09-26 03:48:24 +02:00
azyges
323d3f39e2 uid colors 2025-09-26 08:26:24 +09:00
azyges
c4b6e85f60 bump submodule 2025-09-26 04:37:41 +09:00
azyges
4004cf289e clear lightfinder joiners 2025-09-26 04:31:42 +09:00
CakeAndBanana
e470e5346a Typo 2025-09-25 18:14:49 +02:00
CakeAndBanana
f084837e01 Updated submodule 2025-09-25 18:04:50 +02:00
b0e10d220c Merge pull request 'Endpoints changed and added for Groups' (#7) from endpoints_groups into 1.12.0-server
Reviewed-on: #7
2025-09-25 18:03:17 +02:00
CakeAndBanana
39aded4fb7 Changed prefix of syncshells from MSS to LSS 2025-09-25 17:42:09 +02:00
defnotken
f9f25829a0 Add logging to test 2025-09-25 10:00:16 -05:00
CakeAndBanana
7fecea2c6f Updated Submodule to groups 2025-09-25 05:24:54 +02:00
CakeAndBanana
81e773e0c4 Updated submodule 2025-09-25 05:24:07 +02:00
CakeAndBanana
825bb3b7d6 Cleaned up a bit. 2025-09-25 04:34:25 +02:00
CakeAndBanana
bf380688c8 Redoing of groupsgetall 2025-09-25 04:28:52 +02:00
CakeAndBanana
3a4a934d09 Revert of GroupFullInfoDto 2025-09-25 03:37:13 +02:00
CakeAndBanana
1a97dded9c Removal of an accidental change I did. 2025-09-25 03:14:09 +02:00
CakeAndBanana
03f633a273 Added lightless finder in dto of joining 2025-09-25 02:03:15 +02:00
CakeAndBanana
f1cbf32123 Merged lightfinder changes 2025-09-24 15:06:04 +02:00
CakeAndBanana
71c01461ae Lightfinder merge into group changes 2025-09-24 15:04:02 +02:00
azyges
5b3fe6e240 lightfinder! 2025-09-24 06:33:56 +09:00
CakeAndBanana
6fb5f6e9a7 Added client sendback of profileDTO 2025-09-17 05:37:04 +02:00
CakeAndBanana
931ca0d622 Added parameters 2025-09-17 05:19:37 +02:00
CakeAndBanana
f0e7280d7d Rebase 2025-09-17 05:14:26 +02:00
defnotken
b669e2cb24 Adding group profile to the group model 2025-09-16 19:54:31 -05:00
CakeAndBanana
deea39d621 Added get of group profile, removed group from model. redone group data. 2025-09-17 02:38:20 +02:00
CakeAndBanana
f5b03846fe Added changes for the profiles to be returned and be able to be changed. 2025-09-17 02:07:46 +02:00
defnotken
0df7ee424d db changes 2025-09-16 15:03:15 -05:00
defnotken
81261fae49 Database changes for syncshell changes 2025-09-16 09:52:15 -05:00
d7e8be97ff make lower limit 3 characters. add forbidden words. (#4)
Co-authored-by: defnotken <itsdefnotken@gmail.com>
Reviewed-on: #4
2025-09-09 19:01:41 +02:00
8217d99478 Added Ban and Unban calls
Co-authored-by: defnotken <itsdefnotken@gmail.com>
Reviewed-on: #2
2025-09-06 00:15:19 +02:00
115 changed files with 25110 additions and 1578 deletions

5
.gitignore vendored
View File

@@ -350,4 +350,7 @@ MigrationBackup/
.ionide/
# docker run data
Docker/run/data/
Docker/run/data/
#idea files
*.idea

1
.gitmodules vendored
View File

@@ -1,3 +1,4 @@
[submodule "LightlessAPI"]
path = LightlessAPI
url = https://git.lightless-sync.org/Lightless-Sync/LightlessAPI
branch = main

View File

@@ -100,14 +100,15 @@ public abstract class AuthControllerBase : Controller
protected async Task<IActionResult> CreateJwtFromId(string uid, string charaIdent, string alias)
{
var token = CreateJwt(new List<Claim>()
{
var token = CreateJwt(
[
new Claim(LightlessClaimTypes.Uid, uid),
new Claim(LightlessClaimTypes.CharaIdent, charaIdent),
new Claim(LightlessClaimTypes.Alias, alias),
new Claim(LightlessClaimTypes.Expires, DateTime.UtcNow.AddHours(6).Ticks.ToString(CultureInfo.InvariantCulture)),
new Claim(LightlessClaimTypes.Continent, await _geoIPProvider.GetCountryFromIP(HttpAccessor))
});
new Claim(LightlessClaimTypes.Continent, await _geoIPProvider.GetContinentFromIP(HttpAccessor)),
new Claim(LightlessClaimTypes.Country, await _geoIPProvider.GetCountryFromIP(HttpAccessor)),
]);
return Content(token.RawData);
}
@@ -121,6 +122,7 @@ public abstract class AuthControllerBase : Controller
{
CharacterIdentification = charaIdent,
Reason = "Autobanned CharacterIdent (" + uid + ")",
BannedUid = uid,
});
}

View File

@@ -1,5 +1,6 @@
using LightlessSync.API.Routes;
using LightlessSyncAuthService.Services;
using LightlessSyncAuthService.Utils;
using LightlessSyncShared;
using LightlessSyncShared.Data;
using LightlessSyncShared.Services;

View File

@@ -1,5 +1,6 @@
using LightlessSync.API.Routes;
using LightlessSyncAuthService.Services;
using LightlessSyncAuthService.Utils;
using LightlessSyncShared;
using LightlessSyncShared.Data;
using LightlessSyncShared.Services;

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

View File

@@ -1,7 +1,8 @@
using LightlessSyncShared;
using LightlessSyncAuthService.Utils;
using LightlessSyncShared.Services;
using LightlessSyncShared.Utils.Configuration;
using MaxMind.GeoIP2;
using System.Net;
namespace LightlessSyncAuthService.Services;
@@ -23,7 +24,7 @@ public class GeoIPService : IHostedService
_lightlessConfiguration = lightlessConfiguration;
}
public async Task<string> GetCountryFromIP(IHttpContextAccessor httpContextAccessor)
public async Task<string> GetContinentFromIP(IHttpContextAccessor httpContextAccessor)
{
if (!_useGeoIP)
{
@@ -32,7 +33,9 @@ public class GeoIPService : IHostedService
try
{
var ip = httpContextAccessor.GetIpAddress();
var ip = httpContextAccessor.GetClientIpAddress();
if (ip is null || IPAddress.IsLoopback(ip))
return "*";
using CancellationTokenSource waitCts = new();
waitCts.CancelAfter(TimeSpan.FromSeconds(5));
@@ -41,11 +44,14 @@ public class GeoIPService : IHostedService
if (_dbReader!.TryCity(ip, out var response))
{
string? continent = response?.Continent.Code;
string? countryIso = response?.Country.IsoCode;
if (!string.IsNullOrEmpty(continent) &&
string.Equals(continent, "NA", StringComparison.Ordinal)
&& response?.Location.Longitude != null)
{
if (response.Location.Longitude < -102)
if (response.Location.Longitude < -102 &&
!string.Equals(countryIso, "CA", StringComparison.OrdinalIgnoreCase))
{
continent = "NA-W";
}
@@ -53,6 +59,8 @@ public class GeoIPService : IHostedService
{
continent = "NA-E";
}
_logger.LogDebug("Connecting {countryIso} to {continent}", countryIso, continent);
}
return continent ?? "*";
@@ -140,4 +148,34 @@ public class GeoIPService : IHostedService
_dbReader?.Dispose();
return Task.CompletedTask;
}
internal async Task<string> GetCountryFromIP(IHttpContextAccessor httpContextAccessor)
{
if (!_useGeoIP)
return "*";
var ip = httpContextAccessor.GetClientIpAddress();
if (ip is null || IPAddress.IsLoopback(ip))
return "*";
try
{
using CancellationTokenSource waitCts = new(TimeSpan.FromSeconds(5));
while (_processingReload)
await Task.Delay(100, waitCts.Token).ConfigureAwait(false);
if (_dbReader!.TryCity(ip, out var response))
{
var country = response?.Country?.IsoCode;
return country ?? "*";
}
return "*";
}
catch (Exception ex)
{
_logger.LogError(ex, "GeoIP lookup failed for {Ip}", ip);
return "*";
}
}
}

View File

@@ -3,8 +3,6 @@ using LightlessSyncShared.Metrics;
using LightlessSyncShared.Services;
using LightlessSyncShared.Utils;
using Microsoft.AspNetCore.Mvc.Controllers;
using StackExchange.Redis.Extensions.Core.Configuration;
using StackExchange.Redis.Extensions.System.Text.Json;
using StackExchange.Redis;
using System.Net;
using LightlessSyncAuthService.Services;
@@ -17,7 +15,6 @@ using LightlessSyncShared.Data;
using Microsoft.EntityFrameworkCore;
using Prometheus;
using LightlessSyncShared.Utils.Configuration;
using StackExchange.Redis.Extensions.Core.Abstractions;
namespace LightlessSyncAuthService;

View File

@@ -0,0 +1,26 @@
using System.Net;
namespace LightlessSyncAuthService.Utils
{
public static class HttpContextAccessorExtensions
{
public static IPAddress? GetClientIpAddress(this IHttpContextAccessor accessor)
{
var context = accessor.HttpContext;
if (context == null) return null;
string[] headerKeys = { "CF-Connecting-IP", "X-Forwarded-For", "X-Real-IP" };
foreach (var key in headerKeys)
{
if (context.Request.Headers.TryGetValue(key, out var values))
{
var ipCandidate = values.FirstOrDefault()?.Split(',').FirstOrDefault()?.Trim();
if (IPAddress.TryParse(ipCandidate, out var parsed))
return parsed;
}
}
return context.Connection?.RemoteIpAddress;
}
}
}

View File

@@ -0,0 +1,83 @@
using System;
using Microsoft.Extensions.Options;
namespace LightlessSyncServer.Configuration;
public class BroadcastConfiguration : IBroadcastConfiguration
{
private static readonly TimeSpan DefaultEntryTtl = TimeSpan.FromMinutes(180);
private const int DefaultMaxStatusBatchSize = 30;
private const string DefaultNotificationTemplate = "{DisplayName} sent you a pair request. To accept, right-click them, open the context menu, and send a request back.";
private readonly IOptionsMonitor<BroadcastOptions> _optionsMonitor;
public BroadcastConfiguration(IOptionsMonitor<BroadcastOptions> optionsMonitor)
{
_optionsMonitor = optionsMonitor;
}
private BroadcastOptions Options => _optionsMonitor.CurrentValue ?? new BroadcastOptions();
public string RedisKeyPrefix
{
get
{
var prefix = Options.RedisKeyPrefix;
return string.IsNullOrWhiteSpace(prefix) ? "broadcast:" : prefix!;
}
}
public TimeSpan BroadcastEntryTtl
{
get
{
var seconds = Options.EntryTtlSeconds;
return seconds > 0 ? TimeSpan.FromSeconds(seconds) : DefaultEntryTtl;
}
}
public int MaxStatusBatchSize
{
get
{
var value = Options.MaxStatusBatchSize;
return value > 0 ? value : DefaultMaxStatusBatchSize;
}
}
public bool NotifyOwnerOnPairRequest => Options.NotifyOwnerOnPairRequest;
public bool EnableBroadcasting => Options.EnableBroadcasting;
public bool EnableSyncshellBroadcastPayloads => Options.EnableSyncshellBroadcastPayloads;
public string BuildRedisKey(string hashedCid)
{
if (string.IsNullOrEmpty(hashedCid))
return RedisKeyPrefix;
return string.Concat(RedisKeyPrefix, hashedCid);
}
public string BuildUserOwnershipKey(string userUid)
{
if (string.IsNullOrWhiteSpace(userUid))
throw new ArgumentException("User UID must not be null or empty.", nameof(userUid));
return string.Concat(RedisKeyPrefix, "owner:", userUid);
}
public string BuildPairRequestNotification()
{
var template = Options.PairRequestNotificationTemplate;
if (string.IsNullOrWhiteSpace(template))
{
template = DefaultNotificationTemplate;
}
return template;
}
public int PairRequestRateLimit => Options.PairRequestRateLimit > 0 ? Options.PairRequestRateLimit : 5;
public int PairRequestRateWindow => Options.PairRequestRateWindow > 0 ? Options.PairRequestRateWindow : 60;
}

View File

@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
namespace LightlessSyncServer.Configuration;
public class BroadcastOptions
{
[Required]
public string RedisKeyPrefix { get; set; } = "broadcast:";
[Range(1, int.MaxValue)]
public int EntryTtlSeconds { get; set; } = 10800;
[Range(1, int.MaxValue)]
public int MaxStatusBatchSize { get; set; } = 30;
public bool NotifyOwnerOnPairRequest { get; set; } = true;
public bool EnableBroadcasting { get; set; } = true;
public bool EnableSyncshellBroadcastPayloads { get; set; } = true;
public string PairRequestNotificationTemplate { get; set; } = "{DisplayName} sent you a pair request. To accept, right-click them, open the context menu, and send a request back.";
[Range(1, int.MaxValue)]
public int PairRequestRateLimit { get; set; } = 5;
[Range(1, int.MaxValue)]
public int PairRequestRateWindow { get; set; } = 60;
}

View File

@@ -0,0 +1,16 @@
using System.Collections.Generic;
namespace LightlessSyncServer.Configuration;
public sealed class ChatZoneOverridesOptions
{
public List<ChatZoneOverride>? Zones { get; set; }
}
public sealed class ChatZoneOverride
{
public string Key { get; set; } = string.Empty;
public string? DisplayName { get; set; }
public List<string>? TerritoryNames { get; set; }
public List<ushort>? TerritoryIds { get; set; }
}

View File

@@ -0,0 +1,20 @@
using System;
namespace LightlessSyncServer.Configuration;
public interface IBroadcastConfiguration
{
string RedisKeyPrefix { get; }
TimeSpan BroadcastEntryTtl { get; }
int MaxStatusBatchSize { get; }
bool NotifyOwnerOnPairRequest { get; }
bool EnableBroadcasting { get; }
bool EnableSyncshellBroadcastPayloads { get; }
string BuildRedisKey(string hashedCid);
string BuildUserOwnershipKey(string userUid);
string BuildPairRequestNotification();
int PairRequestRateLimit { get; }
int PairRequestRateWindow { get; }
}

View File

@@ -0,0 +1,56 @@
using LightlessSync.API.Dto.Group;
using LightlessSync.API.Routes;
using LightlessSyncShared.Data;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace LightlessSyncServer.Controllers;
[Route(LightlessAuth.Group)]
[Authorize(Policy = "Internal")]
public class GroupController : Controller
{
protected readonly ILogger Logger;
protected readonly IDbContextFactory<LightlessDbContext> LightlessDbContextFactory;
public GroupController(ILogger<GroupController> logger, IDbContextFactory<LightlessDbContext> lightlessDbContext)
{
Logger = logger;
LightlessDbContextFactory = lightlessDbContext;
}
[Route(LightlessAuth.Disable_Profile)]
[HttpPost]
public async Task DisableGroupProfile([FromBody] GroupProfileAvailabilityRequest request)
{
using var dbContext = await LightlessDbContextFactory.CreateDbContextAsync();
Logger.LogInformation("Disabling profile for group with GID {GID}", request.GID);
var group = await dbContext.GroupProfiles.FirstOrDefaultAsync(f => f.GroupGID == request.GID);
if (group != null)
{
group.ProfileDisabled = true;
}
await dbContext.SaveChangesAsync();
}
[Route(LightlessAuth.Enable_Profile)]
[HttpPost]
public async Task EnableGroupProfile([FromBody] GroupProfileAvailabilityRequest request)
{
using var dbContext = await LightlessDbContextFactory.CreateDbContextAsync();
Logger.LogInformation("Disabling profile for group with GID {GID}", request.GID);
var group = await dbContext.GroupProfiles.FirstOrDefaultAsync(f => f.GroupGID == request.GID);
if (group != null)
{
group.ProfileDisabled = false;
}
await dbContext.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,133 @@
using LightlessSync.API.Dto.User;
using LightlessSync.API.Routes;
using LightlessSyncShared.Data;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace LightlessSyncServer.Controllers;
[Route(LightlessAuth.User)]
[Authorize(Policy = "Internal")]
public class UserController : Controller
{
protected readonly ILogger Logger;
protected readonly IDbContextFactory<LightlessDbContext> LightlessDbContextFactory;
public UserController(ILogger<UserController> logger, IDbContextFactory<LightlessDbContext> lightlessDbContext)
{
Logger = logger;
LightlessDbContextFactory = lightlessDbContext;
}
[Route(LightlessAuth.Ban_Uid)]
[HttpPost]
public async Task MarkForBanUid([FromBody] BanRequest request)
{
using var dbContext = await LightlessDbContextFactory.CreateDbContextAsync();
Logger.LogInformation("Banning user with UID {UID}", request.Uid);
//Mark User as banned, and not marked for ban
var auth = await dbContext.Auth.FirstOrDefaultAsync(f => f.UserUID == request.Uid);
if (auth != null)
{
auth.MarkForBan = true;
}
await dbContext.SaveChangesAsync();
}
[Route(LightlessAuth.User_Unban_Uid)]
[HttpPost]
public async Task UnBanUserByUid([FromBody] UnbanRequest request)
{
using var dbContext = await LightlessDbContextFactory.CreateDbContextAsync();
Logger.LogInformation("Unbanning user with UID {UID}", request.Uid);
//Mark User as not banned, and not marked for ban (if marked)
var auth = await dbContext.Auth.FirstOrDefaultAsync(f => f.UserUID == request.Uid);
if (auth != null)
{
auth.IsBanned = false;
auth.MarkForBan = false;
}
// Remove all bans associated with this user
var bannedFromLightlessIds = dbContext.BannedUsers.Where(b => b.BannedUid == request.Uid);
dbContext.BannedUsers.RemoveRange(bannedFromLightlessIds);
// Remove all character/discord bans associated with this user
var lodestoneAuths = dbContext.LodeStoneAuth.Where(l => l.User != null && l.User.UID == request.Uid).ToList();
foreach (var lodestoneAuth in lodestoneAuths)
{
var bannedRegs = dbContext.BannedRegistrations.Where(b => b.DiscordIdOrLodestoneAuth == lodestoneAuth.HashedLodestoneId || b.DiscordIdOrLodestoneAuth == lodestoneAuth.DiscordId.ToString());
dbContext.BannedRegistrations.RemoveRange(bannedRegs);
}
await dbContext.SaveChangesAsync();
}
[Route(LightlessAuth.User_Unban_Discord)]
[HttpPost]
public async Task UnBanUserByDiscordId([FromBody] UnbanRequest request)
{
Logger.LogInformation("Unbanning user with discordId: {discordId}", request.DiscordId);
using var dbContext = await LightlessDbContextFactory.CreateDbContextAsync();
var userByDiscord = await dbContext.LodeStoneAuth.Include(l => l.User).FirstOrDefaultAsync(l => l.DiscordId.ToString() == request.DiscordId);
if (userByDiscord?.User == null)
{
Logger.LogInformation("Unbanning user with discordId: {discordId} but no user found", request.DiscordId);
return;
}
var bannedRegs = dbContext.BannedRegistrations.Where(b => b.DiscordIdOrLodestoneAuth == request.DiscordId || b.DiscordIdOrLodestoneAuth == userByDiscord.HashedLodestoneId);
//Mark User as not banned, and not marked for ban (if marked)
var auth = await dbContext.Auth.FirstOrDefaultAsync(f => f.UserUID == userByDiscord.User.UID);
if (auth != null)
{
auth.IsBanned = false;
auth.MarkForBan = false;
}
// Remove all bans associated with this user
var bannedFromLightlessIds = dbContext.BannedUsers.Where(b => b.BannedUid == auth.UserUID || b.BannedUid == auth.PrimaryUserUID);
dbContext.BannedUsers.RemoveRange(bannedFromLightlessIds);
await dbContext.SaveChangesAsync();
}
[Route(LightlessAuth.Disable_Profile)]
[HttpPost]
public async Task DisableGroupProfile([FromBody] UserProfileAvailabilityRequest request)
{
using var dbContext = await LightlessDbContextFactory.CreateDbContextAsync();
Logger.LogInformation("Disabling profile for user with uid {UID}", request.UID);
var user = await dbContext.UserProfileData.FirstOrDefaultAsync(f => f.UserUID == request.UID);
if (user != null)
{
user.ProfileDisabled = true;
}
await dbContext.SaveChangesAsync();
}
[Route(LightlessAuth.Enable_Profile)]
[HttpPost]
public async Task EnableGroupProfile([FromBody] UserProfileAvailabilityRequest request)
{
using var dbContext = await LightlessDbContextFactory.CreateDbContextAsync();
Logger.LogInformation("Enabling profile for user with uid {UID}", request.UID);
var user = await dbContext.UserProfileData.FirstOrDefaultAsync(f => f.UserUID == request.UID);
if (user != null)
{
user.ProfileDisabled = false;
}
await dbContext.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,697 @@
using System.Collections.Concurrent;
using System.Text.Json;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto.Chat;
using LightlessSyncServer.Models;
using LightlessSyncServer.Services;
using LightlessSyncServer.Utils;
using LightlessSyncShared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
namespace LightlessSyncServer.Hubs;
public partial class LightlessHub
{
private const int MaxChatMessageLength = 200;
private const int ChatRateLimitMessages = 7;
private static readonly TimeSpan ChatRateLimitWindow = TimeSpan.FromMinutes(1);
private static readonly ConcurrentDictionary<string, ChatRateLimitState> ChatRateLimiters = new(StringComparer.Ordinal);
private sealed class ChatRateLimitState
{
public readonly Queue<DateTime> Events = new();
public readonly object SyncRoot = new();
}
private static readonly JsonSerializerOptions ChatReportSnapshotSerializerOptions = new(JsonSerializerDefaults.General)
{
WriteIndented = false
};
[Authorize(Policy = "Identified")]
public Task<IReadOnlyList<ZoneChatChannelInfoDto>> GetZoneChatChannels()
{
return Task.FromResult(_chatChannelService.GetZoneChannelInfos());
}
[Authorize(Policy = "Identified")]
public async Task<IReadOnlyList<GroupChatChannelInfoDto>> GetGroupChatChannels()
{
var userUid = UserUID;
var groupInfos = await DbContext.Groups
.AsNoTracking()
.Where(g => g.ChatEnabled
&& (g.OwnerUID == userUid
|| DbContext.GroupPairs.Any(p => p.GroupGID == g.GID && p.GroupUserUID == userUid))
)
.ToListAsync()
.ConfigureAwait(false);
return groupInfos
.Select(g =>
{
var displayName = string.IsNullOrWhiteSpace(g.Alias) ? g.GID : g.Alias!;
var descriptor = new ChatChannelDescriptor
{
Type = ChatChannelType.Group,
WorldId = 0,
ZoneId = 0,
CustomKey = g.GID
};
return new GroupChatChannelInfoDto(
descriptor,
displayName,
g.GID,
string.Equals(g.OwnerUID, userUid, StringComparison.Ordinal));
})
.OrderBy(info => info.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToList();
}
[Authorize(Policy = "Identified")]
public async Task UpdateChatPresence(ChatPresenceUpdateDto presence)
{
var channel = presence.Channel.WithNormalizedCustomKey();
var userRecord = await DbContext.Users
.AsNoTracking()
.SingleAsync(u => u.UID == UserUID, cancellationToken: RequestAbortedToken)
.ConfigureAwait(false);
if (userRecord.ChatBanned)
{
TryInvokeChatService(() => _chatChannelService.RemovePresence(UserUID), "clearing presence for banned user");
await NotifyChatBanAsync(UserUID).ConfigureAwait(false);
return;
}
if (!presence.IsActive)
{
if (!TryInvokeChatService(() => _chatChannelService.RemovePresence(UserUID, channel), "removing chat presence", channel))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "We couldn't update your chat presence. Please try again.").ConfigureAwait(false);
}
return;
}
switch (channel.Type)
{
case ChatChannelType.Zone:
if (!_chatChannelService.TryResolveZone(channel.CustomKey, out var definition))
{
throw new HubException("Unsupported chat channel.");
}
if (channel.WorldId == 0 || !WorldRegistry.IsKnownWorld(channel.WorldId))
{
throw new HubException("Unsupported chat channel.");
}
if (presence.TerritoryId == 0 || !definition.TerritoryIds.Contains(presence.TerritoryId))
{
throw new HubException("Zone chat is only available in supported territories.");
}
string? hashedCid = null;
var isLightfinder = false;
if (IsValidHashedCid(UserCharaIdent))
{
var (entry, expiry) = await TryGetBroadcastEntryAsync(UserCharaIdent).ConfigureAwait(false);
isLightfinder = HasActiveBroadcast(entry, expiry);
if (isLightfinder)
{
hashedCid = UserCharaIdent;
}
}
if (!TryInvokeChatService(
() => _chatChannelService.UpdateZonePresence(
UserUID,
definition,
channel.WorldId,
presence.TerritoryId,
hashedCid,
isLightfinder,
isActive: true),
"updating zone chat presence",
channel))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Zone chat is temporarily unavailable. Please try again.").ConfigureAwait(false);
return;
}
break;
case ChatChannelType.Group:
var groupKey = channel.CustomKey ?? string.Empty;
if (string.IsNullOrEmpty(groupKey))
{
throw new HubException("Unsupported chat channel.");
}
var userData = userRecord.ToUserData();
var group = await DbContext.Groups
.AsNoTracking()
.SingleOrDefaultAsync(g => g.GID == groupKey, cancellationToken: RequestAbortedToken)
.ConfigureAwait(false);
if (group is null)
{
throw new HubException("Unsupported chat channel.");
}
if (!group.ChatEnabled)
{
TryInvokeChatService(() => _chatChannelService.RemovePresence(UserUID, channel), "removing chat presence", channel);
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "This Syncshell chat is disabled.").ConfigureAwait(false);
return;
}
var isMember = string.Equals(group.OwnerUID, UserUID, StringComparison.Ordinal)
|| await DbContext.GroupPairs
.AsNoTracking()
.AnyAsync(gp => gp.GroupGID == groupKey && gp.GroupUserUID == UserUID, cancellationToken: RequestAbortedToken)
.ConfigureAwait(false);
if (!isMember)
{
throw new HubException("Join the syncshell before using chat.");
}
var displayName = string.IsNullOrWhiteSpace(group.Alias) ? group.GID : group.Alias;
if (!TryInvokeChatService(
() => _chatChannelService.UpdateGroupPresence(
UserUID,
group.GID,
displayName,
userData,
IsValidHashedCid(UserCharaIdent) ? UserCharaIdent : null,
isActive: true),
"updating group chat presence",
channel))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Syncshell chat is temporarily unavailable. Please try again.").ConfigureAwait(false);
return;
}
break;
default:
throw new HubException("Unsupported chat channel.");
}
}
[Authorize(Policy = "Identified")]
public async Task SendChatMessage(ChatSendRequestDto request)
{
if (string.IsNullOrWhiteSpace(request.Message))
{
throw new HubException("Message cannot be empty.");
}
var channel = request.Channel.WithNormalizedCustomKey();
if (channel.Type == ChatChannelType.Group)
{
var groupId = channel.CustomKey ?? string.Empty;
var chatEnabled = !string.IsNullOrEmpty(groupId) && await DbContext.Groups
.AsNoTracking()
.Where(g => g.GID == groupId)
.Select(g => g.ChatEnabled)
.SingleOrDefaultAsync(RequestAbortedToken)
.ConfigureAwait(false);
if (!chatEnabled)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "This Syncshell chat is disabled.").ConfigureAwait(false);
return;
}
}
if (await HandleIfChatBannedAsync(UserUID).ConfigureAwait(false))
{
throw new HubException("Chat access has been revoked.");
}
if (!_chatChannelService.TryGetPresence(UserUID, channel, out var presence))
{
throw new HubException("Join a chat channel before sending messages.");
}
if (!UseChatRateLimit(UserUID))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You can send at most " + ChatRateLimitMessages + " chat messages per minute. Please wait before sending more.").ConfigureAwait(false);
return;
}
var sanitizedMessage = request.Message.Trim().ReplaceLineEndings(" ");
if (sanitizedMessage.Length > MaxChatMessageLength)
{
sanitizedMessage = sanitizedMessage[..MaxChatMessageLength];
}
if (channel.Type == ChatChannelType.Zone &&
!ChatMessageFilter.TryValidate(sanitizedMessage, out var rejectionReason))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, rejectionReason).ConfigureAwait(false);
return;
}
try
{
var recipients = _chatChannelService.GetMembers(presence.Channel);
var recipientsList = recipients.ToList();
if (recipientsList.Count == 0)
{
return;
}
var bannedRecipients = recipientsList.Count == 0
? new List<string>()
: await DbContext.Users.AsNoTracking()
.Where(u => recipientsList.Contains(u.UID) && u.ChatBanned)
.Select(u => u.UID)
.ToListAsync(RequestAbortedToken)
.ConfigureAwait(false);
HashSet<string>? bannedSet = null;
if (bannedRecipients.Count > 0)
{
bannedSet = new HashSet<string>(bannedRecipients, StringComparer.Ordinal);
foreach (var bannedUid in bannedSet)
{
_chatChannelService.RemovePresence(bannedUid);
await NotifyChatBanAsync(bannedUid).ConfigureAwait(false);
}
}
var deliveryTargets = new Dictionary<string, (string Uid, bool IncludeSensitive)>(StringComparer.Ordinal);
foreach (var uid in recipientsList)
{
if (bannedSet != null && bannedSet.Contains(uid))
{
continue;
}
if (_userConnections.TryGetValue(uid, out var connectionId))
{
if (_chatChannelService.IsTokenMuted(uid, presence.Channel, presence.Participant.Token))
{
continue;
}
var includeSensitive = await AllowsLightfinderDetailsAsync(presence.Channel, uid).ConfigureAwait(false);
if (deliveryTargets.TryGetValue(connectionId, out var existing))
{
deliveryTargets[connectionId] = (existing.Uid, existing.IncludeSensitive || includeSensitive);
}
else
{
deliveryTargets[connectionId] = (uid, includeSensitive);
}
}
else
{
_chatChannelService.RemovePresence(uid);
}
}
if (deliveryTargets.Count == 0)
{
return;
}
var timestamp = DateTime.UtcNow;
var messageId = _chatChannelService.RecordMessage(presence.Channel, presence.Participant, sanitizedMessage, timestamp);
var sendTasks = new List<Task>(deliveryTargets.Count);
foreach (var (connectionId, target) in deliveryTargets)
{
var sender = BuildSenderDescriptor(presence.Channel, presence.Participant, target.IncludeSensitive);
var payload = new ChatMessageDto(
presence.Channel,
sender,
sanitizedMessage,
timestamp,
messageId);
sendTasks.Add(Clients.Client(connectionId).Client_ChatReceive(payload));
}
await Task.WhenAll(sendTasks).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deliver chat message for {User} in {Channel}", UserUID, DescribeChannel(presence.Channel));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Something went wrong while sending your message. Please try again.").ConfigureAwait(false);
}
}
[Authorize(Policy = "Identified")]
public async Task ReportChatMessage(ChatReportSubmitDto request)
{
var channel = request.Channel.WithNormalizedCustomKey();
if (!_chatChannelService.TryGetMessage(request.MessageId, out var messageEntry))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Unable to locate the reported message. It may have already expired.").ConfigureAwait(false);
return;
}
var requestedChannelKey = ChannelKey.FromDescriptor(channel);
var messageChannelKey = ChannelKey.FromDescriptor(messageEntry.Channel.WithNormalizedCustomKey());
if (!requestedChannelKey.Equals(messageChannelKey))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "The reported message no longer matches this channel.").ConfigureAwait(false);
return;
}
if (string.Equals(messageEntry.SenderUserUid, UserUID, StringComparison.Ordinal))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You cannot report your own message.").ConfigureAwait(false);
return;
}
var reason = request.Reason?.Trim();
if (string.IsNullOrWhiteSpace(reason))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "Please provide a short explanation for the report.").ConfigureAwait(false);
return;
}
const int MaxReasonLength = 500;
if (reason.Length > MaxReasonLength)
{
reason = reason[..MaxReasonLength];
}
var additionalContext = string.IsNullOrWhiteSpace(request.AdditionalContext)
? null
: request.AdditionalContext.Trim();
const int MaxContextLength = 1000;
if (!string.IsNullOrEmpty(additionalContext) && additionalContext.Length > MaxContextLength)
{
additionalContext = additionalContext[..MaxContextLength];
}
var alreadyReported = await DbContext.ReportedChatMessages
.AsNoTracking()
.AnyAsync(r => r.MessageId == request.MessageId && r.ReporterUserUid == UserUID && !r.Resolved, cancellationToken: RequestAbortedToken)
.ConfigureAwait(false);
if (alreadyReported)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You already reported this message and it is pending review.").ConfigureAwait(false);
return;
}
var oneHourAgo = DateTime.UtcNow - TimeSpan.FromHours(1);
var reportRateLimited = await DbContext.ReportedChatMessages
.AsNoTracking()
.AnyAsync(r => r.ReporterUserUid == UserUID && r.ReportTimeUtc >= oneHourAgo, cancellationToken: RequestAbortedToken)
.ConfigureAwait(false);
if (reportRateLimited)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You can file at most one chat report per hour.").ConfigureAwait(false);
return;
}
/* if (!string.IsNullOrEmpty(messageEntry.SenderUserUid))
{
var targetAlreadyPending = await DbContext.ReportedChatMessages
.AsNoTracking()
.AnyAsync(r => r.ReportedUserUid == messageEntry.SenderUserUid && !r.Resolved, cancellationToken: RequestAbortedToken)
.ConfigureAwait(false);
if (targetAlreadyPending)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "This user already has a report pending review.").ConfigureAwait(false);
return;
}
} */
var snapshotEntries = _chatChannelService.GetRecentMessages(messageEntry.Channel, 25);
var snapshotItems = snapshotEntries
.Select(e => new ChatReportSnapshotItem(
e.MessageId,
e.SentAtUtc,
e.SenderUserUid,
e.SenderUser?.AliasOrUID,
e.SenderIsLightfinder,
e.SenderHashedCid,
e.Message))
.ToArray();
var snapshotJson = JsonSerializer.Serialize(snapshotItems, ChatReportSnapshotSerializerOptions);
var report = new ReportedChatMessage
{
ReportTimeUtc = DateTime.UtcNow,
ReporterUserUid = UserUID,
ReportedUserUid = messageEntry.SenderUserUid,
ChannelType = messageEntry.Channel.Type,
WorldId = messageEntry.Channel.WorldId,
ZoneId = messageEntry.Channel.ZoneId,
ChannelKey = messageEntry.Channel.CustomKey ?? string.Empty,
MessageId = messageEntry.MessageId,
MessageSentAtUtc = messageEntry.SentAtUtc,
MessageContent = messageEntry.Message,
SenderHashedCid = messageEntry.SenderHashedCid,
SenderDisplayName = messageEntry.SenderUser?.AliasOrUID,
SenderWasLightfinder = messageEntry.SenderIsLightfinder,
SnapshotJson = snapshotJson,
Reason = reason,
AdditionalContext = additionalContext
};
DbContext.ReportedChatMessages.Add(report);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "Thank you. Your report has been queued for moderator review.").ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task SetChatParticipantMute(ChatParticipantMuteRequestDto request)
{
var channel = request.Channel.WithNormalizedCustomKey();
if (!_chatChannelService.TryGetPresence(UserUID, channel, out _))
{
throw new HubException("Join the chat channel before updating mutes.");
}
if (string.IsNullOrWhiteSpace(request.Token))
{
throw new HubException("Invalid participant.");
}
if (!_chatChannelService.TryGetActiveParticipant(channel, request.Token, out var participant))
{
throw new HubException("Unable to locate that participant in this channel.");
}
if (string.Equals(participant.UserUid, UserUID, StringComparison.Ordinal))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You cannot mute yourself.").ConfigureAwait(false);
return;
}
ChatMuteUpdateResult result;
try
{
result = _chatChannelService.SetMutedParticipant(UserUID, channel, participant, request.Mute);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update mute for {User} in {Channel}", UserUID, DescribeChannel(channel));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Unable to update mute settings right now. Please try again.").ConfigureAwait(false);
return;
}
if (result == ChatMuteUpdateResult.ChannelLimitReached)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, $"You can mute at most {ChatChannelService.MaxMutedParticipantsPerChannel} participants per channel. Unmute someone before adding another mute.").ConfigureAwait(false);
return;
}
if (result != ChatMuteUpdateResult.Changed)
{
return;
}
if (request.Mute)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "You will no longer receive this participant's messages in the current channel.").ConfigureAwait(false);
}
else
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "You will receive this participant's messages again.").ConfigureAwait(false);
}
}
private static string DescribeChannel(ChatChannelDescriptor descriptor) =>
$"{descriptor.Type}:{descriptor.WorldId}:{descriptor.CustomKey}";
private bool TryInvokeChatService(Action action, string operationDescription, ChatChannelDescriptor? descriptor = null, string? targetUserUid = null)
{
try
{
action();
return true;
}
catch (Exception ex)
{
var logUser = targetUserUid ?? UserUID;
if (descriptor is ChatChannelDescriptor described)
{
_logger.LogError(ex, "Chat service failed while {Operation} for {User} in {Channel}", operationDescription, logUser, DescribeChannel(described));
}
else
{
_logger.LogError(ex, "Chat service failed while {Operation} for {User}", operationDescription, logUser);
}
return false;
}
}
private ChatSenderDescriptor BuildSenderDescriptor(ChatChannelDescriptor descriptor, ChatParticipantInfo participant, bool includeSensitiveInfo = false)
{
var kind = descriptor.Type == ChatChannelType.Group
? ChatSenderKind.IdentifiedUser
: ChatSenderKind.Anonymous;
string? displayName;
if (kind == ChatSenderKind.IdentifiedUser)
{
displayName = participant.User?.Alias ?? participant.User?.UID ?? participant.UserUid;
}
else if (includeSensitiveInfo && participant.IsLightfinder && !string.IsNullOrEmpty(participant.HashedCid))
{
displayName = participant.HashedCid;
}
else
{
var source = participant.UserUid ?? string.Empty;
var suffix = source.Length >= 4 ? source[^4..] : source;
displayName = string.IsNullOrEmpty(suffix) ? "Anonymous" : $"Anon-{suffix}";
}
var hashedCid = includeSensitiveInfo && participant.IsLightfinder
? participant.HashedCid
: null;
var canResolveProfile = includeSensitiveInfo && (kind == ChatSenderKind.IdentifiedUser || participant.IsLightfinder);
return new ChatSenderDescriptor(
kind,
participant.Token,
displayName,
hashedCid,
descriptor.Type == ChatChannelType.Group ? participant.User : null,
canResolveProfile);
}
private async Task<bool> ViewerAllowsLightfinderDetailsAsync(ChatChannelDescriptor descriptor)
{
if (descriptor.Type == ChatChannelType.Group)
{
return true;
}
var viewerCid = UserCharaIdent;
if (!IsValidHashedCid(viewerCid))
{
return false;
}
var (entry, expiry) = await TryGetBroadcastEntryAsync(viewerCid).ConfigureAwait(false);
return HasActiveBroadcast(entry, expiry);
}
private async Task<bool> AllowsLightfinderDetailsAsync(ChatChannelDescriptor descriptor, string userUid)
{
if (descriptor.Type == ChatChannelType.Group)
{
return true;
}
if (_chatChannelService.TryGetPresence(userUid, descriptor, out var presence))
{
if (!presence.Participant.IsLightfinder || !IsValidHashedCid(presence.Participant.HashedCid))
{
return false;
}
var (entry, expiry) = await TryGetBroadcastEntryAsync(presence.Participant.HashedCid!).ConfigureAwait(false);
if (!IsActiveBroadcastForUser(entry, expiry, userUid))
{
TryInvokeChatService(
() => _chatChannelService.RefreshLightfinderState(userUid, null, isLightfinder: false),
"refreshing lightfinder state",
descriptor,
userUid);
return false;
}
return true;
}
return false;
}
private async Task<bool> HandleIfChatBannedAsync(string userUid)
{
var isBanned = await DbContext.Users
.AsNoTracking()
.AnyAsync(u => u.UID == userUid && u.ChatBanned, RequestAbortedToken)
.ConfigureAwait(false);
if (!isBanned)
return false;
TryInvokeChatService(() => _chatChannelService.RemovePresence(userUid), "clearing presence for chat-banned user", targetUserUid: userUid);
await NotifyChatBanAsync(userUid).ConfigureAwait(false);
return true;
}
private async Task NotifyChatBanAsync(string userUid)
{
if (string.Equals(userUid, UserUID, StringComparison.Ordinal))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your chat access has been revoked.").ConfigureAwait(false);
}
else if (_userConnections.TryGetValue(userUid, out var connectionId))
{
await Clients.Client(connectionId).Client_ReceiveServerMessage(MessageSeverity.Error, "Your chat access has been revoked.").ConfigureAwait(false);
}
}
private static bool UseChatRateLimit(string userUid)
{
var state = ChatRateLimiters.GetOrAdd(userUid, _ => new ChatRateLimitState());
lock (state.SyncRoot)
{
var now = DateTime.UtcNow;
while (state.Events.Count > 0 && now - state.Events.Peek() >= ChatRateLimitWindow)
{
state.Events.Dequeue();
}
if (state.Events.Count >= ChatRateLimitMessages)
{
return false;
}
state.Events.Enqueue(now);
return true;
}
}
}

View File

@@ -1,6 +1,7 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto;
using LightlessSync.API.Dto.Chat;
using LightlessSync.API.Dto.CharaData;
using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User;
@@ -10,41 +11,25 @@ namespace LightlessSyncServer.Hubs
public partial class LightlessHub
{
public Task Client_DownloadReady(Guid requestId) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GroupChangePermissions(GroupPermissionDto groupPermission) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GroupDelete(GroupDto groupDto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GroupPairChangeUserInfo(GroupPairUserInfoDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GroupPairJoined(GroupPairFullInfoDto groupPairInfoDto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GroupPairLeft(GroupPairDto groupPairDto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GroupSendFullInfo(GroupFullInfoDto groupInfo) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GroupSendProfile(GroupProfileDto groupProfile) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GroupSendInfo(GroupInfoDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_ReceiveServerMessage(MessageSeverity messageSeverity, string message) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_ReceiveBroadcastPairRequest(UserPairNotificationDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UpdateSystemInfo(SystemInfoDto systemInfo) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UserAddClientPair(UserPairDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UserReceiveCharacterData(OnlineUserCharaDataDto dataDto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UserReceiveUploadStatus(UserDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UserRemoveClientPair(UserDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UserSendOffline(UserDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UserSendOnline(OnlineUserIdentDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UserUpdateOtherPairPermissions(UserPermissionsDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UserUpdateProfile(UserDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UserUpdateSelfPairPermissions(UserPermissionsDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UserUpdateDefaultPermissions(DefaultPermissionsDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UpdateUserIndividualPairStatusDto(UserIndividualPairStatusDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
@@ -54,5 +39,7 @@ namespace LightlessSyncServer.Hubs
public Task Client_GposeLobbyPushCharacterData(CharaDataDownloadDto charaDownloadDto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GposeLobbyPushPoseData(UserData userData, PoseData poseData) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GposeLobbyPushWorldData(UserData userData, WorldData worldData) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_ChatReceive(ChatMessageDto message) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_SendLocationToClient(LocationDto locationDto, DateTimeOffset expireAt) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
}
}

View File

@@ -1,11 +1,14 @@
using LightlessSyncShared.Models;
using Microsoft.EntityFrameworkCore;
using LightlessSyncServer.Utils;
using LightlessSyncShared.Utils;
using LightlessSync.API.Data;
using LightlessSync.API.Dto.Group;
using LightlessSyncServer.Models;
using LightlessSyncServer.Utils;
using LightlessSyncShared.Metrics;
using LightlessSyncShared.Models;
using LightlessSyncShared.Utils;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
using System.Text.Json;
namespace LightlessSyncServer.Hubs;
@@ -17,6 +20,8 @@ public partial class LightlessHub
public string Continent => Context.User?.Claims?.SingleOrDefault(c => string.Equals(c.Type, LightlessClaimTypes.Continent, StringComparison.Ordinal))?.Value ?? "UNK";
public string Country => Context.User?.Claims?.SingleOrDefault(c => string.Equals(c.Type, LightlessClaimTypes.Country, StringComparison.Ordinal))?.Value ?? "UNK";
private async Task DeleteUser(User user)
{
var ownPairData = await DbContext.ClientPairs.Where(u => u.User.UID == user.UID).ToListAsync().ConfigureAwait(false);
@@ -94,9 +99,77 @@ public partial class LightlessHub
private async Task RemoveUserFromRedis()
{
if (IsValidHashedCid(UserCharaIdent))
{
await _redis.RemoveAsync("CID:" + UserCharaIdent, StackExchange.Redis.CommandFlags.FireAndForget).ConfigureAwait(false);
}
await _redis.RemoveAsync("UID:" + UserUID, StackExchange.Redis.CommandFlags.FireAndForget).ConfigureAwait(false);
}
private async Task<User?> EnsureUserHasVanity(string uid, CancellationToken cancellationToken = default)
{
cancellationToken = cancellationToken == default && _contextAccessor.HttpContext != null
? RequestAbortedToken
: cancellationToken;
var user = await DbContext.Users.SingleOrDefaultAsync(u => u.UID == uid, cancellationToken).ConfigureAwait(false);
if (user == null)
{
_logger.LogCallWarning(LightlessHubLogger.Args("vanity check", uid, "missing user"));
return null;
}
if (!user.HasVanity)
{
_logger.LogCallWarning(LightlessHubLogger.Args("vanity check", uid, "no vanity"));
return null;
}
return user;
}
private async Task ClearOwnedBroadcastLock()
{
var db = _redis.Database;
var ownershipKey = _broadcastConfiguration.BuildUserOwnershipKey(UserUID);
var ownedCidValue = await db.StringGetAsync(ownershipKey).ConfigureAwait(false);
if (ownedCidValue.IsNullOrEmpty)
return;
var ownedCid = ownedCidValue.ToString();
await db.KeyDeleteAsync(ownershipKey, CommandFlags.FireAndForget).ConfigureAwait(false);
if (string.IsNullOrEmpty(ownedCid))
return;
var broadcastKey = _broadcastConfiguration.BuildRedisKey(ownedCid);
var broadcastValue = await db.StringGetAsync(broadcastKey).ConfigureAwait(false);
if (broadcastValue.IsNullOrEmpty)
return;
BroadcastRedisEntry? entry;
try
{
entry = JsonSerializer.Deserialize<BroadcastRedisEntry>(broadcastValue.ToString()!);
}
catch (Exception ex)
{
_logger.LogCallWarning(LightlessHubLogger.Args("failed to deserialize broadcast during disconnect cleanup", "CID", ownedCid, "Value", broadcastValue, "Error", ex));
return;
}
if (entry is null)
return;
if (entry.HasOwner() && !entry.OwnedBy(UserUID))
return;
await db.KeyDeleteAsync(broadcastKey, CommandFlags.FireAndForget).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args("broadcast cleaned on disconnect", UserUID, "CID", entry.HashedCID, "GID", entry.GID));
}
private async Task SendGroupDeletedToAll(List<GroupPair> groupUsers)
{
foreach (var pair in groupUsers)
@@ -138,7 +211,8 @@ public partial class LightlessHub
if (isOwnerResult.ReferredGroup == null) return (false, null);
var groupPairSelf = await DbContext.GroupPairs.SingleOrDefaultAsync(g => g.GroupGID == gid && g.GroupUserUID == UserUID).ConfigureAwait(false);
var groupPairSelf = await DbContext.GroupPairs.SingleOrDefaultAsync(
g => (g.GroupGID == gid || g.Group.Alias == gid) && g.GroupUserUID == UserUID).ConfigureAwait(false);
if (groupPairSelf == null || !groupPairSelf.IsModerator) return (false, null);
return (true, isOwnerResult.ReferredGroup);
@@ -146,7 +220,7 @@ public partial class LightlessHub
private async Task<(bool isValid, Group ReferredGroup)> TryValidateOwner(string gid)
{
var group = await DbContext.Groups.SingleOrDefaultAsync(g => g.GID == gid).ConfigureAwait(false);
var group = await DbContext.Groups.SingleOrDefaultAsync(g => g.GID == gid || g.Alias == gid).ConfigureAwait(false);
if (group == null) return (false, null);
return (string.Equals(group.OwnerUID, UserUID, StringComparison.Ordinal), group);
@@ -165,7 +239,13 @@ public partial class LightlessHub
private async Task UpdateUserOnRedis()
{
await _redis.AddAsync("UID:" + UserUID, UserCharaIdent, TimeSpan.FromSeconds(60), StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags.FireAndForget).ConfigureAwait(false);
var hashedCid = UserCharaIdent;
if (IsValidHashedCid(hashedCid))
{
await _redis.AddAsync("CID:" + hashedCid, UserUID, TimeSpan.FromSeconds(60), StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags.FireAndForget).ConfigureAwait(false);
}
await _redis.AddAsync("UID:" + UserUID, hashedCid, TimeSpan.FromSeconds(60), StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags.FireAndForget).ConfigureAwait(false);
}
private async Task UserGroupLeave(GroupPair groupUserPair, string userIdent, Dictionary<string, UserInfo> allUserPairs, string? uid = null)
@@ -323,7 +403,12 @@ public partial class LightlessHub
GID = user.Gid,
Synced = user.Synced,
OwnPermissions = ownperm,
OtherPermissions = otherperm
OtherPermissions = otherperm,
OtherUserIsAdmin = u.IsAdmin,
OtherUserIsModerator = u.IsModerator,
OtherUserHasVanity = u.HasVanity,
OtherUserTextColorHex = u.TextColorHex,
OtherUserTextGlowColorHex = u.TextGlowColorHex
};
var resultList = await result.AsNoTracking().ToListAsync().ConfigureAwait(false);
@@ -331,12 +416,18 @@ public partial class LightlessHub
if (!resultList.Any()) return null;
var groups = resultList.Select(g => g.GID).ToList();
return new UserInfo(resultList[0].OtherUserAlias,
return new UserInfo(
resultList[0].OtherUserAlias,
resultList.SingleOrDefault(p => string.IsNullOrEmpty(p.GID))?.Synced ?? false,
resultList.Max(p => p.Synced),
resultList.Select(p => string.IsNullOrEmpty(p.GID) ? Constants.IndividualKeyword : p.GID).ToList(),
resultList[0].OwnPermissions,
resultList[0].OtherPermissions);
resultList[0].OtherPermissions,
resultList[0].OtherUserIsAdmin,
resultList[0].OtherUserIsModerator,
resultList[0].OtherUserHasVanity,
resultList[0].OtherUserTextColorHex ?? string.Empty,
resultList[0].OtherUserTextGlowColorHex ?? string.Empty);
}
private async Task<Dictionary<string, UserInfo>> GetAllPairInfo(string uid)
@@ -408,18 +499,29 @@ public partial class LightlessHub
GID = user.Gid,
Synced = user.Synced,
OwnPermissions = ownperm,
OtherPermissions = otherperm
OtherPermissions = otherperm,
OtherUserIsAdmin = u.IsAdmin,
OtherUserIsModerator = u.IsModerator,
OtherUserHasVanity = u.HasVanity,
OtherUserTextColorHex = u.TextColorHex,
OtherUserTextGlowColorHex = u.TextGlowColorHex
};
var resultList = await result.AsNoTracking().ToListAsync().ConfigureAwait(false);
return resultList.GroupBy(g => g.OtherUserUID, StringComparer.Ordinal).ToDictionary(g => g.Key, g =>
{
return new UserInfo(g.First().OtherUserAlias,
return new UserInfo(
g.First().OtherUserAlias,
g.SingleOrDefault(p => string.IsNullOrEmpty(p.GID))?.Synced ?? false,
g.Max(p => p.Synced),
g.Select(p => string.IsNullOrEmpty(p.GID) ? Constants.IndividualKeyword : p.GID).ToList(),
g.First().OwnPermissions,
g.First().OtherPermissions);
g.First().OtherPermissions,
g.First().OtherUserIsAdmin,
g.First().OtherUserIsModerator,
g.First().OtherUserHasVanity,
g.First().OtherUserTextColorHex ?? string.Empty,
g.First().OtherUserTextGlowColorHex ?? string.Empty);
}, StringComparer.Ordinal);
}
@@ -484,5 +586,17 @@ public partial class LightlessHub
return await result.Distinct().AsNoTracking().ToListAsync().ConfigureAwait(false);
}
public record UserInfo(string Alias, bool IndividuallyPaired, bool IsSynced, List<string> GIDs, UserPermissionSet? OwnPermissions, UserPermissionSet? OtherPermissions);
public record UserInfo(
string Alias,
bool IndividuallyPaired,
bool IsSynced,
List<string> GIDs,
UserPermissionSet? OwnPermissions,
UserPermissionSet? OtherPermissions,
bool IsAdmin,
bool IsModerator,
bool HasVanity,
string? TextColorHex,
string? TextGlowColorHex
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto;
using LightlessSync.API.SignalR;
using LightlessSyncServer.Services;
using LightlessSyncServer.Configuration;
using LightlessSyncServer.Utils;
using LightlessSyncShared;
using LightlessSyncShared.Data;
@@ -15,6 +16,9 @@ using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis.Extensions.Core.Abstractions;
using System.Collections.Concurrent;
using LightlessSync.API.Dto.CharaData;
using LightlessSync.API.Dto.User;
using LightlessSyncServer.Services.Interfaces;
namespace LightlessSyncServer.Hubs;
@@ -24,10 +28,13 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
private static readonly ConcurrentDictionary<string, string> _userConnections = new(StringComparer.Ordinal);
private readonly LightlessMetrics _lightlessMetrics;
private readonly SystemInfoService _systemInfoService;
private readonly PairService _pairService;
private readonly IPruneService _pruneService;
private readonly IHttpContextAccessor _contextAccessor;
private readonly LightlessHubLogger _logger;
private readonly string _shardName;
private readonly int _maxExistingGroupsByUser;
private readonly IBroadcastConfiguration _broadcastConfiguration;
private readonly int _maxJoinedGroupsByUser;
private readonly int _maxGroupUserCount;
private readonly IRedisDatabase _redis;
@@ -40,12 +47,16 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
private LightlessDbContext DbContext => _dbContextLazy.Value;
private readonly int _maxCharaDataByUser;
private readonly int _maxCharaDataByUserVanity;
private readonly ChatChannelService _chatChannelService;
private CancellationToken RequestAbortedToken => _contextAccessor.HttpContext?.RequestAborted ?? Context?.ConnectionAborted ?? CancellationToken.None;
public LightlessHub(LightlessMetrics lightlessMetrics,
IDbContextFactory<LightlessDbContext> lightlessDbContextFactory, ILogger<LightlessHub> logger, SystemInfoService systemInfoService,
IConfigurationService<ServerConfiguration> configuration, IHttpContextAccessor contextAccessor,
IRedisDatabase redisDb, OnlineSyncedPairCacheService onlineSyncedPairCacheService, LightlessCensus lightlessCensus,
GPoseLobbyDistributionService gPoseLobbyDistributionService)
GPoseLobbyDistributionService gPoseLobbyDistributionService, IBroadcastConfiguration broadcastConfiguration, PairService pairService,
ChatChannelService chatChannelService, IPruneService pruneService)
{
_lightlessMetrics = lightlessMetrics;
_systemInfoService = systemInfoService;
@@ -64,6 +75,10 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
_gPoseLobbyDistributionService = gPoseLobbyDistributionService;
_logger = new LightlessHubLogger(this, logger);
_dbContextLazy = new Lazy<LightlessDbContext>(() => lightlessDbContextFactory.CreateDbContext());
_broadcastConfiguration = broadcastConfiguration;
_pairService = pairService;
_chatChannelService = chatChannelService;
_pruneService = pruneService;
}
protected override void Dispose(bool disposing)
@@ -109,6 +124,9 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
ServerVersion = ILightlessHub.ApiVersion,
IsAdmin = dbUser.IsAdmin,
IsModerator = dbUser.IsModerator,
HasVanity = dbUser.HasVanity,
TextColorHex = dbUser.TextColorHex,
TextGlowColorHex = dbUser.TextGlowColorHex,
ServerInfo = new ServerInfo()
{
MaxGroupsCreatedByUser = _maxExistingGroupsByUser,
@@ -150,8 +168,13 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
}
else
{
_lightlessMetrics.IncGaugeWithLabels(MetricsAPI.GaugeConnections, labels: Continent);
var ResultLabels = new List<string>
{
Continent,
Country,
};
_lightlessMetrics.IncGaugeWithLabels(MetricsAPI.GaugeConnections, labels: [.. ResultLabels]);
try
{
_logger.LogCallInfo(LightlessHubLogger.Args(_contextAccessor.GetIpAddress(), Context.ConnectionId, UserCharaIdent));
@@ -174,7 +197,12 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
if (_userConnections.TryGetValue(UserUID, out var connectionId)
&& string.Equals(connectionId, Context.ConnectionId, StringComparison.Ordinal))
{
_lightlessMetrics.DecGaugeWithLabels(MetricsAPI.GaugeConnections, labels: Continent);
var ResultLabels = new List<string>
{
Continent,
Country,
};
_lightlessMetrics.DecGaugeWithLabels(MetricsAPI.GaugeConnections, labels: [.. ResultLabels]);
try
{
@@ -186,9 +214,13 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
if (exception != null)
_logger.LogCallWarning(LightlessHubLogger.Args(_contextAccessor.GetIpAddress(), Context.ConnectionId, exception.Message, exception.StackTrace));
await ClearOwnedBroadcastLock().ConfigureAwait(false);
await RemoveUserFromRedis().ConfigureAwait(false);
_lightlessCensus.ClearStatistics(UserUID);
await UpdateLocation(new LocationDto(new UserData(UserUID), new LocationInfo()), offline: true).ConfigureAwait(false);
await SendOfflineToAllPairedUsers().ConfigureAwait(false);
@@ -199,6 +231,7 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
catch { }
finally
{
TryInvokeChatService(() => _chatChannelService.RemovePresence(UserUID), "removing chat presence on disconnect");
_userConnections.Remove(UserUID, out _);
}
}

View File

@@ -1,672 +0,0 @@
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.Group;
using LightlessSyncServer.Utils;
using LightlessSyncShared.Models;
using LightlessSyncShared.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
namespace LightlessSyncServer.Hubs;
public partial class LightlessHub
{
[Authorize(Policy = "Identified")]
public async Task GroupBanUser(GroupPairDto dto, string reason)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto, reason));
var (userHasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (!userHasRights) return;
var (userExists, groupPair) = await TryValidateUserInGroup(dto.Group.GID, dto.User.UID).ConfigureAwait(false);
if (!userExists) return;
if (groupPair.IsModerator || string.Equals(group.OwnerUID, dto.User.UID, StringComparison.Ordinal)) return;
var alias = string.IsNullOrEmpty(groupPair.GroupUser.Alias) ? "-" : groupPair.GroupUser.Alias;
var ban = new GroupBan()
{
BannedByUID = UserUID,
BannedReason = $"{reason} (Alias at time of ban: {alias})",
BannedOn = DateTime.UtcNow,
BannedUserUID = dto.User.UID,
GroupGID = dto.Group.GID,
};
DbContext.Add(ban);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
await GroupRemoveUser(dto).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
}
[Authorize(Policy = "Identified")]
public async Task GroupChangeGroupPermissionState(GroupPermissionDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (!hasRights) return;
group.InvitesEnabled = !dto.Permissions.HasFlag(GroupPermissions.DisableInvites);
group.PreferDisableSounds = dto.Permissions.HasFlag(GroupPermissions.PreferDisableSounds);
group.PreferDisableAnimations = dto.Permissions.HasFlag(GroupPermissions.PreferDisableAnimations);
group.PreferDisableVFX = dto.Permissions.HasFlag(GroupPermissions.PreferDisableVFX);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
var groupPairs = DbContext.GroupPairs.Where(p => p.GroupGID == dto.Group.GID).Select(p => p.GroupUserUID).ToList();
await Clients.Users(groupPairs).Client_GroupChangePermissions(new GroupPermissionDto(dto.Group, dto.Permissions)).ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task GroupChangeOwnership(GroupPairDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (isOwner, group) = await TryValidateOwner(dto.Group.GID).ConfigureAwait(false);
if (!isOwner) return;
var (isInGroup, newOwnerPair) = await TryValidateUserInGroup(dto.Group.GID, dto.User.UID).ConfigureAwait(false);
if (!isInGroup) return;
var ownedShells = await DbContext.Groups.CountAsync(g => g.OwnerUID == dto.User.UID).ConfigureAwait(false);
if (ownedShells >= _maxExistingGroupsByUser) return;
var prevOwner = await DbContext.GroupPairs.SingleOrDefaultAsync(g => g.GroupGID == dto.Group.GID && g.GroupUserUID == UserUID).ConfigureAwait(false);
prevOwner.IsPinned = false;
group.Owner = newOwnerPair.GroupUser;
group.Alias = null;
newOwnerPair.IsPinned = true;
newOwnerPair.IsModerator = false;
await DbContext.SaveChangesAsync().ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
var groupPairs = await DbContext.GroupPairs.Where(p => p.GroupGID == dto.Group.GID).Select(p => p.GroupUserUID).AsNoTracking().ToListAsync().ConfigureAwait(false);
await Clients.Users(groupPairs).Client_GroupSendInfo(new GroupInfoDto(group.ToGroupData(), newOwnerPair.GroupUser.ToUserData(), group.ToEnum())).ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task<bool> GroupChangePassword(GroupPasswordDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (isOwner, group) = await TryValidateOwner(dto.Group.GID).ConfigureAwait(false);
if (!isOwner || dto.Password.Length < 10) return false;
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
group.HashedPassword = StringUtils.Sha256String(dto.Password);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
return true;
}
[Authorize(Policy = "Identified")]
public async Task GroupClear(GroupDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (!hasRights) return;
var groupPairs = await DbContext.GroupPairs.Include(p => p.GroupUser).Where(p => p.GroupGID == dto.Group.GID).ToListAsync().ConfigureAwait(false);
var notPinned = groupPairs.Where(g => !g.IsPinned && !g.IsModerator).ToList();
await Clients.Users(notPinned.Select(g => g.GroupUserUID)).Client_GroupDelete(new GroupDto(group.ToGroupData())).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
DbContext.GroupPairs.RemoveRange(notPinned);
foreach (var pair in notPinned)
{
await Clients.Users(groupPairs.Where(p => p.IsPinned || p.IsModerator).Select(g => g.GroupUserUID))
.Client_GroupPairLeft(new GroupPairDto(dto.Group, pair.GroupUser.ToUserData())).ConfigureAwait(false);
var pairIdent = await GetUserIdent(pair.GroupUserUID).ConfigureAwait(false);
if (string.IsNullOrEmpty(pairIdent)) continue;
var allUserPairs = await GetAllPairInfo(pair.GroupUserUID).ConfigureAwait(false);
var sharedData = await DbContext.CharaDataAllowances.Where(u => u.AllowedGroup != null && u.AllowedGroupGID == dto.GID && u.ParentUploaderUID == pair.GroupUserUID).ToListAsync().ConfigureAwait(false);
DbContext.CharaDataAllowances.RemoveRange(sharedData);
foreach (var groupUserPair in groupPairs.Where(p => !string.Equals(p.GroupUserUID, pair.GroupUserUID, StringComparison.Ordinal)))
{
await UserGroupLeave(pair, pairIdent, allUserPairs, pair.GroupUserUID).ConfigureAwait(false);
}
}
await DbContext.SaveChangesAsync().ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task<GroupJoinDto> GroupCreate()
{
_logger.LogCallInfo();
var existingGroupsByUser = await DbContext.Groups.CountAsync(u => u.OwnerUID == UserUID).ConfigureAwait(false);
var existingJoinedGroups = await DbContext.GroupPairs.CountAsync(u => u.GroupUserUID == UserUID).ConfigureAwait(false);
if (existingGroupsByUser >= _maxExistingGroupsByUser || existingJoinedGroups >= _maxJoinedGroupsByUser)
{
throw new System.Exception($"Max groups for user is {_maxExistingGroupsByUser}, max joined groups is {_maxJoinedGroupsByUser}.");
}
var gid = StringUtils.GenerateRandomString(12);
while (await DbContext.Groups.AnyAsync(g => g.GID == "MSS-" + gid).ConfigureAwait(false))
{
gid = StringUtils.GenerateRandomString(12);
}
gid = "MSS-" + gid;
var passwd = StringUtils.GenerateRandomString(16);
using var sha = SHA256.Create();
var hashedPw = StringUtils.Sha256String(passwd);
UserDefaultPreferredPermission defaultPermissions = await DbContext.UserDefaultPreferredPermissions.SingleAsync(u => u.UserUID == UserUID).ConfigureAwait(false);
Group newGroup = new()
{
GID = gid,
HashedPassword = hashedPw,
InvitesEnabled = true,
OwnerUID = UserUID,
PreferDisableAnimations = defaultPermissions.DisableGroupAnimations,
PreferDisableSounds = defaultPermissions.DisableGroupSounds,
PreferDisableVFX = defaultPermissions.DisableGroupVFX
};
GroupPair initialPair = new()
{
GroupGID = newGroup.GID,
GroupUserUID = UserUID,
IsPinned = true,
};
GroupPairPreferredPermission initialPrefPermissions = new()
{
UserUID = UserUID,
GroupGID = newGroup.GID,
DisableSounds = defaultPermissions.DisableGroupSounds,
DisableAnimations = defaultPermissions.DisableGroupAnimations,
DisableVFX = defaultPermissions.DisableGroupAnimations
};
await DbContext.Groups.AddAsync(newGroup).ConfigureAwait(false);
await DbContext.GroupPairs.AddAsync(initialPair).ConfigureAwait(false);
await DbContext.GroupPairPreferredPermissions.AddAsync(initialPrefPermissions).ConfigureAwait(false);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
var self = await DbContext.Users.SingleAsync(u => u.UID == UserUID).ConfigureAwait(false);
await Clients.User(UserUID).Client_GroupSendFullInfo(new GroupFullInfoDto(newGroup.ToGroupData(), self.ToUserData(),
newGroup.ToEnum(), initialPrefPermissions.ToEnum(), initialPair.ToEnum(), new(StringComparer.Ordinal)))
.ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(gid));
return new GroupJoinDto(newGroup.ToGroupData(), passwd, initialPrefPermissions.ToEnum());
}
[Authorize(Policy = "Identified")]
public async Task<List<string>> GroupCreateTempInvite(GroupDto dto, int amount)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto, amount));
List<string> inviteCodes = new();
List<GroupTempInvite> tempInvites = new();
var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (!hasRights) return new();
var existingInvites = await DbContext.GroupTempInvites.Where(g => g.GroupGID == group.GID).ToListAsync().ConfigureAwait(false);
for (int i = 0; i < amount; i++)
{
bool hasValidInvite = false;
string invite = string.Empty;
string hashedInvite = string.Empty;
while (!hasValidInvite)
{
invite = StringUtils.GenerateRandomString(10, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789");
hashedInvite = StringUtils.Sha256String(invite);
if (existingInvites.Any(i => string.Equals(i.Invite, hashedInvite, StringComparison.Ordinal))) continue;
hasValidInvite = true;
inviteCodes.Add(invite);
}
tempInvites.Add(new GroupTempInvite()
{
ExpirationDate = DateTime.UtcNow.AddDays(1),
GroupGID = group.GID,
Invite = hashedInvite,
});
}
DbContext.GroupTempInvites.AddRange(tempInvites);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
return inviteCodes;
}
[Authorize(Policy = "Identified")]
public async Task GroupDelete(GroupDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (hasRights, group) = await TryValidateOwner(dto.Group.GID).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
var groupPairs = await DbContext.GroupPairs.Where(p => p.GroupGID == dto.Group.GID).ToListAsync().ConfigureAwait(false);
DbContext.RemoveRange(groupPairs);
DbContext.Remove(group);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
await Clients.Users(groupPairs.Select(g => g.GroupUserUID)).Client_GroupDelete(new GroupDto(group.ToGroupData())).ConfigureAwait(false);
await SendGroupDeletedToAll(groupPairs).ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task<List<BannedGroupUserDto>> GroupGetBannedUsers(GroupDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (userHasRights, group) = await TryValidateGroupModeratorOrOwner(dto.GID).ConfigureAwait(false);
if (!userHasRights) return new List<BannedGroupUserDto>();
var banEntries = await DbContext.GroupBans.Include(b => b.BannedUser).Where(g => g.GroupGID == dto.Group.GID).AsNoTracking().ToListAsync().ConfigureAwait(false);
List<BannedGroupUserDto> bannedGroupUsers = banEntries.Select(b =>
new BannedGroupUserDto(group.ToGroupData(), b.BannedUser.ToUserData(), b.BannedReason, b.BannedOn,
b.BannedByUID)).ToList();
_logger.LogCallInfo(LightlessHubLogger.Args(dto, bannedGroupUsers.Count));
return bannedGroupUsers;
}
[Authorize(Policy = "Identified")]
public async Task<GroupJoinInfoDto> GroupJoin(GroupPasswordDto dto)
{
var aliasOrGid = dto.Group.GID.Trim();
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var group = await DbContext.Groups.Include(g => g.Owner).AsNoTracking().SingleOrDefaultAsync(g => g.GID == aliasOrGid || g.Alias == aliasOrGid).ConfigureAwait(false);
var groupGid = group?.GID ?? string.Empty;
var existingPair = await DbContext.GroupPairs.AsNoTracking().SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.GroupUserUID == UserUID).ConfigureAwait(false);
var hashedPw = StringUtils.Sha256String(dto.Password);
var existingUserCount = await DbContext.GroupPairs.AsNoTracking().CountAsync(g => g.GroupGID == groupGid).ConfigureAwait(false);
var joinedGroups = await DbContext.GroupPairs.CountAsync(g => g.GroupUserUID == UserUID).ConfigureAwait(false);
var isBanned = await DbContext.GroupBans.AnyAsync(g => g.GroupGID == groupGid && g.BannedUserUID == UserUID).ConfigureAwait(false);
var oneTimeInvite = await DbContext.GroupTempInvites.SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.Invite == hashedPw).ConfigureAwait(false);
if (group == null
|| (!string.Equals(group.HashedPassword, hashedPw, StringComparison.Ordinal) && oneTimeInvite == null)
|| existingPair != null
|| existingUserCount >= _maxGroupUserCount
|| !group.InvitesEnabled
|| joinedGroups >= _maxJoinedGroupsByUser
|| isBanned)
return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false);
return new GroupJoinInfoDto(group.ToGroupData(), group.Owner.ToUserData(), group.ToEnum(), true);
}
[Authorize(Policy = "Identified")]
public async Task<bool> GroupJoinFinalize(GroupJoinDto dto)
{
var aliasOrGid = dto.Group.GID.Trim();
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var group = await DbContext.Groups.Include(g => g.Owner).AsNoTracking().SingleOrDefaultAsync(g => g.GID == aliasOrGid || g.Alias == aliasOrGid).ConfigureAwait(false);
var groupGid = group?.GID ?? string.Empty;
var existingPair = await DbContext.GroupPairs.AsNoTracking().SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.GroupUserUID == UserUID).ConfigureAwait(false);
var hashedPw = StringUtils.Sha256String(dto.Password);
var existingUserCount = await DbContext.GroupPairs.AsNoTracking().CountAsync(g => g.GroupGID == groupGid).ConfigureAwait(false);
var joinedGroups = await DbContext.GroupPairs.CountAsync(g => g.GroupUserUID == UserUID).ConfigureAwait(false);
var isBanned = await DbContext.GroupBans.AnyAsync(g => g.GroupGID == groupGid && g.BannedUserUID == UserUID).ConfigureAwait(false);
var oneTimeInvite = await DbContext.GroupTempInvites.SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.Invite == hashedPw).ConfigureAwait(false);
if (group == null
|| (!string.Equals(group.HashedPassword, hashedPw, StringComparison.Ordinal) && oneTimeInvite == null)
|| existingPair != null
|| existingUserCount >= _maxGroupUserCount
|| !group.InvitesEnabled
|| joinedGroups >= _maxJoinedGroupsByUser
|| isBanned)
return false;
// get all pairs before we join
var allUserPairs = (await GetAllPairInfo(UserUID).ConfigureAwait(false));
if (oneTimeInvite != null)
{
_logger.LogCallInfo(LightlessHubLogger.Args(aliasOrGid, "TempInvite", oneTimeInvite.Invite));
DbContext.Remove(oneTimeInvite);
}
GroupPair newPair = new()
{
GroupGID = group.GID,
GroupUserUID = UserUID,
};
var preferredPermissions = await DbContext.GroupPairPreferredPermissions.SingleOrDefaultAsync(u => u.UserUID == UserUID && u.GroupGID == group.GID).ConfigureAwait(false);
if (preferredPermissions == null)
{
GroupPairPreferredPermission newPerms = new()
{
GroupGID = group.GID,
UserUID = UserUID,
DisableSounds = dto.GroupUserPreferredPermissions.IsDisableSounds(),
DisableVFX = dto.GroupUserPreferredPermissions.IsDisableVFX(),
DisableAnimations = dto.GroupUserPreferredPermissions.IsDisableAnimations(),
IsPaused = false
};
DbContext.Add(newPerms);
preferredPermissions = newPerms;
}
else
{
preferredPermissions.DisableSounds = dto.GroupUserPreferredPermissions.IsDisableSounds();
preferredPermissions.DisableVFX = dto.GroupUserPreferredPermissions.IsDisableVFX();
preferredPermissions.DisableAnimations = dto.GroupUserPreferredPermissions.IsDisableAnimations();
preferredPermissions.IsPaused = false;
DbContext.Update(preferredPermissions);
}
await DbContext.GroupPairs.AddAsync(newPair).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(aliasOrGid, "Success"));
await DbContext.SaveChangesAsync().ConfigureAwait(false);
var groupInfos = await DbContext.GroupPairs.Where(u => u.GroupGID == group.GID && (u.IsPinned || u.IsModerator)).ToListAsync().ConfigureAwait(false);
await Clients.User(UserUID).Client_GroupSendFullInfo(new GroupFullInfoDto(group.ToGroupData(), group.Owner.ToUserData(),
group.ToEnum(), preferredPermissions.ToEnum(), newPair.ToEnum(),
groupInfos.ToDictionary(u => u.GroupUserUID, u => u.ToEnum(), StringComparer.Ordinal))).ConfigureAwait(false);
var self = DbContext.Users.Single(u => u.UID == UserUID);
var groupPairs = await DbContext.GroupPairs.Include(p => p.GroupUser)
.Where(p => p.GroupGID == group.GID && p.GroupUserUID != UserUID).ToListAsync().ConfigureAwait(false);
var userPairsAfterJoin = await GetAllPairInfo(UserUID).ConfigureAwait(false);
foreach (var pair in groupPairs)
{
var perms = userPairsAfterJoin.TryGetValue(pair.GroupUserUID, out var userinfo);
// check if we have had prior permissions to that pair, if not add them
var ownPermissionsToOther = userinfo?.OwnPermissions ?? null;
if (ownPermissionsToOther == null)
{
var existingPermissionsOnDb = await DbContext.Permissions.SingleOrDefaultAsync(p => p.UserUID == UserUID && p.OtherUserUID == pair.GroupUserUID).ConfigureAwait(false);
if (existingPermissionsOnDb == null)
{
ownPermissionsToOther = new()
{
UserUID = UserUID,
OtherUserUID = pair.GroupUserUID,
DisableAnimations = preferredPermissions.DisableAnimations,
DisableSounds = preferredPermissions.DisableSounds,
DisableVFX = preferredPermissions.DisableVFX,
IsPaused = preferredPermissions.IsPaused,
Sticky = false
};
await DbContext.Permissions.AddAsync(ownPermissionsToOther).ConfigureAwait(false);
}
else
{
existingPermissionsOnDb.DisableAnimations = preferredPermissions.DisableAnimations;
existingPermissionsOnDb.DisableSounds = preferredPermissions.DisableSounds;
existingPermissionsOnDb.DisableVFX = preferredPermissions.DisableVFX;
existingPermissionsOnDb.IsPaused = false;
existingPermissionsOnDb.Sticky = false;
DbContext.Update(existingPermissionsOnDb);
ownPermissionsToOther = existingPermissionsOnDb;
}
}
else if (!ownPermissionsToOther.Sticky)
{
ownPermissionsToOther = await DbContext.Permissions.SingleAsync(u => u.UserUID == UserUID && u.OtherUserUID == pair.GroupUserUID).ConfigureAwait(false);
// update the existing permission only if it was not set to sticky
ownPermissionsToOther.DisableAnimations = preferredPermissions.DisableAnimations;
ownPermissionsToOther.DisableVFX = preferredPermissions.DisableVFX;
ownPermissionsToOther.DisableSounds = preferredPermissions.DisableSounds;
ownPermissionsToOther.IsPaused = false;
DbContext.Update(ownPermissionsToOther);
}
// get others permissionset to self and eventually update it
var otherPermissionToSelf = userinfo?.OtherPermissions ?? null;
if (otherPermissionToSelf == null)
{
var otherExistingPermsOnDb = await DbContext.Permissions.SingleOrDefaultAsync(p => p.UserUID == pair.GroupUserUID && p.OtherUserUID == UserUID).ConfigureAwait(false);
if (otherExistingPermsOnDb == null)
{
var otherPreferred = await DbContext.GroupPairPreferredPermissions.SingleAsync(u => u.GroupGID == group.GID && u.UserUID == pair.GroupUserUID).ConfigureAwait(false);
otherExistingPermsOnDb = new()
{
UserUID = pair.GroupUserUID,
OtherUserUID = UserUID,
DisableAnimations = otherPreferred.DisableAnimations,
DisableSounds = otherPreferred.DisableSounds,
DisableVFX = otherPreferred.DisableVFX,
IsPaused = otherPreferred.IsPaused,
Sticky = false
};
await DbContext.AddAsync(otherExistingPermsOnDb).ConfigureAwait(false);
}
else if (!otherExistingPermsOnDb.Sticky)
{
var otherPreferred = await DbContext.GroupPairPreferredPermissions.SingleAsync(u => u.GroupGID == group.GID && u.UserUID == pair.GroupUserUID).ConfigureAwait(false);
otherExistingPermsOnDb.DisableAnimations = otherPreferred.DisableAnimations;
otherExistingPermsOnDb.DisableSounds = otherPreferred.DisableSounds;
otherExistingPermsOnDb.DisableVFX = otherPreferred.DisableVFX;
otherExistingPermsOnDb.IsPaused = otherPreferred.IsPaused;
DbContext.Update(otherExistingPermsOnDb);
}
otherPermissionToSelf = otherExistingPermsOnDb;
}
else if (!otherPermissionToSelf.Sticky)
{
var otherPreferred = await DbContext.GroupPairPreferredPermissions.SingleAsync(u => u.GroupGID == group.GID && u.UserUID == pair.GroupUserUID).ConfigureAwait(false);
otherPermissionToSelf.DisableAnimations = otherPreferred.DisableAnimations;
otherPermissionToSelf.DisableSounds = otherPreferred.DisableSounds;
otherPermissionToSelf.DisableVFX = otherPreferred.DisableVFX;
otherPermissionToSelf.IsPaused = otherPreferred.IsPaused;
DbContext.Update(otherPermissionToSelf);
}
await Clients.User(UserUID).Client_GroupPairJoined(new GroupPairFullInfoDto(group.ToGroupData(),
pair.ToUserData(), ownPermissionsToOther.ToUserPermissions(setSticky: ownPermissionsToOther.Sticky),
otherPermissionToSelf.ToUserPermissions(setSticky: false))).ConfigureAwait(false);
await Clients.User(pair.GroupUserUID).Client_GroupPairJoined(new GroupPairFullInfoDto(group.ToGroupData(),
self.ToUserData(), otherPermissionToSelf.ToUserPermissions(setSticky: otherPermissionToSelf.Sticky),
ownPermissionsToOther.ToUserPermissions(setSticky: false))).ConfigureAwait(false);
// if not paired prior and neither has the permissions set to paused, send online
if ((!allUserPairs.ContainsKey(pair.GroupUserUID) || (allUserPairs.TryGetValue(pair.GroupUserUID, out var info) && !info.IsSynced))
&& !otherPermissionToSelf.IsPaused && !ownPermissionsToOther.IsPaused)
{
var groupUserIdent = await GetUserIdent(pair.GroupUserUID).ConfigureAwait(false);
if (!string.IsNullOrEmpty(groupUserIdent))
{
await Clients.User(UserUID).Client_UserSendOnline(new(pair.ToUserData(), groupUserIdent)).ConfigureAwait(false);
await Clients.User(pair.GroupUserUID).Client_UserSendOnline(new(self.ToUserData(), UserCharaIdent)).ConfigureAwait(false);
}
}
}
await DbContext.SaveChangesAsync().ConfigureAwait(false);
return true;
}
[Authorize(Policy = "Identified")]
public async Task GroupLeave(GroupDto dto)
{
await UserLeaveGroup(dto, UserUID).ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task<int> GroupPrune(GroupDto dto, int days, bool execute)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto, days, execute));
var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (!hasRights) return -1;
var allGroupUsers = await DbContext.GroupPairs.Include(p => p.GroupUser).Include(p => p.Group)
.Where(g => g.GroupGID == dto.Group.GID)
.ToListAsync().ConfigureAwait(false);
var usersToPrune = allGroupUsers.Where(p => !p.IsPinned && !p.IsModerator
&& p.GroupUserUID != UserUID
&& p.Group.OwnerUID != p.GroupUserUID
&& p.GroupUser.LastLoggedIn.AddDays(days) < DateTime.UtcNow);
if (!execute) return usersToPrune.Count();
DbContext.GroupPairs.RemoveRange(usersToPrune);
foreach (var pair in usersToPrune)
{
await Clients.Users(allGroupUsers.Where(p => !usersToPrune.Contains(p)).Select(g => g.GroupUserUID))
.Client_GroupPairLeft(new GroupPairDto(dto.Group, pair.GroupUser.ToUserData())).ConfigureAwait(false);
}
await DbContext.SaveChangesAsync().ConfigureAwait(false);
return usersToPrune.Count();
}
[Authorize(Policy = "Identified")]
public async Task GroupRemoveUser(GroupPairDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (!hasRights) return;
var (userExists, groupPair) = await TryValidateUserInGroup(dto.Group.GID, dto.User.UID).ConfigureAwait(false);
if (!userExists) return;
if (groupPair.IsModerator || string.Equals(group.OwnerUID, dto.User.UID, StringComparison.Ordinal)) return;
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
DbContext.GroupPairs.Remove(groupPair);
var groupPairs = DbContext.GroupPairs.Where(p => p.GroupGID == group.GID).AsNoTracking().ToList();
await Clients.Users(groupPairs.Select(p => p.GroupUserUID)).Client_GroupPairLeft(dto).ConfigureAwait(false);
var sharedData = await DbContext.CharaDataAllowances.Where(u => u.AllowedGroup != null && u.AllowedGroupGID == dto.GID && u.ParentUploaderUID == dto.UID).ToListAsync().ConfigureAwait(false);
DbContext.CharaDataAllowances.RemoveRange(sharedData);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
var userIdent = await GetUserIdent(dto.User.UID).ConfigureAwait(false);
if (userIdent == null)
{
await DbContext.SaveChangesAsync().ConfigureAwait(false);
return;
}
await Clients.User(dto.User.UID).Client_GroupDelete(new GroupDto(dto.Group)).ConfigureAwait(false);
var userPairs = await GetAllPairInfo(dto.User.UID).ConfigureAwait(false);
foreach (var groupUserPair in groupPairs)
{
await UserGroupLeave(groupUserPair, userIdent, userPairs, dto.User.UID).ConfigureAwait(false);
}
}
[Authorize(Policy = "Identified")]
public async Task GroupSetUserInfo(GroupPairUserInfoDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (userExists, userPair) = await TryValidateUserInGroup(dto.Group.GID, dto.User.UID).ConfigureAwait(false);
if (!userExists) return;
var (userIsOwner, _) = await TryValidateOwner(dto.Group.GID).ConfigureAwait(false);
var (userIsModerator, _) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (dto.GroupUserInfo.HasFlag(GroupPairUserInfo.IsPinned) && userIsModerator && !userPair.IsPinned)
{
userPair.IsPinned = true;
}
else if (userIsModerator && userPair.IsPinned)
{
userPair.IsPinned = false;
}
if (dto.GroupUserInfo.HasFlag(GroupPairUserInfo.IsModerator) && userIsOwner && !userPair.IsModerator)
{
userPair.IsModerator = true;
}
else if (userIsOwner && userPair.IsModerator)
{
userPair.IsModerator = false;
}
await DbContext.SaveChangesAsync().ConfigureAwait(false);
var groupPairs = await DbContext.GroupPairs.AsNoTracking().Where(p => p.GroupGID == dto.Group.GID).Select(p => p.GroupUserUID).ToListAsync().ConfigureAwait(false);
await Clients.Users(groupPairs).Client_GroupPairChangeUserInfo(new GroupPairUserInfoDto(dto.Group, dto.User, userPair.ToEnum())).ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task<List<GroupFullInfoDto>> GroupsGetAll()
{
_logger.LogCallInfo();
var groups = await DbContext.GroupPairs.Include(g => g.Group).Include(g => g.Group.Owner).Where(g => g.GroupUserUID == UserUID).AsNoTracking().ToListAsync().ConfigureAwait(false);
var preferredPermissions = (await DbContext.GroupPairPreferredPermissions.Where(u => u.UserUID == UserUID).ToListAsync().ConfigureAwait(false))
.Where(u => groups.Exists(k => string.Equals(k.GroupGID, u.GroupGID, StringComparison.Ordinal)))
.ToDictionary(u => groups.First(f => string.Equals(f.GroupGID, u.GroupGID, StringComparison.Ordinal)), u => u);
var groupInfos = await DbContext.GroupPairs.Where(u => groups.Select(g => g.GroupGID).Contains(u.GroupGID) && (u.IsPinned || u.IsModerator))
.ToListAsync().ConfigureAwait(false);
return preferredPermissions.Select(g => new GroupFullInfoDto(g.Key.Group.ToGroupData(), g.Key.Group.Owner.ToUserData(),
g.Key.Group.ToEnum(), g.Value.ToEnum(), g.Key.ToEnum(),
groupInfos.Where(i => string.Equals(i.GroupGID, g.Key.GroupGID, StringComparison.Ordinal))
.ToDictionary(i => i.GroupUserUID, i => i.ToEnum(), StringComparer.Ordinal))).ToList();
}
[Authorize(Policy = "Identified")]
public async Task GroupUnbanUser(GroupPairDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (userHasRights, _) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (!userHasRights) return;
var banEntry = await DbContext.GroupBans.SingleOrDefaultAsync(g => g.GroupGID == dto.Group.GID && g.BannedUserUID == dto.User.UID).ConfigureAwait(false);
if (banEntry == null) return;
DbContext.Remove(banEntry);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
}
}

View File

@@ -1,437 +0,0 @@
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.User;
using LightlessSyncServer.Utils;
using LightlessSyncShared.Metrics;
using LightlessSyncShared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace LightlessSyncServer.Hubs;
public partial class LightlessHub
{
private static readonly string[] AllowedExtensionsForGamePaths = { ".mdl", ".tex", ".mtrl", ".tmb", ".pap", ".avfx", ".atex", ".sklb", ".eid", ".phyb", ".pbd", ".scd", ".skp", ".shpk" };
[Authorize(Policy = "Identified")]
public async Task UserAddPair(UserDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
// don't allow adding nothing
var uid = dto.User.UID.Trim();
if (string.Equals(dto.User.UID, UserUID, StringComparison.Ordinal) || string.IsNullOrWhiteSpace(dto.User.UID)) return;
// grab other user, check if it exists and if a pair already exists
var otherUser = await DbContext.Users.SingleOrDefaultAsync(u => u.UID == uid || u.Alias == uid).ConfigureAwait(false);
if (otherUser == null)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, $"Cannot pair with {dto.User.UID}, UID does not exist").ConfigureAwait(false);
return;
}
if (string.Equals(otherUser.UID, UserUID, StringComparison.Ordinal))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, $"My god you can't pair with yourself why would you do that please stop").ConfigureAwait(false);
return;
}
var existingEntry =
await DbContext.ClientPairs.AsNoTracking()
.FirstOrDefaultAsync(p =>
p.User.UID == UserUID && p.OtherUserUID == otherUser.UID).ConfigureAwait(false);
if (existingEntry != null)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, $"Cannot pair with {dto.User.UID}, already paired").ConfigureAwait(false);
return;
}
// grab self create new client pair and save
var user = await DbContext.Users.SingleAsync(u => u.UID == UserUID).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
ClientPair wl = new ClientPair()
{
OtherUser = otherUser,
User = user,
};
await DbContext.ClientPairs.AddAsync(wl).ConfigureAwait(false);
var existingData = await GetPairInfo(UserUID, otherUser.UID).ConfigureAwait(false);
var permissions = existingData?.OwnPermissions;
if (permissions == null || !permissions.Sticky)
{
var ownDefaultPermissions = await DbContext.UserDefaultPreferredPermissions.AsNoTracking().SingleOrDefaultAsync(f => f.UserUID == UserUID).ConfigureAwait(false);
permissions = new UserPermissionSet()
{
User = user,
OtherUser = otherUser,
DisableAnimations = ownDefaultPermissions.DisableIndividualAnimations,
DisableSounds = ownDefaultPermissions.DisableIndividualSounds,
DisableVFX = ownDefaultPermissions.DisableIndividualVFX,
IsPaused = false,
Sticky = true
};
var existingDbPerms = await DbContext.Permissions.SingleOrDefaultAsync(u => u.UserUID == UserUID && u.OtherUserUID == otherUser.UID).ConfigureAwait(false);
if (existingDbPerms == null)
{
await DbContext.Permissions.AddAsync(permissions).ConfigureAwait(false);
}
else
{
existingDbPerms.DisableAnimations = permissions.DisableAnimations;
existingDbPerms.DisableSounds = permissions.DisableSounds;
existingDbPerms.DisableVFX = permissions.DisableVFX;
existingDbPerms.IsPaused = false;
existingDbPerms.Sticky = true;
DbContext.Permissions.Update(existingDbPerms);
}
}
await DbContext.SaveChangesAsync().ConfigureAwait(false);
// get the opposite entry of the client pair
var otherEntry = OppositeEntry(otherUser.UID);
var otherIdent = await GetUserIdent(otherUser.UID).ConfigureAwait(false);
var otherPermissions = existingData?.OtherPermissions ?? null;
var ownPerm = permissions.ToUserPermissions(setSticky: true);
var otherPerm = otherPermissions.ToUserPermissions();
var userPairResponse = new UserPairDto(otherUser.ToUserData(),
otherEntry == null ? IndividualPairStatus.OneSided : IndividualPairStatus.Bidirectional,
ownPerm, otherPerm);
await Clients.User(user.UID).Client_UserAddClientPair(userPairResponse).ConfigureAwait(false);
// check if other user is online
if (otherIdent == null || otherEntry == null) return;
// send push with update to other user if other user is online
await Clients.User(otherUser.UID)
.Client_UserUpdateOtherPairPermissions(new UserPermissionsDto(user.ToUserData(),
permissions.ToUserPermissions())).ConfigureAwait(false);
await Clients.User(otherUser.UID)
.Client_UpdateUserIndividualPairStatusDto(new(user.ToUserData(), IndividualPairStatus.Bidirectional))
.ConfigureAwait(false);
if (!ownPerm.IsPaused() && !otherPerm.IsPaused())
{
await Clients.User(UserUID).Client_UserSendOnline(new(otherUser.ToUserData(), otherIdent)).ConfigureAwait(false);
await Clients.User(otherUser.UID).Client_UserSendOnline(new(user.ToUserData(), UserCharaIdent)).ConfigureAwait(false);
}
}
[Authorize(Policy = "Identified")]
public async Task UserDelete()
{
_logger.LogCallInfo();
var userEntry = await DbContext.Users.SingleAsync(u => u.UID == UserUID).ConfigureAwait(false);
var secondaryUsers = await DbContext.Auth.Include(u => u.User).Where(u => u.PrimaryUserUID == UserUID).Select(c => c.User).ToListAsync().ConfigureAwait(false);
foreach (var user in secondaryUsers)
{
await DeleteUser(user).ConfigureAwait(false);
}
await DeleteUser(userEntry).ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task<List<OnlineUserIdentDto>> UserGetOnlinePairs(CensusDataDto? censusData)
{
_logger.LogCallInfo();
var allPairedUsers = await GetAllPairedUnpausedUsers().ConfigureAwait(false);
var pairs = await GetOnlineUsers(allPairedUsers).ConfigureAwait(false);
await SendOnlineToAllPairedUsers().ConfigureAwait(false);
_lightlessCensus.PublishStatistics(UserUID, censusData);
return pairs.Select(p => new OnlineUserIdentDto(new UserData(p.Key), p.Value)).ToList();
}
[Authorize(Policy = "Identified")]
public async Task<List<UserFullPairDto>> UserGetPairedClients()
{
_logger.LogCallInfo();
var pairs = await GetAllPairInfo(UserUID).ConfigureAwait(false);
return pairs.Select(p =>
{
return new UserFullPairDto(new UserData(p.Key, p.Value.Alias),
p.Value.ToIndividualPairStatus(),
p.Value.GIDs.Where(g => !string.Equals(g, Constants.IndividualKeyword, StringComparison.OrdinalIgnoreCase)).ToList(),
p.Value.OwnPermissions.ToUserPermissions(setSticky: true),
p.Value.OtherPermissions.ToUserPermissions());
}).ToList();
}
[Authorize(Policy = "Identified")]
public async Task<UserProfileDto> UserGetProfile(UserDto user)
{
_logger.LogCallInfo(LightlessHubLogger.Args(user));
var allUserPairs = await GetAllPairedUnpausedUsers().ConfigureAwait(false);
if (!allUserPairs.Contains(user.User.UID, StringComparer.Ordinal) && !string.Equals(user.User.UID, UserUID, StringComparison.Ordinal))
{
return new UserProfileDto(user.User, false, null, null, "Due to the pause status you cannot access this users profile.");
}
var data = await DbContext.UserProfileData.SingleOrDefaultAsync(u => u.UserUID == user.User.UID).ConfigureAwait(false);
if (data == null) return new UserProfileDto(user.User, false, null, null, null);
if (data.FlaggedForReport) return new UserProfileDto(user.User, true, null, null, "This profile is flagged for report and pending evaluation");
if (data.ProfileDisabled) return new UserProfileDto(user.User, true, null, null, "This profile was permanently disabled");
return new UserProfileDto(user.User, false, data.IsNSFW, data.Base64ProfileImage, data.UserDescription);
}
[Authorize(Policy = "Identified")]
public async Task UserPushData(UserCharaDataMessageDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto.CharaData.FileReplacements.Count));
// check for honorific containing . and /
try
{
var honorificJson = Encoding.Default.GetString(Convert.FromBase64String(dto.CharaData.HonorificData));
var deserialized = JsonSerializer.Deserialize<JsonElement>(honorificJson);
if (deserialized.TryGetProperty("Title", out var honorificTitle))
{
var title = honorificTitle.GetString().Normalize(NormalizationForm.FormKD);
if (UrlRegex().IsMatch(title))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your data was not pushed: The usage of URLs the Honorific titles is prohibited. Remove them to be able to continue to push data.").ConfigureAwait(false);
throw new HubException("Invalid data provided, Honorific title invalid: " + title);
}
}
}
catch (HubException)
{
throw;
}
catch (Exception)
{
// swallow
}
bool hadInvalidData = false;
List<string> invalidGamePaths = new();
List<string> invalidFileSwapPaths = new();
foreach (var replacement in dto.CharaData.FileReplacements.SelectMany(p => p.Value))
{
var invalidPaths = replacement.GamePaths.Where(p => !GamePathRegex().IsMatch(p)).ToList();
invalidPaths.AddRange(replacement.GamePaths.Where(p => !AllowedExtensionsForGamePaths.Any(e => p.EndsWith(e, StringComparison.OrdinalIgnoreCase))));
replacement.GamePaths = replacement.GamePaths.Where(p => !invalidPaths.Contains(p, StringComparer.OrdinalIgnoreCase)).ToArray();
bool validGamePaths = replacement.GamePaths.Any();
bool validHash = string.IsNullOrEmpty(replacement.Hash) || HashRegex().IsMatch(replacement.Hash);
bool validFileSwapPath = string.IsNullOrEmpty(replacement.FileSwapPath) || GamePathRegex().IsMatch(replacement.FileSwapPath);
if (!validGamePaths || !validHash || !validFileSwapPath)
{
_logger.LogCallWarning(LightlessHubLogger.Args("Invalid Data", "GamePaths", validGamePaths, string.Join(",", invalidPaths), "Hash", validHash, replacement.Hash, "FileSwap", validFileSwapPath, replacement.FileSwapPath));
hadInvalidData = true;
if (!validFileSwapPath) invalidFileSwapPaths.Add(replacement.FileSwapPath);
if (!validGamePaths) invalidGamePaths.AddRange(replacement.GamePaths);
if (!validHash) invalidFileSwapPaths.Add(replacement.Hash);
}
}
if (hadInvalidData)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "One or more of your supplied mods were rejected from the server. Consult /xllog for more information.").ConfigureAwait(false);
throw new HubException("Invalid data provided, contact the appropriate mod creator to resolve those issues"
+ Environment.NewLine
+ string.Join(Environment.NewLine, invalidGamePaths.Select(p => "Invalid Game Path: " + p))
+ Environment.NewLine
+ string.Join(Environment.NewLine, invalidFileSwapPaths.Select(p => "Invalid FileSwap Path: " + p)));
}
var recipientUids = dto.Recipients.Select(r => r.UID).ToList();
bool allCached = await _onlineSyncedPairCacheService.AreAllPlayersCached(UserUID,
recipientUids, Context.ConnectionAborted).ConfigureAwait(false);
if (!allCached)
{
var allPairedUsers = await GetAllPairedUnpausedUsers().ConfigureAwait(false);
recipientUids = allPairedUsers.Where(f => recipientUids.Contains(f, StringComparer.Ordinal)).ToList();
await _onlineSyncedPairCacheService.CachePlayers(UserUID, allPairedUsers, Context.ConnectionAborted).ConfigureAwait(false);
}
_logger.LogCallInfo(LightlessHubLogger.Args(recipientUids.Count));
await Clients.Users(recipientUids).Client_UserReceiveCharacterData(new OnlineUserCharaDataDto(new UserData(UserUID), dto.CharaData)).ConfigureAwait(false);
_lightlessCensus.PublishStatistics(UserUID, dto.CensusDataDto);
_lightlessMetrics.IncCounter(MetricsAPI.CounterUserPushData);
_lightlessMetrics.IncCounter(MetricsAPI.CounterUserPushDataTo, recipientUids.Count);
}
[Authorize(Policy = "Identified")]
public async Task UserRemovePair(UserDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
if (string.Equals(dto.User.UID, UserUID, StringComparison.Ordinal)) return;
// check if client pair even exists
ClientPair callerPair =
await DbContext.ClientPairs.SingleOrDefaultAsync(w => w.UserUID == UserUID && w.OtherUserUID == dto.User.UID).ConfigureAwait(false);
if (callerPair == null) return;
var pairData = await GetPairInfo(UserUID, dto.User.UID).ConfigureAwait(false);
// delete from database, send update info to users pair list
DbContext.ClientPairs.Remove(callerPair);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
await Clients.User(UserUID).Client_UserRemoveClientPair(dto).ConfigureAwait(false);
// check if opposite entry exists
if (!pairData.IndividuallyPaired) return;
// check if other user is online, if no then there is no need to do anything further
var otherIdent = await GetUserIdent(dto.User.UID).ConfigureAwait(false);
if (otherIdent == null) return;
// if the other user had paused the user the state will be offline for either, do nothing
bool callerHadPaused = pairData.OwnPermissions?.IsPaused ?? false;
// send updated individual pair status
await Clients.User(dto.User.UID)
.Client_UpdateUserIndividualPairStatusDto(new(new(UserUID), IndividualPairStatus.OneSided))
.ConfigureAwait(false);
UserPermissionSet? otherPermissions = pairData.OtherPermissions;
bool otherHadPaused = otherPermissions?.IsPaused ?? true;
// if the either had paused, do nothing
if (callerHadPaused && otherHadPaused) return;
var currentPairData = await GetPairInfo(UserUID, dto.User.UID).ConfigureAwait(false);
// if neither user had paused each other and either is not in an unpaused group with each other, change state to offline
if (!currentPairData?.IsSynced ?? true)
{
await Clients.User(UserUID).Client_UserSendOffline(dto).ConfigureAwait(false);
await Clients.User(dto.User.UID).Client_UserSendOffline(new(new(UserUID))).ConfigureAwait(false);
}
}
[Authorize(Policy = "Identified")]
public async Task UserSetProfile(UserProfileDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
if (!string.Equals(dto.User.UID, UserUID, StringComparison.Ordinal)) throw new HubException("Cannot modify profile data for anyone but yourself");
var existingData = await DbContext.UserProfileData.SingleOrDefaultAsync(u => u.UserUID == dto.User.UID).ConfigureAwait(false);
if (existingData?.FlaggedForReport ?? false)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your profile is currently flagged for report and cannot be edited").ConfigureAwait(false);
return;
}
if (existingData?.ProfileDisabled ?? false)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your profile was permanently disabled and cannot be edited").ConfigureAwait(false);
return;
}
if (!string.IsNullOrEmpty(dto.ProfilePictureBase64))
{
byte[] imageData = Convert.FromBase64String(dto.ProfilePictureBase64);
using MemoryStream ms = new(imageData);
var format = await Image.DetectFormatAsync(ms).ConfigureAwait(false);
if (!format.FileExtensions.Contains("png", StringComparer.OrdinalIgnoreCase))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your provided image file is not in PNG format").ConfigureAwait(false);
return;
}
using var image = Image.Load<Rgba32>(imageData);
if (image.Width > 256 || image.Height > 256 || (imageData.Length > 250 * 1024))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your provided image file is larger than 256x256 or more than 250KiB.").ConfigureAwait(false);
return;
}
}
if (existingData != null)
{
if (string.Equals("", dto.ProfilePictureBase64, StringComparison.OrdinalIgnoreCase))
{
existingData.Base64ProfileImage = null;
}
else if (dto.ProfilePictureBase64 != null)
{
existingData.Base64ProfileImage = dto.ProfilePictureBase64;
}
if (dto.IsNSFW != null)
{
existingData.IsNSFW = dto.IsNSFW.Value;
}
if (dto.Description != null)
{
existingData.UserDescription = dto.Description;
}
}
else
{
UserProfileData userProfileData = new()
{
UserUID = dto.User.UID,
Base64ProfileImage = dto.ProfilePictureBase64 ?? null,
UserDescription = dto.Description ?? null,
IsNSFW = dto.IsNSFW ?? false
};
await DbContext.UserProfileData.AddAsync(userProfileData).ConfigureAwait(false);
}
await DbContext.SaveChangesAsync().ConfigureAwait(false);
var allPairedUsers = await GetAllPairedUnpausedUsers().ConfigureAwait(false);
var pairs = await GetOnlineUsers(allPairedUsers).ConfigureAwait(false);
await Clients.Users(pairs.Select(p => p.Key)).Client_UserUpdateProfile(new(dto.User)).ConfigureAwait(false);
await Clients.Caller.Client_UserUpdateProfile(new(dto.User)).ConfigureAwait(false);
}
[GeneratedRegex(@"^([a-z0-9_ '+&,\.\-\{\}]+\/)+([a-z0-9_ '+&,\.\-\{\}]+\.[a-z]{3,4})$", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.ECMAScript)]
private static partial Regex GamePathRegex();
[GeneratedRegex(@"^[A-Z0-9]{40}$", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.ECMAScript)]
private static partial Regex HashRegex();
[GeneratedRegex("^[-a-zA-Z0-9@:%._\\+~#=]{1,256}[\\.,][a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&\\/=]*)$")]
private static partial Regex UrlRegex();
private ClientPair OppositeEntry(string otherUID) =>
DbContext.ClientPairs.AsNoTracking().SingleOrDefault(w => w.User.UID == otherUID && w.OtherUser.UID == UserUID);
}

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<UserSecretsId>aspnet-LightlessSyncServer-BA82A12A-0B30-463C-801D-B7E81318CD50</UserSecretsId>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<ImplicitUsings>enable</ImplicitUsings>

View File

@@ -0,0 +1,12 @@
namespace LightlessSyncServer.Models;
public class BroadcastRedisEntry()
{
public string? GID { get; set; }
public string HashedCID { get; set; } = string.Empty;
public string OwnerUID { get; set; } = string.Empty;
public bool OwnedBy(string userUid) => !string.IsNullOrEmpty(userUid) && string.Equals(OwnerUID, userUid, StringComparison.Ordinal);
public bool HasOwner() => !string.IsNullOrEmpty(OwnerUID);
}

View File

@@ -0,0 +1,56 @@
using LightlessSync.API.Data;
using LightlessSync.API.Dto.Chat;
namespace LightlessSyncServer.Models;
internal readonly record struct ChatReportSnapshotItem(
string MessageId,
DateTime SentAtUtc,
string SenderUserUid,
string? SenderAlias,
bool SenderIsLightfinder,
string? SenderHashedCid,
string Message);
public readonly record struct ChatPresenceEntry(
ChatChannelDescriptor Channel,
ChannelKey ChannelKey,
string DisplayName,
ChatParticipantInfo Participant,
DateTime UpdatedAt);
public readonly record struct ChatParticipantInfo(
string Token,
string UserUid,
UserData? User,
string? HashedCid,
bool IsLightfinder);
public readonly record struct ChatMessageLogEntry(
string MessageId,
ChatChannelDescriptor Channel,
DateTime SentAtUtc,
string SenderUserUid,
UserData? SenderUser,
bool SenderIsLightfinder,
string? SenderHashedCid,
string Message);
public readonly record struct ZoneChannelDefinition(
string Key,
string DisplayName,
ChatChannelDescriptor Descriptor,
IReadOnlyList<string> TerritoryNames,
IReadOnlySet<ushort> TerritoryIds);
public readonly record struct ChannelKey(ChatChannelType Type, ushort WorldId, string CustomKey)
{
public static ChannelKey FromDescriptor(ChatChannelDescriptor descriptor) =>
new(
descriptor.Type,
descriptor.Type == ChatChannelType.Zone ? descriptor.WorldId : (ushort)0,
NormalizeKey(descriptor.CustomKey));
private static string NormalizeKey(string? value) =>
string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant();
}

View File

@@ -0,0 +1,65 @@
using LightlessSync.API.Dto.Chat;
namespace LightlessSyncServer.Models;
internal static class ChatZoneDefinitions
{
public static IReadOnlyList<ZoneChannelDefinition> Defaults { get; } =
new[]
{
new ZoneChannelDefinition(
Key: "limsa",
DisplayName: "Limsa Lominsa",
Descriptor: new ChatChannelDescriptor
{
Type = ChatChannelType.Zone,
WorldId = 0,
ZoneId = 0,
CustomKey = "limsa"
},
TerritoryNames: new[]
{
"Limsa Lominsa Lower Decks",
"Limsa Lominsa Upper Decks"
},
TerritoryIds: TerritoryRegistry.GetIds(
"Limsa Lominsa Lower Decks",
"Limsa Lominsa Upper Decks")),
new ZoneChannelDefinition(
Key: "gridania",
DisplayName: "Gridania",
Descriptor: new ChatChannelDescriptor
{
Type = ChatChannelType.Zone,
WorldId = 0,
ZoneId = 0,
CustomKey = "gridania"
},
TerritoryNames: new[]
{
"New Gridania",
"Old Gridania"
},
TerritoryIds: TerritoryRegistry.GetIds(
"New Gridania",
"Old Gridania")),
new ZoneChannelDefinition(
Key: "uldah",
DisplayName: "Ul'dah",
Descriptor: new ChatChannelDescriptor
{
Type = ChatChannelType.Zone,
WorldId = 0,
ZoneId = 0,
CustomKey = "uldah"
},
TerritoryNames: new[]
{
"Ul'dah - Steps of Nald",
"Ul'dah - Steps of Thal"
},
TerritoryIds: TerritoryRegistry.GetIds(
"Ul'dah - Steps of Nald",
"Ul'dah - Steps of Thal")),
};
}

View File

@@ -0,0 +1,8 @@
namespace LightlessSyncServer.Models;
public class PairingPayload
{
public string UID { get; set; } = string.Empty;
public string HashedCid { get; set; } = string.Empty;
public DateTime Timestamp { get; set; }
}

View File

@@ -0,0 +1,5 @@
namespace LightlessSyncServer.Models;
internal readonly record struct TerritoryDefinition(
ushort TerritoryId,
string Name);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
namespace LightlessSyncServer.Models;
internal readonly record struct WorldDefinition(
ushort WorldId,
string Name,
string Region,
string DataCenter);

View File

@@ -0,0 +1,117 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
namespace LightlessSyncServer.Models;
internal static class WorldRegistry
{
private static readonly WorldDefinition[] WorldArray = new[]
{
new WorldDefinition(80, "Cerberus", "Europe", "Chaos"),
new WorldDefinition(83, "Louisoix", "Europe", "Chaos"),
new WorldDefinition(71, "Moogle", "Europe", "Chaos"),
new WorldDefinition(39, "Omega", "Europe", "Chaos"),
new WorldDefinition(401, "Phantom", "Europe", "Chaos"),
new WorldDefinition(97, "Ragnarok", "Europe", "Chaos"),
new WorldDefinition(400, "Sagittarius", "Europe", "Chaos"),
new WorldDefinition(85, "Spriggan", "Europe", "Chaos"),
new WorldDefinition(402, "Alpha", "Europe", "Light"),
new WorldDefinition(36, "Lich", "Europe", "Light"),
new WorldDefinition(66, "Odin", "Europe", "Light"),
new WorldDefinition(56, "Phoenix", "Europe", "Light"),
new WorldDefinition(403, "Raiden", "Europe", "Light"),
new WorldDefinition(67, "Shiva", "Europe", "Light"),
new WorldDefinition(33, "Twintania", "Europe", "Light"),
new WorldDefinition(42, "Zodiark", "Europe", "Light"),
new WorldDefinition(90, "Aegis", "Japan", "Elemental"),
new WorldDefinition(68, "Atomos", "Japan", "Elemental"),
new WorldDefinition(45, "Carbuncle", "Japan", "Elemental"),
new WorldDefinition(58, "Garuda", "Japan", "Elemental"),
new WorldDefinition(94, "Gungnir", "Japan", "Elemental"),
new WorldDefinition(49, "Kujata", "Japan", "Elemental"),
new WorldDefinition(72, "Tonberry", "Japan", "Elemental"),
new WorldDefinition(50, "Typhon", "Japan", "Elemental"),
new WorldDefinition(43, "Alexander", "Japan", "Gaia"),
new WorldDefinition(69, "Bahamut", "Japan", "Gaia"),
new WorldDefinition(92, "Durandal", "Japan", "Gaia"),
new WorldDefinition(46, "Fenrir", "Japan", "Gaia"),
new WorldDefinition(59, "Ifrit", "Japan", "Gaia"),
new WorldDefinition(98, "Ridill", "Japan", "Gaia"),
new WorldDefinition(76, "Tiamat", "Japan", "Gaia"),
new WorldDefinition(51, "Ultima", "Japan", "Gaia"),
new WorldDefinition(44, "Anima", "Japan", "Mana"),
new WorldDefinition(23, "Asura", "Japan", "Mana"),
new WorldDefinition(70, "Chocobo", "Japan", "Mana"),
new WorldDefinition(47, "Hades", "Japan", "Mana"),
new WorldDefinition(48, "Ixion", "Japan", "Mana"),
new WorldDefinition(96, "Masamune", "Japan", "Mana"),
new WorldDefinition(28, "Pandaemonium", "Japan", "Mana"),
new WorldDefinition(61, "Titan", "Japan", "Mana"),
new WorldDefinition(24, "Belias", "Japan", "Meteor"),
new WorldDefinition(82, "Mandragora", "Japan", "Meteor"),
new WorldDefinition(60, "Ramuh", "Japan", "Meteor"),
new WorldDefinition(29, "Shinryu", "Japan", "Meteor"),
new WorldDefinition(30, "Unicorn", "Japan", "Meteor"),
new WorldDefinition(52, "Valefor", "Japan", "Meteor"),
new WorldDefinition(31, "Yojimbo", "Japan", "Meteor"),
new WorldDefinition(32, "Zeromus", "Japan", "Meteor"),
new WorldDefinition(73, "Adamantoise", "North America", "Aether"),
new WorldDefinition(79, "Cactuar", "North America", "Aether"),
new WorldDefinition(54, "Faerie", "North America", "Aether"),
new WorldDefinition(63, "Gilgamesh", "North America", "Aether"),
new WorldDefinition(40, "Jenova", "North America", "Aether"),
new WorldDefinition(65, "Midgardsormr", "North America", "Aether"),
new WorldDefinition(99, "Sargatanas", "North America", "Aether"),
new WorldDefinition(57, "Siren", "North America", "Aether"),
new WorldDefinition(91, "Balmung", "North America", "Crystal"),
new WorldDefinition(34, "Brynhildr", "North America", "Crystal"),
new WorldDefinition(74, "Coeurl", "North America", "Crystal"),
new WorldDefinition(62, "Diabolos", "North America", "Crystal"),
new WorldDefinition(81, "Goblin", "North America", "Crystal"),
new WorldDefinition(75, "Malboro", "North America", "Crystal"),
new WorldDefinition(37, "Mateus", "North America", "Crystal"),
new WorldDefinition(41, "Zalera", "North America", "Crystal"),
new WorldDefinition(408, "Cuchulainn", "North America", "Dynamis"),
new WorldDefinition(411, "Golem", "North America", "Dynamis"),
new WorldDefinition(406, "Halicarnassus", "North America", "Dynamis"),
new WorldDefinition(409, "Kraken", "North America", "Dynamis"),
new WorldDefinition(407, "Maduin", "North America", "Dynamis"),
new WorldDefinition(404, "Marilith", "North America", "Dynamis"),
new WorldDefinition(410, "Rafflesia", "North America", "Dynamis"),
new WorldDefinition(405, "Seraph", "North America", "Dynamis"),
new WorldDefinition(78, "Behemoth", "North America", "Primal"),
new WorldDefinition(93, "Excalibur", "North America", "Primal"),
new WorldDefinition(53, "Exodus", "North America", "Primal"),
new WorldDefinition(35, "Famfrit", "North America", "Primal"),
new WorldDefinition(95, "Hyperion", "North America", "Primal"),
new WorldDefinition(55, "Lamia", "North America", "Primal"),
new WorldDefinition(64, "Leviathan", "North America", "Primal"),
new WorldDefinition(77, "Ultros", "North America", "Primal"),
new WorldDefinition(22, "Bismarck", "Oceania", "Materia"),
new WorldDefinition(21, "Ravana", "Oceania", "Materia"),
new WorldDefinition(86, "Sephirot", "Oceania", "Materia"),
new WorldDefinition(87, "Sophia", "Oceania", "Materia"),
new WorldDefinition(88, "Zurvan", "Oceania", "Materia"),
};
public static IReadOnlyList<WorldDefinition> All { get; } = Array.AsReadOnly(WorldArray);
public static IReadOnlyDictionary<ushort, WorldDefinition> ById { get; } = new ReadOnlyDictionary<ushort, WorldDefinition>(WorldArray.ToDictionary(w => w.WorldId));
public static IReadOnlyDictionary<string, IReadOnlyList<WorldDefinition>> ByDataCenter { get; } = new ReadOnlyDictionary<string, IReadOnlyList<WorldDefinition>>(WorldArray
.GroupBy(w => w.DataCenter, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => (IReadOnlyList<WorldDefinition>)g.OrderBy(w => w.Name, StringComparer.Ordinal).ToArray(),
StringComparer.OrdinalIgnoreCase));
public static IReadOnlyDictionary<string, IReadOnlyList<WorldDefinition>> ByRegion { get; } = new ReadOnlyDictionary<string, IReadOnlyList<WorldDefinition>>(WorldArray
.GroupBy(w => w.Region, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => (IReadOnlyList<WorldDefinition>)g.OrderBy(w => w.Name, StringComparer.Ordinal).ToArray(),
StringComparer.OrdinalIgnoreCase));
public static bool TryGet(ushort worldId, out WorldDefinition definition) => ById.TryGetValue(worldId, out definition);
public static bool IsKnownWorld(ushort worldId) => ById.ContainsKey(worldId);
}

View File

@@ -41,7 +41,6 @@ public class Program
metrics.SetGaugeTo(MetricsAPI.GaugeUsersRegistered, context.Users.AsNoTracking().Count());
metrics.SetGaugeTo(MetricsAPI.GaugePairs, context.ClientPairs.AsNoTracking().Count());
metrics.SetGaugeTo(MetricsAPI.GaugePairsPaused, context.Permissions.AsNoTracking().Where(p=>p.IsPaused).Count());
}
if (args.Length == 0 || !string.Equals(args[0], "dry", StringComparison.Ordinal))

View File

@@ -0,0 +1,767 @@
using System.Security.Cryptography;
using LightlessSync.API.Data;
using LightlessSync.API.Dto.Chat;
using LightlessSyncServer.Configuration;
using LightlessSyncServer.Models;
using Microsoft.Extensions.Options;
namespace LightlessSyncServer.Services;
public sealed class ChatChannelService : IDisposable
{
private readonly ILogger<ChatChannelService> _logger;
private readonly Dictionary<string, ZoneChannelDefinition> _zoneDefinitions;
private readonly Dictionary<ChannelKey, HashSet<string>> _membersByChannel = new();
private readonly Dictionary<string, Dictionary<ChannelKey, ChatPresenceEntry>> _presenceByUser = new(StringComparer.Ordinal);
private readonly Dictionary<ChannelKey, Dictionary<string, ChatParticipantInfo>> _participantsByChannel = new();
private readonly Dictionary<ChannelKey, LinkedList<ChatMessageLogEntry>> _messagesByChannel = new();
private readonly Dictionary<string, (ChannelKey Channel, LinkedListNode<ChatMessageLogEntry> Node)> _messageIndex = new(StringComparer.Ordinal);
private readonly Dictionary<string, Dictionary<ChannelKey, HashSet<string>>> _mutedTokensByUser = new(StringComparer.Ordinal);
private readonly Dictionary<string, Dictionary<ChannelKey, HashSet<string>>> _mutedUidsByUser = new(StringComparer.Ordinal);
private readonly object _syncRoot = new();
private const int MaxMessagesPerChannel = 200;
internal const int MaxMutedParticipantsPerChannel = 8;
public ChatChannelService(ILogger<ChatChannelService> logger, IOptions<ChatZoneOverridesOptions>? zoneOverrides = null)
{
_logger = logger;
_zoneDefinitions = BuildZoneDefinitions(zoneOverrides?.Value);
}
private Dictionary<string, ZoneChannelDefinition> BuildZoneDefinitions(ChatZoneOverridesOptions? overrides)
{
var definitions = ChatZoneDefinitions.Defaults
.ToDictionary(definition => definition.Key, StringComparer.OrdinalIgnoreCase);
if (overrides?.Zones is null || overrides.Zones.Count == 0)
{
return definitions;
}
foreach (var entry in overrides.Zones)
{
if (entry is null)
{
continue;
}
if (!TryCreateZoneDefinition(entry, out var definition))
{
continue;
}
definitions[definition.Key] = definition;
}
return definitions;
}
private bool TryCreateZoneDefinition(ChatZoneOverride entry, out ZoneChannelDefinition definition)
{
definition = default;
var key = NormalizeZoneKey(entry.Key);
if (string.IsNullOrEmpty(key))
{
_logger.LogWarning("Skipped chat zone override with missing key.");
return false;
}
var territoryIds = new HashSet<ushort>();
if (entry.TerritoryIds is not null)
{
foreach (var candidate in entry.TerritoryIds)
{
if (candidate > 0)
{
territoryIds.Add(candidate);
}
}
}
var territoryNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (entry.TerritoryNames is not null)
{
foreach (var name in entry.TerritoryNames)
{
if (string.IsNullOrWhiteSpace(name))
continue;
var trimmed = name.Trim();
territoryNames.Add(trimmed);
if (TerritoryRegistry.TryGetIds(trimmed, out var ids))
{
territoryIds.UnionWith(ids);
}
else
{
_logger.LogWarning("Chat zone override {Zone} references unknown territory '{Territory}'.", key, trimmed);
}
}
}
if (territoryIds.Count == 0)
{
_logger.LogWarning("Skipped chat zone override for {Zone}: no territory IDs resolved.", key);
return false;
}
if (territoryNames.Count == 0)
{
foreach (var territoryId in territoryIds)
{
if (TerritoryRegistry.ById.TryGetValue(territoryId, out var territory))
{
territoryNames.Add(territory.Name);
}
}
}
if (territoryNames.Count == 0)
{
territoryNames.Add("Territory");
}
var descriptor = new ChatChannelDescriptor
{
Type = ChatChannelType.Zone,
WorldId = 0,
ZoneId = 0,
CustomKey = key
};
var displayName = string.IsNullOrWhiteSpace(entry.DisplayName)
? key
: entry.DisplayName.Trim();
definition = new ZoneChannelDefinition(
key,
displayName,
descriptor,
territoryNames.ToArray(),
territoryIds);
return true;
}
private static string NormalizeZoneKey(string? value) =>
string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim();
public IReadOnlyList<ZoneChatChannelInfoDto> GetZoneChannelInfos() =>
_zoneDefinitions.Values
.Select(definition => new ZoneChatChannelInfoDto(
definition.Descriptor,
definition.DisplayName,
definition.TerritoryNames))
.ToArray();
public bool TryResolveZone(string? key, out ZoneChannelDefinition definition)
{
definition = default;
if (string.IsNullOrWhiteSpace(key))
return false;
return _zoneDefinitions.TryGetValue(key, out definition);
}
public ChatPresenceEntry? UpdateZonePresence(
string userUid,
ZoneChannelDefinition definition,
ushort worldId,
ushort territoryId,
string? hashedCid,
bool isLightfinder,
bool isActive)
{
if (worldId == 0 || !WorldRegistry.IsKnownWorld(worldId))
{
_logger.LogWarning("Rejected zone chat presence for {User} in {Zone}: unknown world {WorldId}", userUid, definition.Key, worldId);
return null;
}
if (!definition.TerritoryIds.Contains(territoryId))
{
_logger.LogWarning("Rejected zone chat presence for {User} in {Zone}: invalid territory {TerritoryId}", userUid, definition.Key, territoryId);
return null;
}
var descriptor = definition.Descriptor with { WorldId = worldId, ZoneId = territoryId };
var participant = new ChatParticipantInfo(
Token: string.Empty,
UserUid: userUid,
User: null,
HashedCid: isLightfinder ? hashedCid : null,
IsLightfinder: isLightfinder);
return UpdatePresence(
userUid,
descriptor,
definition.DisplayName,
participant,
isActive,
replaceExistingOfSameType: true);
}
public ChatPresenceEntry? UpdateGroupPresence(
string userUid,
string groupId,
string displayName,
UserData user,
string? hashedCid,
bool isActive)
{
var descriptor = new ChatChannelDescriptor
{
Type = ChatChannelType.Group,
WorldId = 0,
ZoneId = 0,
CustomKey = groupId
};
var participant = new ChatParticipantInfo(
Token: string.Empty,
UserUid: userUid,
User: user,
HashedCid: hashedCid,
IsLightfinder: !string.IsNullOrEmpty(hashedCid));
return UpdatePresence(
userUid,
descriptor,
displayName,
participant,
isActive,
replaceExistingOfSameType: false);
}
public bool TryGetPresence(string userUid, ChatChannelDescriptor channel, out ChatPresenceEntry presence)
{
var key = ChannelKey.FromDescriptor(channel);
lock (_syncRoot)
{
if (_presenceByUser.TryGetValue(userUid, out var entries) && entries.TryGetValue(key, out presence))
{
return true;
}
}
presence = default;
return false;
}
public IReadOnlyCollection<string> GetMembers(ChatChannelDescriptor channel)
{
var key = ChannelKey.FromDescriptor(channel);
lock (_syncRoot)
{
if (_membersByChannel.TryGetValue(key, out var members))
{
return members.ToArray();
}
}
return Array.Empty<string>();
}
public string RecordMessage(ChatChannelDescriptor channel, ChatParticipantInfo participant, string message, DateTime sentAtUtc)
{
var key = ChannelKey.FromDescriptor(channel);
var messageId = Guid.NewGuid().ToString("N");
var entry = new ChatMessageLogEntry(
messageId,
channel,
sentAtUtc,
participant.UserUid,
participant.User,
participant.IsLightfinder,
participant.HashedCid,
message);
lock (_syncRoot)
{
if (!_messagesByChannel.TryGetValue(key, out var list))
{
list = new LinkedList<ChatMessageLogEntry>();
_messagesByChannel[key] = list;
}
var node = list.AddLast(entry);
_messageIndex[messageId] = (key, node);
while (list.Count > MaxMessagesPerChannel)
{
var removedNode = list.First;
if (removedNode is null)
{
break;
}
list.RemoveFirst();
_messageIndex.Remove(removedNode.Value.MessageId);
}
}
return messageId;
}
public bool TryGetMessage(string messageId, out ChatMessageLogEntry entry)
{
lock (_syncRoot)
{
if (_messageIndex.TryGetValue(messageId, out var located))
{
entry = located.Node.Value;
return true;
}
}
entry = default;
return false;
}
public IReadOnlyList<ChatMessageLogEntry> GetRecentMessages(ChatChannelDescriptor descriptor, int maxCount)
{
lock (_syncRoot)
{
var key = ChannelKey.FromDescriptor(descriptor);
if (!_messagesByChannel.TryGetValue(key, out var list) || list.Count == 0)
{
return Array.Empty<ChatMessageLogEntry>();
}
var take = Math.Min(maxCount, list.Count);
var result = new ChatMessageLogEntry[take];
var node = list.Last;
for (var i = take - 1; i >= 0 && node is not null; i--)
{
result[i] = node.Value;
node = node.Previous;
}
return result;
}
}
public bool RemovePresence(string userUid, ChatChannelDescriptor? channel = null)
{
ArgumentException.ThrowIfNullOrEmpty(userUid);
lock (_syncRoot)
{
if (!_presenceByUser.TryGetValue(userUid, out var entries))
{
return false;
}
if (channel is null)
{
foreach (var existing in entries.Keys.ToList())
{
RemovePresenceInternal(userUid, entries, existing);
}
_presenceByUser.Remove(userUid);
return true;
}
var key = ChannelKey.FromDescriptor(channel.Value);
var removed = RemovePresenceInternal(userUid, entries, key);
if (entries.Count == 0)
{
_presenceByUser.Remove(userUid);
}
return removed;
}
}
public void RefreshLightfinderState(string userUid, string? hashedCid, bool isLightfinder)
{
ArgumentException.ThrowIfNullOrEmpty(userUid);
lock (_syncRoot)
{
if (!_presenceByUser.TryGetValue(userUid, out var entries) || entries.Count == 0)
{
return;
}
foreach (var (key, existing) in entries.ToArray())
{
var updatedParticipant = existing.Participant with
{
HashedCid = isLightfinder ? hashedCid : null,
IsLightfinder = isLightfinder
};
var updatedEntry = existing with
{
Participant = updatedParticipant,
UpdatedAt = DateTime.UtcNow
};
entries[key] = updatedEntry;
if (_participantsByChannel.TryGetValue(key, out var participants))
{
participants[updatedParticipant.Token] = updatedParticipant;
}
}
}
}
private ChatPresenceEntry? UpdatePresence(
string userUid,
ChatChannelDescriptor descriptor,
string displayName,
ChatParticipantInfo participant,
bool isActive,
bool replaceExistingOfSameType)
{
ArgumentException.ThrowIfNullOrEmpty(userUid);
var normalizedDescriptor = descriptor.WithNormalizedCustomKey();
var key = ChannelKey.FromDescriptor(normalizedDescriptor);
lock (_syncRoot)
{
if (!_presenceByUser.TryGetValue(userUid, out var entries))
{
if (!isActive)
return null;
entries = new Dictionary<ChannelKey, ChatPresenceEntry>();
_presenceByUser[userUid] = entries;
}
string? reusableToken = null;
if (entries.TryGetValue(key, out var existing))
{
reusableToken = existing.Participant.Token;
RemovePresenceInternal(userUid, entries, key);
}
if (replaceExistingOfSameType)
{
foreach (var candidate in entries.Keys.Where(k => k.Type == key.Type).ToList())
{
if (entries.TryGetValue(candidate, out var entry))
{
reusableToken ??= entry.Participant.Token;
}
RemovePresenceInternal(userUid, entries, candidate);
}
if (!isActive)
{
if (entries.Count == 0)
{
_presenceByUser.Remove(userUid);
}
_logger.LogDebug("Chat presence cleared for {User} ({Type})", userUid, normalizedDescriptor.Type);
return null;
}
}
else if (!isActive)
{
var removed = RemovePresenceInternal(userUid, entries, key);
if (removed)
{
_logger.LogDebug("Chat presence removed for {User} from {Channel}", userUid, Describe(key));
}
if (entries.Count == 0)
{
_presenceByUser.Remove(userUid);
}
return null;
}
var token = !string.IsNullOrEmpty(participant.Token)
? participant.Token
: reusableToken ?? GenerateToken();
var finalParticipant = participant with { Token = token };
var entryToStore = new ChatPresenceEntry(
normalizedDescriptor,
key,
displayName,
finalParticipant,
DateTime.UtcNow);
entries[key] = entryToStore;
if (!_membersByChannel.TryGetValue(key, out var members))
{
members = new HashSet<string>(StringComparer.Ordinal);
_membersByChannel[key] = members;
}
members.Add(userUid);
if (!_participantsByChannel.TryGetValue(key, out var participantsByToken))
{
participantsByToken = new Dictionary<string, ChatParticipantInfo>(StringComparer.Ordinal);
_participantsByChannel[key] = participantsByToken;
}
participantsByToken[token] = finalParticipant;
ApplyUIDMuteIfPresent(normalizedDescriptor, finalParticipant);
_logger.LogDebug("Chat presence updated for {User} in {Channel}", userUid, Describe(key));
return entryToStore;
}
}
private bool RemovePresenceInternal(string userUid, Dictionary<ChannelKey, ChatPresenceEntry> entries, ChannelKey key)
{
if (!entries.TryGetValue(key, out var existing))
{
return false;
}
entries.Remove(key);
if (_membersByChannel.TryGetValue(key, out var members))
{
members.Remove(userUid);
if (members.Count == 0)
{
_membersByChannel.Remove(key);
// Preserve message history even when a channel becomes empty so moderation can still resolve reports.
}
}
if (_participantsByChannel.TryGetValue(key, out var participants))
{
participants.Remove(existing.Participant.Token);
if (participants.Count == 0)
{
_participantsByChannel.Remove(key);
}
}
ClearMutesForChannel(userUid, key);
return true;
}
internal bool TryGetActiveParticipant(ChatChannelDescriptor channel, string token, out ChatParticipantInfo participant)
{
var key = ChannelKey.FromDescriptor(channel.WithNormalizedCustomKey());
lock (_syncRoot)
{
if (_participantsByChannel.TryGetValue(key, out var participants) &&
participants.TryGetValue(token, out participant))
{
return true;
}
}
participant = default;
return false;
}
internal bool IsTokenMuted(string userUid, ChatChannelDescriptor channel, string token)
{
var key = ChannelKey.FromDescriptor(channel.WithNormalizedCustomKey());
lock (_syncRoot)
{
if (!_mutedTokensByUser.TryGetValue(userUid, out var channels) ||
!channels.TryGetValue(key, out var tokens))
{
return false;
}
if (tokens.Contains(token))
{
return true;
}
if (_participantsByChannel.TryGetValue(key, out var participants) &&
participants.TryGetValue(token, out var participant))
{
return IsUIDMutedLocked(userUid, key, participant.UserUid);
}
return false;
}
}
public ChatMuteUpdateResult SetMutedParticipant(string userUid, ChatChannelDescriptor channel, ChatParticipantInfo participant, bool mute)
{
ArgumentException.ThrowIfNullOrEmpty(userUid);
ArgumentException.ThrowIfNullOrEmpty(participant.Token);
ArgumentException.ThrowIfNullOrEmpty(participant.UserUid);
var key = ChannelKey.FromDescriptor(channel.WithNormalizedCustomKey());
lock (_syncRoot)
{
if (!_mutedTokensByUser.TryGetValue(userUid, out var channels))
{
if (!mute)
{
return ChatMuteUpdateResult.NoChange;
}
channels = new Dictionary<ChannelKey, HashSet<string>>();
_mutedTokensByUser[userUid] = channels;
}
if (!channels.TryGetValue(key, out var tokens))
{
if (!mute)
{
return ChatMuteUpdateResult.NoChange;
}
tokens = new HashSet<string>(StringComparer.Ordinal);
channels[key] = tokens;
}
if (mute)
{
if (!tokens.Contains(participant.Token) && tokens.Count >= MaxMutedParticipantsPerChannel)
{
return ChatMuteUpdateResult.ChannelLimitReached;
}
var added = tokens.Add(participant.Token);
EnsureUIDMuteLocked(userUid, key, participant.UserUid);
return added ? ChatMuteUpdateResult.Changed : ChatMuteUpdateResult.NoChange;
}
var removed = tokens.Remove(participant.Token);
if (tokens.Count == 0)
{
channels.Remove(key);
if (channels.Count == 0)
{
_mutedTokensByUser.Remove(userUid);
}
}
RemoveUIDMuteLocked(userUid, key, participant.UserUid);
return removed ? ChatMuteUpdateResult.Changed : ChatMuteUpdateResult.NoChange;
}
}
private static string GenerateToken()
{
Span<byte> buffer = stackalloc byte[8];
RandomNumberGenerator.Fill(buffer);
return Convert.ToHexString(buffer);
}
private static string Describe(ChannelKey key)
=> $"{key.Type}:{key.WorldId}:{key.CustomKey}";
private void ClearMutesForChannel(string userUid, ChannelKey key)
{
if (_mutedTokensByUser.TryGetValue(userUid, out var tokenChannels) &&
tokenChannels.Remove(key) &&
tokenChannels.Count == 0)
{
_mutedTokensByUser.Remove(userUid);
}
if (_mutedUidsByUser.TryGetValue(userUid, out var uidChannels) &&
uidChannels.Remove(key) &&
uidChannels.Count == 0)
{
_mutedUidsByUser.Remove(userUid);
}
}
private void ApplyUIDMuteIfPresent(ChatChannelDescriptor descriptor, ChatParticipantInfo participant)
{
var key = ChannelKey.FromDescriptor(descriptor);
foreach (var kvp in _mutedUidsByUser)
{
var muter = kvp.Key;
var channels = kvp.Value;
if (!channels.TryGetValue(key, out var mutedUids) || !mutedUids.Contains(participant.UserUid))
{
continue;
}
if (!_mutedTokensByUser.TryGetValue(muter, out var tokenChannels))
{
tokenChannels = new Dictionary<ChannelKey, HashSet<string>>();
_mutedTokensByUser[muter] = tokenChannels;
}
if (!tokenChannels.TryGetValue(key, out var tokens))
{
tokens = new HashSet<string>(StringComparer.Ordinal);
tokenChannels[key] = tokens;
}
tokens.Add(participant.Token);
}
}
private void EnsureUIDMuteLocked(string userUid, ChannelKey key, string targetUid)
{
if (!_mutedUidsByUser.TryGetValue(userUid, out var channels))
{
channels = new Dictionary<ChannelKey, HashSet<string>>();
_mutedUidsByUser[userUid] = channels;
}
if (!channels.TryGetValue(key, out var set))
{
set = new HashSet<string>(StringComparer.Ordinal);
channels[key] = set;
}
set.Add(targetUid);
}
private void RemoveUIDMuteLocked(string userUid, ChannelKey key, string targetUid)
{
if (!_mutedUidsByUser.TryGetValue(userUid, out var channels) ||
!channels.TryGetValue(key, out var set))
{
return;
}
set.Remove(targetUid);
if (set.Count == 0)
{
channels.Remove(key);
if (channels.Count == 0)
{
_mutedUidsByUser.Remove(userUid);
}
}
}
private bool IsUIDMutedLocked(string userUid, ChannelKey key, string targetUid)
{
return _mutedUidsByUser.TryGetValue(userUid, out var channels) &&
channels.TryGetValue(key, out var set) &&
set.Contains(targetUid);
}
public void Dispose()
{
}
}
public enum ChatMuteUpdateResult
{
NoChange,
Changed,
ChannelLimitReached
}

View File

@@ -0,0 +1,105 @@
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace LightlessSyncServer.Services
{
public class ImageCheckService
{
private static readonly int _imageWidthAvatar = 512;
private static readonly int _imageHeightAvatar = 512;
private static readonly int _imageWidthBanner = 840;
private static readonly int _imageHeightBanner = 260;
private static readonly int _imageSize = 2000;
public class ImageLoadResult
{
public bool Success { get; init; }
public string? Base64Image { get; init; }
public string? ErrorMessage { get; init; }
public static ImageLoadResult Fail(string message) => new()
{
Success = false,
ErrorMessage = message,
};
public static ImageLoadResult Ok(string base64) => new()
{
Success = true,
Base64Image = base64,
};
}
public static async Task<ImageLoadResult> ValidateImageAsync(string base64String, bool banner, CancellationToken token)
{
if (token.IsCancellationRequested)
return ImageLoadResult.Fail("Operation cancelled.");
byte[] imageData;
try
{
imageData = Convert.FromBase64String(base64String);
}
catch (FormatException)
{
return ImageLoadResult.Fail("The provided image is not a valid Base64 string.");
}
Image<Rgba32>? image = null;
bool imageLoaded = false;
IImageFormat? format = null;
try
{
using (var ms = new MemoryStream(imageData))
{
format = await Image.DetectFormatAsync(ms, token).ConfigureAwait(false);
}
if (format == null)
{
return ImageLoadResult.Fail("Unable to detect image format.");
}
using (image = Image.Load<Rgba32>(imageData))
{
imageLoaded = true;
int maxWidth = banner ? _imageWidthBanner : _imageWidthAvatar;
int maxHeight = banner ? _imageHeightBanner : _imageHeightAvatar;
if (image.Width > maxWidth || image.Height > maxHeight)
{
var ratio = Math.Min((double)maxWidth / image.Width, (double)maxHeight / image.Height);
int newWidth = (int)(image.Width * ratio);
int newHeight = (int)(image.Height * ratio);
image.Mutate(x => x.Resize(newWidth, newHeight));
}
using var memoryStream = new MemoryStream();
await image.SaveAsPngAsync(memoryStream, token).ConfigureAwait(false);
if (memoryStream.Length > _imageSize * 1024)
{
return ImageLoadResult.Fail("Your image exceeds 2 MiB after resizing/conversion.");
}
string base64Png = Convert.ToBase64String(memoryStream.GetBuffer(), 0, (int)memoryStream.Length);
return ImageLoadResult.Ok(base64Png);
}
}
catch
{
if (imageLoaded)
image?.Dispose();
return ImageLoadResult.Fail("Failed to load or process the image. It may be corrupted or unsupported.");
}
}
}
}

View File

@@ -0,0 +1,10 @@
using LightlessSyncShared.Models;
namespace LightlessSyncServer.Services.Interfaces
{
public interface IPruneService
{
Task<int> CountPrunableUsersAsync(string groupGid, int days, CancellationToken ct);
Task<IReadOnlyList<GroupPair>> ExecutePruneAsync(string groupGid, int days, CancellationToken ct);
}
}

View File

@@ -72,7 +72,7 @@ public class LightlessCensus : IHostedService
Dictionary<ushort, short> worldDcs = new();
var dcs = await client.GetStringAsync("https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/WorldDCGroupType.csv", cancellationToken).ConfigureAwait(false);
var dcs = await client.GetStringAsync("https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/en/WorldDCGroupType.csv", cancellationToken).ConfigureAwait(false);
// dc: https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/WorldDCGroupType.csv
// id, name, region
@@ -92,7 +92,7 @@ public class LightlessCensus : IHostedService
_dcs[id] = name;
}
var worlds = await client.GetStringAsync("https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/World.csv", cancellationToken).ConfigureAwait(false);
var worlds = await client.GetStringAsync("https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/en/World.csv", cancellationToken).ConfigureAwait(false);
// world: https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/World.csv
// id, internalname, name, region, usertype, datacenter, ispublic
@@ -114,7 +114,7 @@ public class LightlessCensus : IHostedService
_logger.LogInformation("World: ID: {id}, Name: {name}, DC: {dc}", id, name, dc);
}
var races = await client.GetStringAsync("https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/Race.csv", cancellationToken).ConfigureAwait(false);
var races = await client.GetStringAsync("https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/en/Race.csv", cancellationToken).ConfigureAwait(false);
// race: https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/Race.csv
// id, masc name, fem name, other crap I don't care about
@@ -134,7 +134,7 @@ public class LightlessCensus : IHostedService
_logger.LogInformation("Race: ID: {id}, Name: {name}", id, name);
}
var tribe = await client.GetStringAsync("https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/Tribe.csv", cancellationToken).ConfigureAwait(false);
var tribe = await client.GetStringAsync("https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/en/Tribe.csv", cancellationToken).ConfigureAwait(false);
// tribe: https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/Tribe.csv
// id masc name, fem name, other crap I don't care about

View File

@@ -0,0 +1,108 @@
using LightlessSyncShared.Data;
using LightlessSyncShared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System;
using System.Threading.Tasks;
public class PairService
{
private readonly IDbContextFactory<LightlessDbContext> _dbFactory;
private readonly ILogger<PairService> _logger;
public PairService(IDbContextFactory<LightlessDbContext> dbFactory, ILogger<PairService> logger)
{
_dbFactory = dbFactory;
_logger = logger;
}
public async Task<bool> TryAddPairAsync(string userUid, string otherUid)
{
if (userUid == otherUid || string.IsNullOrWhiteSpace(userUid) || string.IsNullOrWhiteSpace(otherUid))
return false;
await using var db = await _dbFactory.CreateDbContextAsync();
var user = await db.Users.SingleOrDefaultAsync(u => u.UID == userUid);
var other = await db.Users.SingleOrDefaultAsync(u => u.UID == otherUid);
if (user == null || other == null)
return false;
bool modified = false;
if (!await db.ClientPairs.AnyAsync(p => p.UserUID == userUid && p.OtherUserUID == otherUid))
{
db.ClientPairs.Add(new ClientPair
{
UserUID = userUid,
OtherUserUID = otherUid
});
modified = true;
}
if (!await db.ClientPairs.AnyAsync(p => p.UserUID == otherUid && p.OtherUserUID == userUid))
{
db.ClientPairs.Add(new ClientPair
{
UserUID = otherUid,
OtherUserUID = userUid
});
modified = true;
}
if (!await db.Permissions.AnyAsync(p => p.UserUID == userUid && p.OtherUserUID == otherUid))
{
var defaultPerms = await db.UserDefaultPreferredPermissions
.SingleOrDefaultAsync(p => p.UserUID == userUid);
if (defaultPerms != null)
{
db.Permissions.Add(new UserPermissionSet
{
UserUID = userUid,
OtherUserUID = otherUid,
DisableAnimations = defaultPerms.DisableIndividualAnimations,
DisableSounds = defaultPerms.DisableIndividualSounds,
DisableVFX = defaultPerms.DisableIndividualVFX,
IsPaused = false,
Sticky = true,
});
modified = true;
}
}
if (!await db.Permissions.AnyAsync(p => p.UserUID == otherUid && p.OtherUserUID == userUid))
{
var defaultPerms = await db.UserDefaultPreferredPermissions
.SingleOrDefaultAsync(p => p.UserUID == otherUid);
if (defaultPerms != null)
{
db.Permissions.Add(new UserPermissionSet
{
UserUID = otherUid,
OtherUserUID = userUid,
DisableAnimations = defaultPerms.DisableIndividualAnimations,
DisableSounds = defaultPerms.DisableIndividualSounds,
DisableVFX = defaultPerms.DisableIndividualVFX,
IsPaused = false,
Sticky = true,
});
modified = true;
}
}
if (modified)
{
await db.SaveChangesAsync();
_logger.LogInformation("Mutual pair established between {UserUID} and {OtherUID}", userUid, otherUid);
}
else
{
_logger.LogInformation("Pair already exists between {UserUID} and {OtherUID}", userUid, otherUid);
}
return modified;
}
}

View File

@@ -0,0 +1,100 @@
using LightlessSyncServer.Services.Interfaces;
using LightlessSyncShared.Data;
using LightlessSyncShared.Models;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis.Extensions.Core.Abstractions;
namespace LightlessSyncServer.Services
{
public class PruneService(LightlessDbContext dbContext, IRedisDatabase redis) : IPruneService
{
private readonly LightlessDbContext _dbContext = dbContext;
private readonly IRedisDatabase _redis = redis;
public async Task<int> CountPrunableUsersAsync(string groupGid, int days, CancellationToken ct)
{
var onlineUids = await GetOnlineUidsAsync().ConfigureAwait(false);
var allGroupUsers = await _dbContext.GroupPairs
.Include(p => p.GroupUser)
.Include(p => p.Group)
.Where(g => g.GroupGID == groupGid)
.ToListAsync(ct).ConfigureAwait(false);
var inactivitySpan = GetInactivitySpan(days);
var now = DateTime.UtcNow;
var usersToPrune = GetPruneUserList(allGroupUsers, onlineUids, inactivitySpan, now);
return usersToPrune.Count;
}
public async Task<IReadOnlyList<GroupPair>> ExecutePruneAsync(string groupGid, int days, CancellationToken ct)
{
var onlineUids = await GetOnlineUidsAsync().ConfigureAwait(false);
var allGroupUsers = await _dbContext.GroupPairs
.Include(p => p.GroupUser)
.Include(p => p.Group)
.Where(g => g.GroupGID == groupGid)
.ToListAsync(ct).ConfigureAwait(false);
var inactivitySpan = GetInactivitySpan(days);
var now = DateTime.UtcNow;
var usersToPrune = GetPruneUserList(allGroupUsers, onlineUids, inactivitySpan, now);
_dbContext.GroupPairs.RemoveRange(usersToPrune);
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
return usersToPrune;
}
private static List<GroupPair> GetPruneUserList(
List<GroupPair> allGroupUsers,
HashSet<string> onlineUids,
TimeSpan inactivitySpan,
DateTime now)
{
return
[
.. allGroupUsers.Where(p =>
!p.IsPinned &&
!p.IsModerator &&
!string.Equals(p.Group.OwnerUID, p.GroupUserUID, StringComparison.Ordinal) &&
!onlineUids.Contains(p.GroupUserUID) &&
p.GroupUser.LastLoggedIn < now - inactivitySpan),
];
}
private async Task<HashSet<string>> GetOnlineUidsAsync()
{
var keys = await _redis.SearchKeysAsync("UID:*").ConfigureAwait(false);
var set = new HashSet<string>(StringComparer.Ordinal);
foreach (var k in keys)
{
if (string.IsNullOrEmpty(k)) continue;
const string prefix = "UID:";
if (k.StartsWith(prefix, StringComparison.Ordinal))
{
var uid = k.Substring(prefix.Length);
if (!string.IsNullOrEmpty(uid))
set.Add(uid);
}
else
{
var idx = k.IndexOf(':', StringComparison.Ordinal);
if (idx >= 0 && idx < k.Length - 1)
set.Add(k[(idx + 1)..]);
}
}
return set;
}
private static TimeSpan GetInactivitySpan(int days) =>
days == 0 ? TimeSpan.FromHours(2) : TimeSpan.FromDays(days);
}
}

View File

@@ -1,6 +1,7 @@
using LightlessSync.API.Dto;
using LightlessSync.API.SignalR;
using LightlessSyncServer.Hubs;
using LightlessSyncServer.Models;
using LightlessSyncShared.Data;
using LightlessSyncShared.Metrics;
using LightlessSyncShared.Services;
@@ -52,6 +53,13 @@ public sealed class SystemInfoService : BackgroundService
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeAvailableIOWorkerThreads, ioThreads);
var onlineUsers = (_redis.SearchKeysAsync("UID:*").GetAwaiter().GetResult()).Count();
var allLightfinderKeys = _redis.SearchKeysAsync("broadcast:*").GetAwaiter().GetResult().Where(c => !c.Contains("owner", StringComparison.Ordinal)).ToHashSet(StringComparer.Ordinal);
var allLightfinderItems = _redis.GetAllAsync<BroadcastRedisEntry>(allLightfinderKeys).GetAwaiter().GetResult();
var countLightFinderUsers = allLightfinderItems.Count;
var countLightFinderSyncshells = allLightfinderItems.Count(static l => !string.IsNullOrEmpty(l.Value.GID));
SystemInfoDto = new SystemInfoDto()
{
OnlineUsers = onlineUsers,
@@ -64,12 +72,16 @@ public sealed class SystemInfoService : BackgroundService
await _hubContext.Clients.All.Client_UpdateSystemInfo(SystemInfoDto).ConfigureAwait(false);
using var db = await _dbContextFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
var groupsWithAutoPrune = db.Groups.AsNoTracking().Count(g => g.AutoPruneEnabled && g.AutoPruneDays > 0);
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeAuthorizedConnections, onlineUsers);
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeLightFinderConnections, countLightFinderUsers);
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeGroupAutoPrunesEnabled, groupsWithAutoPrune);
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugePairs, db.ClientPairs.AsNoTracking().Count());
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugePairsPaused, db.Permissions.AsNoTracking().Where(p => p.IsPaused).Count());
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugePairsPaused, db.Permissions.AsNoTracking().Count(p => p.IsPaused));
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeGroups, db.Groups.AsNoTracking().Count());
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeGroupPairs, db.GroupPairs.AsNoTracking().Count());
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeLightFinderGroups, countLightFinderSyncshells);
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeUsersRegistered, db.Users.AsNoTracking().Count());
}

View File

@@ -1,29 +1,35 @@
using Microsoft.EntityFrameworkCore;
using LightlessSyncServer.Hubs;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.Authorization;
using AspNetCoreRateLimit;
using LightlessSync.API.Data;
using LightlessSync.API.Dto.Group;
using LightlessSync.API.SignalR;
using LightlessSyncServer.Configuration;
using LightlessSyncServer.Controllers;
using LightlessSyncServer.Hubs;
using LightlessSyncServer.Services;
using LightlessSyncServer.Services.Interfaces;
using LightlessSyncServer.Worker;
using LightlessSyncShared.Data;
using LightlessSyncShared.Metrics;
using LightlessSyncServer.Services;
using LightlessSyncShared.Utils;
using LightlessSyncShared.RequirementHandlers;
using LightlessSyncShared.Services;
using Prometheus;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using StackExchange.Redis;
using StackExchange.Redis.Extensions.Core.Configuration;
using System.Net;
using StackExchange.Redis.Extensions.System.Text.Json;
using LightlessSync.API.SignalR;
using LightlessSyncShared.Utils;
using LightlessSyncShared.Utils.Configuration;
using MessagePack;
using MessagePack.Resolvers;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.Mvc.Controllers;
using LightlessSyncServer.Controllers;
using LightlessSyncShared.RequirementHandlers;
using LightlessSyncShared.Utils.Configuration;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Prometheus;
using StackExchange.Redis;
using StackExchange.Redis.Extensions.Core.Configuration;
using StackExchange.Redis.Extensions.System.Text.Json;
using System.Net;
using System.Text;
namespace LightlessSyncServer;
@@ -71,7 +77,7 @@ public class Startup
a.FeatureProviders.Remove(a.FeatureProviders.OfType<ControllerFeatureProvider>().First());
if (lightlessConfig.GetValue<Uri>(nameof(ServerConfiguration.MainServerAddress), defaultValue: null) == null)
{
a.FeatureProviders.Add(new AllowedControllersFeatureProvider(typeof(LightlessServerConfigurationController), typeof(LightlessBaseConfigurationController), typeof(ClientMessageController)));
a.FeatureProviders.Add(new AllowedControllersFeatureProvider(typeof(LightlessServerConfigurationController), typeof(LightlessBaseConfigurationController), typeof(ClientMessageController), typeof(UserController), typeof(GroupController)));
}
else
{
@@ -86,10 +92,14 @@ public class Startup
services.Configure<ServerConfiguration>(Configuration.GetRequiredSection("LightlessSync"));
services.Configure<LightlessConfigurationBase>(Configuration.GetRequiredSection("LightlessSync"));
services.Configure<BroadcastOptions>(Configuration.GetSection("Broadcast"));
services.Configure<ChatZoneOverridesOptions>(Configuration.GetSection("ChatZoneOverrides"));
services.AddSingleton<IBroadcastConfiguration, BroadcastConfiguration>();
services.AddSingleton<ServerTokenGenerator>();
services.AddSingleton<SystemInfoService>();
services.AddSingleton<OnlineSyncedPairCacheService>();
services.AddSingleton<ChatChannelService>();
services.AddHostedService(provider => provider.GetService<SystemInfoService>());
// configure services based on main server status
ConfigureServicesBasedOnShardType(services, lightlessConfig, isMainServer);
@@ -104,9 +114,13 @@ public class Startup
services.AddSingleton<CharaDataCleanupService>();
services.AddHostedService(provider => provider.GetService<CharaDataCleanupService>());
services.AddHostedService<ClientPairPermissionsCleanupService>();
services.AddScoped<PairService>();
services.AddScoped<IPruneService, PruneService>();
}
services.AddSingleton<GPoseLobbyDistributionService>();
services.AddHostedService<AutoPruneWorker>();
services.AddHostedService(provider => provider.GetService<GPoseLobbyDistributionService>());
}
@@ -115,6 +129,8 @@ public class Startup
services.AddSingleton<IUserIdProvider, IdBasedUserIdProvider>();
services.AddSingleton<ConcurrencyFilter>();
var msgpackOptions = MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4Block).WithResolver(ContractlessStandardResolver.Instance);
var signalRServiceBuilder = services.AddSignalR(hubOptions =>
{
hubOptions.MaximumReceiveMessageSize = long.MaxValue;
@@ -126,21 +142,10 @@ public class Startup
hubOptions.AddFilter<ConcurrencyFilter>();
}).AddMessagePackProtocol(opt =>
{
var resolver = CompositeResolver.Create(StandardResolverAllowPrivate.Instance,
BuiltinResolver.Instance,
AttributeFormatterResolver.Instance,
// replace enum resolver
DynamicEnumAsStringResolver.Instance,
DynamicGenericResolver.Instance,
DynamicUnionResolver.Instance,
DynamicObjectResolver.Instance,
PrimitiveObjectResolver.Instance,
// final fallback(last priority)
StandardResolver.Instance);
opt.SerializerOptions = msgpackOptions;
opt.SerializerOptions = MessagePackSerializerOptions.Standard
.WithCompression(MessagePackCompression.Lz4Block)
.WithResolver(resolver);
var dummy = new GroupPruneSettingsDto(new GroupData("TEST-GID", null), true, 14);
MessagePackSerializer.Serialize(dummy, msgpackOptions);
});
@@ -161,7 +166,7 @@ public class Startup
KeyPrefix = "",
Hosts = new RedisHost[]
{
new RedisHost(){ Host = address, Port = port },
new(){ Host = address, Port = port },
},
AllowAdmin = true,
ConnectTimeout = options.ConnectTimeout,
@@ -287,9 +292,12 @@ public class Startup
MetricsAPI.CounterUserPairCacheMiss,
MetricsAPI.CounterUserPairCacheNewEntries,
MetricsAPI.CounterUserPairCacheUpdatedEntries,
}, new List<string>
{
},
[
MetricsAPI.GaugeAuthorizedConnections,
MetricsAPI.GaugeLightFinderConnections,
MetricsAPI.GaugeLightFinderGroups,
MetricsAPI.GaugeGroupAutoPrunesEnabled,
MetricsAPI.GaugeConnections,
MetricsAPI.GaugePairs,
MetricsAPI.GaugePairsPaused,
@@ -305,7 +313,7 @@ public class Startup
MetricsAPI.GaugeGposeLobbyUsers,
MetricsAPI.GaugeHubConcurrency,
MetricsAPI.GaugeHubQueuedConcurrency,
}));
]));
}
private static void ConfigureServicesBasedOnShardType(IServiceCollection services, IConfigurationSection lightlessConfig, bool isMainServer)

View File

@@ -0,0 +1,26 @@
using System;
using System.Text.RegularExpressions;
namespace LightlessSyncServer.Utils;
internal static class ChatMessageFilter
{
private static readonly Regex UrlRegex = new(@"\b(?:https?://|www\.)\S+", RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static bool TryValidate(string? message, out string rejectionReason)
{
rejectionReason = string.Empty;
if (string.IsNullOrWhiteSpace(message))
{
return true;
}
if (UrlRegex.IsMatch(message))
{
rejectionReason = "Links are not permitted in chat.";
return false;
}
return true;
}
}

View File

@@ -1,6 +1,8 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User;
using LightlessSyncShared.Models;
using static LightlessSyncServer.Hubs.LightlessHub;
@@ -8,18 +10,97 @@ namespace LightlessSyncServer.Utils;
public static class Extensions
{
public static void UpdateProfileFromDto(this GroupProfile profile, GroupProfileDto dto, string? base64PictureString = null, string? base64BannerString = null)
{
ArgumentNullException.ThrowIfNull(profile);
ArgumentNullException.ThrowIfNull(dto);
if (profile == null || dto == null) return;
if (base64PictureString != null) profile.Base64GroupProfileImage = base64PictureString;
if (base64BannerString != null) profile.Base64GroupBannerImage = base64BannerString;
if (dto.Tags != null) profile.Tags = dto.Tags;
if (dto.Description != null) profile.Description = dto.Description;
if (dto.IsNsfw.HasValue) profile.IsNSFW = dto.IsNsfw.Value;
if (dto.IsDisabled.HasValue) profile.ProfileDisabled = dto.IsDisabled.Value;
}
public static void UpdateProfileFromDto(this UserProfileData profile, UserProfileDto dto, string? base64PictureString = null, string? base64BannerString = null)
{
ArgumentNullException.ThrowIfNull(profile);
ArgumentNullException.ThrowIfNull(dto);
if (profile == null || dto == null) return;
if (base64PictureString != null) profile.Base64ProfileImage = base64PictureString;
if (base64BannerString != null) profile.Base64BannerImage = base64BannerString;
if (dto.Tags != null) profile.Tags = dto.Tags;
if (dto.Description != null) profile.UserDescription = dto.Description;
if (dto.IsNSFW.HasValue) profile.IsNSFW = dto.IsNSFW.Value;
}
public static GroupProfileDto ToDTO(this GroupProfile groupProfile)
{
if (groupProfile == null)
{
return new GroupProfileDto(Group: null, Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: false, IsDisabled: false);
}
var groupData = groupProfile.Group?.ToGroupData()
?? (!string.IsNullOrWhiteSpace(groupProfile.GroupGID) ? new GroupData(groupProfile.GroupGID) : null);
return new GroupProfileDto(
groupData,
groupProfile.Description,
groupProfile.Tags,
groupProfile.Base64GroupProfileImage,
groupProfile.Base64GroupBannerImage,
groupProfile.IsNSFW,
groupProfile.ProfileDisabled
);
}
public static UserProfileDto ToDTO(this UserProfileData userProfileData)
{
if (userProfileData == null)
{
return new UserProfileDto(User: null, Disabled: false, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: null, Tags: []);
}
var userData = userProfileData.User?.ToUserData();
return new UserProfileDto(
userData,
userProfileData.ProfileDisabled,
userProfileData.IsNSFW,
userProfileData.Base64ProfileImage,
userProfileData.Base64BannerImage,
userProfileData.UserDescription,
userProfileData.Tags
);
}
public static GroupData ToGroupData(this Group group)
{
return new GroupData(group.GID, group.Alias);
if (group == null)
return null;
return new GroupData(group.GID, group.Alias, group.CreatedDate);
}
public static UserData ToUserData(this GroupPair pair)
{
if (pair == null)
return null;
return new UserData(pair.GroupUser.UID, pair.GroupUser.Alias);
}
public static UserData ToUserData(this User user)
{
if (user == null)
return null;
return new UserData(user.UID, user.Alias);
}
@@ -37,6 +118,7 @@ public static class Extensions
permissions.SetPreferDisableSounds(group.PreferDisableSounds);
permissions.SetPreferDisableVFX(group.PreferDisableVFX);
permissions.SetDisableInvites(!group.InvitesEnabled);
permissions.SetDisableChat(!group.ChatEnabled);
return permissions;
}

View File

@@ -1,5 +1,4 @@
using LightlessSync.API.SignalR;
using LightlessSyncServer.Hubs;
using LightlessSyncServer.Hubs;
using System.Runtime.CompilerServices;
namespace LightlessSyncServer.Utils;
@@ -31,4 +30,14 @@ public class LightlessHubLogger
string formattedArgs = args != null && args.Length != 0 ? "|" + string.Join(":", args) : string.Empty;
_logger.LogWarning("{uid}:{method}{args}", _hub.UserUID, methodName, formattedArgs);
}
public void LogError(Exception exception, string message, params object[] args)
{
_logger.LogError(exception, message, args);
}
public void LogError(string message, params object[] args)
{
_logger.LogError(message, args);
}
}

View File

@@ -0,0 +1,68 @@
using LightlessSync.API.Dto.Group;
using LightlessSyncServer.Hubs;
using LightlessSyncServer.Services.Interfaces;
using LightlessSyncServer.Utils;
using LightlessSyncShared.Data;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
namespace LightlessSyncServer.Worker
{
public class AutoPruneWorker(IServiceProvider services, ILogger<AutoPruneWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
var scope = services.CreateAsyncScope();
await using (scope.ConfigureAwait(false))
{
var db = scope.ServiceProvider.GetRequiredService<LightlessDbContext>();
var pruneService = scope.ServiceProvider.GetRequiredService<IPruneService>();
var hubContext = scope.ServiceProvider.GetRequiredService<IHubContext<LightlessHub>>();
var groups = await db.Groups
.Where(g => g.AutoPruneEnabled)
.ToListAsync(stoppingToken).ConfigureAwait(false);
foreach (var group in groups)
{
var allGroupUsers = await db.GroupPairs
.Include(p => p.GroupUser)
.Include(p => p.Group)
.Where(p => p.GroupGID == group.GID)
.ToListAsync(stoppingToken).ConfigureAwait(false);
var prunedPairs = await pruneService.ExecutePruneAsync(group.GID, group.AutoPruneDays, stoppingToken).ConfigureAwait(false);
if (prunedPairs.Count == 0)
continue;
var remainingUserIds = allGroupUsers
.Where(p => !prunedPairs.Any(x => string.Equals(x.GroupUserUID, p.GroupUserUID, StringComparison.Ordinal)))
.Select(p => p.GroupUserUID)
.Distinct(StringComparer.Ordinal)
.ToList();
foreach (var pair in prunedPairs)
{
await hubContext.Clients.Users(remainingUserIds).SendAsync("Client_GroupPairLeft", new GroupPairDto(group.ToGroupData(), pair.GroupUser.ToUserData()), stoppingToken).ConfigureAwait(false);
}
logger.LogInformation("Auto-pruned {Count} users from group {GroupId}", prunedPairs.Count, group.GID);
}
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error in auto-prune worker");
}
//Run task each hour to check for pruning
await Task.Delay(TimeSpan.FromHours(1), stoppingToken).ConfigureAwait(false);
}
}
}
}

View File

@@ -7,6 +7,7 @@
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
"LightlessSyncServer.Authentication": "Warning",
"System.IO.IOException": "Warning"
},
@@ -29,6 +30,20 @@
"ServiceAddress": "http://localhost:5002",
"StaticFileServiceAddress": "http://localhost:5003"
},
"Broadcast": {
"RedisKeyPrefix": "broadcast:",
"EntryTtlSeconds": 10800,
"MaxStatusBatchSize": 30,
"NotifyOwnerOnPairRequest": true,
"EnableBroadcasting": true,
"EnableSyncshellBroadcastPayloads": true,
"PairRequestNotificationTemplate": "{DisplayName} sent you a pair request. To accept, right-click them, open the context menu, and send a request back.",
"PairRequestRateLimit": 5,
"PairRequestRateWindow": 60
},
"ChatZoneOverrides": {
"Zones": []
},
"AllowedHosts": "*",
"Kestrel": {
"Endpoints": {

View File

@@ -1,5 +1,5 @@
using FluentAssertions;
using LightlessSyncServer.Discord;
using LightlessSyncServices.Discord;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

View File

@@ -1,19 +1,28 @@
using Discord;
using System.Globalization;
using System.Text;
using System.Text.Json;
using Discord;
using Discord.Interactions;
using Discord.Rest;
using Discord.WebSocket;
using LightlessSyncShared.Data;
using LightlessSyncShared.Models;
using LightlessSyncShared.Services;
using LightlessSync.API.Dto.Chat;
using LightlessSyncShared.Utils.Configuration;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json.Linq;
using StackExchange.Redis;
namespace LightlessSyncServices.Discord;
internal class DiscordBot : IHostedService
{
private static readonly JsonSerializerOptions ChatReportSerializerOptions = new(JsonSerializerDefaults.General)
{
PropertyNameCaseInsensitive = true
};
private const string ChatReportButtonPrefix = "lightless-chat-report-button";
private readonly DiscordBotServices _botServices;
private readonly IConfigurationService<ServicesConfiguration> _configurationService;
private readonly IConnectionMultiplexer _connectionMultiplexer;
@@ -22,7 +31,7 @@ internal class DiscordBot : IHostedService
private readonly IDbContextFactory<LightlessDbContext> _dbContextFactory;
private readonly IServiceProvider _services;
private InteractionService _interactionModule;
private readonly CancellationTokenSource? _processReportQueueCts;
private CancellationTokenSource? _chatReportProcessingCts;
private CancellationTokenSource? _clientConnectedCts;
public DiscordBot(DiscordBotServices botServices, IServiceProvider services, IConfigurationService<ServicesConfiguration> configuration,
@@ -67,6 +76,7 @@ internal class DiscordBot : IHostedService
var ctx = new SocketInteractionContext(_discordClient, x);
await _interactionModule.ExecuteCommandAsync(ctx, _services).ConfigureAwait(false);
};
_discordClient.ButtonExecuted += OnChatReportButton;
_discordClient.UserJoined += OnUserJoined;
await _botServices.Start().ConfigureAwait(false);
@@ -95,9 +105,11 @@ internal class DiscordBot : IHostedService
if (!string.IsNullOrEmpty(_configurationService.GetValueOrDefault(nameof(ServicesConfiguration.DiscordBotToken), string.Empty)))
{
await _botServices.Stop().ConfigureAwait(false);
_processReportQueueCts?.Cancel();
_chatReportProcessingCts?.Cancel();
_chatReportProcessingCts?.Dispose();
_clientConnectedCts?.Cancel();
_discordClient.ButtonExecuted -= OnChatReportButton;
await _discordClient.LogoutAsync().ConfigureAwait(false);
await _discordClient.StopAsync().ConfigureAwait(false);
_interactionModule?.Dispose();
@@ -113,6 +125,13 @@ internal class DiscordBot : IHostedService
_clientConnectedCts = new();
_ = UpdateStatusAsync(_clientConnectedCts.Token);
_chatReportProcessingCts?.Cancel();
_chatReportProcessingCts?.Dispose();
_chatReportProcessingCts = new();
_ = PollChatReportsAsync(_chatReportProcessingCts.Token);
await PublishChatReportsAsync(CancellationToken.None).ConfigureAwait(false);
await CreateOrUpdateModal(guild).ConfigureAwait(false);
_botServices.UpdateGuild(guild);
await _botServices.LogToChannel("Bot startup complete.").ConfigureAwait(false);
@@ -121,6 +140,358 @@ internal class DiscordBot : IHostedService
_ = RemoveUnregisteredUsers(_clientConnectedCts.Token);
}
private async Task PollChatReportsAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
await PublishChatReportsAsync(token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed while polling chat reports");
}
try
{
await Task.Delay(TimeSpan.FromMinutes(1), token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}
private async Task PublishChatReportsAsync(CancellationToken token)
{
var reportChannelId = _configurationService.GetValueOrDefault(nameof(ServicesConfiguration.DiscordChannelForChatReports), (ulong?)null);
if (reportChannelId is null)
{
return;
}
var channel = await _discordClient.Rest.GetChannelAsync(reportChannelId.Value).ConfigureAwait(false) as RestTextChannel;
if (channel is null)
{
_logger.LogWarning("Configured chat report channel {ChannelId} could not be resolved.", reportChannelId);
return;
}
using var dbContext = await _dbContextFactory.CreateDbContextAsync(token).ConfigureAwait(false);
var pendingReports = await dbContext.ReportedChatMessages
.Where(r => !r.Resolved && r.DiscordMessageId == null)
.OrderBy(r => r.ReportTimeUtc)
.Take(10)
.ToListAsync(token)
.ConfigureAwait(false);
if (pendingReports.Count == 0)
{
return;
}
foreach (var report in pendingReports)
{
var embed = await BuildChatReportEmbedAsync(dbContext, report, token).ConfigureAwait(false);
var components = new ComponentBuilder()
.WithButton("Resolve", $"{ChatReportButtonPrefix}-resolve-{report.ReportId}", ButtonStyle.Danger)
.WithButton("Dismiss", $"{ChatReportButtonPrefix}-dismiss-{report.ReportId}", ButtonStyle.Secondary)
.WithButton("Ban From Chat", $"{ChatReportButtonPrefix}-banchat-{report.ReportId}", ButtonStyle.Danger);
var postedMessage = await channel.SendMessageAsync(embed: embed.Build(), components: components.Build()).ConfigureAwait(false);
report.DiscordMessageId = postedMessage.Id;
report.DiscordMessagePostedAtUtc = DateTime.UtcNow;
}
await dbContext.SaveChangesAsync(token).ConfigureAwait(false);
}
private async Task<EmbedBuilder> BuildChatReportEmbedAsync(LightlessDbContext dbContext, ReportedChatMessage report, CancellationToken token)
{
var reporter = await FormatUserForEmbedAsync(dbContext, report.ReporterUserUid, token).ConfigureAwait(false);
var reportedUser = await FormatUserForEmbedAsync(dbContext, report.ReportedUserUid, token).ConfigureAwait(false);
var channelDescription = await DescribeChannelAsync(dbContext, report, token).ConfigureAwait(false);
var embed = new EmbedBuilder()
.WithTitle("Chat Report")
.WithColor(Color.DarkTeal)
.WithTimestamp(report.ReportTimeUtc)
.AddField("Report ID", report.ReportId, inline: true)
.AddField("Reporter", reporter, inline: true)
.AddField("Reported User", string.IsNullOrEmpty(reportedUser) ? "-" : reportedUser, inline: true)
.AddField("Channel", channelDescription, inline: false)
.AddField("Reason", string.IsNullOrWhiteSpace(report.Reason) ? "-" : report.Reason);
if (!string.IsNullOrWhiteSpace(report.AdditionalContext))
{
embed.AddField("Additional Context", report.AdditionalContext);
}
embed.AddField("Message", $"```{Truncate(report.MessageContent, 1000)}```");
var snapshotPreview = BuildSnapshotPreview(report.SnapshotJson);
if (!string.IsNullOrEmpty(snapshotPreview))
{
embed.AddField("Recent Activity", snapshotPreview);
}
embed.WithFooter($"Message ID: {report.MessageId}");
return embed;
}
private async Task<string> DescribeChannelAsync(LightlessDbContext dbContext, ReportedChatMessage report, CancellationToken token)
{
if (report.ChannelType == ChatChannelType.Group)
{
if (!string.IsNullOrEmpty(report.ChannelKey))
{
var group = await dbContext.Groups.AsNoTracking()
.SingleOrDefaultAsync(g => g.GID == report.ChannelKey, token)
.ConfigureAwait(false);
if (group != null)
{
var name = string.IsNullOrWhiteSpace(group.Alias) ? group.GID : group.Alias;
return $"Group: {name} ({group.GID})";
}
}
return $"Group: {report.ChannelKey ?? "unknown"}";
}
return $"Zone: {report.ChannelKey ?? "unknown"} (World {report.WorldId}, Zone {report.ZoneId})";
}
private async Task<string> FormatUserForEmbedAsync(LightlessDbContext dbContext, string? userUid, CancellationToken token)
{
if (string.IsNullOrEmpty(userUid))
{
return "-";
}
var user = await dbContext.Users.AsNoTracking()
.SingleOrDefaultAsync(u => u.UID == userUid, token)
.ConfigureAwait(false);
var display = user?.Alias ?? user?.UID ?? userUid;
var lodestone = await dbContext.LodeStoneAuth
.Include(l => l.User)
.AsNoTracking()
.SingleOrDefaultAsync(l => l.User != null && l.User.UID == userUid, token)
.ConfigureAwait(false);
if (lodestone != null)
{
display = $"{display} (<@{lodestone.DiscordId}>)";
}
return display;
}
private string BuildSnapshotPreview(string snapshotJson)
{
if (string.IsNullOrWhiteSpace(snapshotJson))
{
return string.Empty;
}
try
{
var snapshot = JsonSerializer.Deserialize<List<ChatReportSnapshotItem>>(snapshotJson, ChatReportSerializerOptions);
if (snapshot is null || snapshot.Count == 0)
{
return string.Empty;
}
var builder = new StringBuilder();
foreach (var item in snapshot.TakeLast(5))
{
var sender = item.SenderAlias ?? item.SenderUserUid;
builder.AppendLine($"{item.SentAtUtc:HH\\:mm} {sender}: {Truncate(item.Message, 120)}");
}
return $"```{builder.ToString().TrimEnd()}```";
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse chat report snapshot");
return string.Empty;
}
}
private static string Truncate(string value, int maxLength)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
{
return value;
}
return value[..maxLength] + "...";
}
private async Task OnChatReportButton(SocketMessageComponent arg)
{
if (!arg.Data.CustomId.StartsWith(ChatReportButtonPrefix, StringComparison.Ordinal))
{
return;
}
if (arg.GuildId is null)
{
await arg.RespondAsync("This action is only available inside the server.", ephemeral: true).ConfigureAwait(false);
return;
}
var guild = _discordClient.GetGuild(arg.GuildId.Value);
if (guild is null)
{
await arg.RespondAsync("Unable to resolve the guild for this interaction.", ephemeral: true).ConfigureAwait(false);
return;
}
var guildUser = guild.GetUser(arg.User.Id);
if (guildUser is null || !(guildUser.GuildPermissions.ManageMessages || guildUser.GuildPermissions.BanMembers || guildUser.GuildPermissions.Administrator))
{
await arg.RespondAsync("You do not have permission to resolve chat reports.", ephemeral: true).ConfigureAwait(false);
return;
}
var parts = arg.Data.CustomId.Split('-', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 5 || !int.TryParse(parts[^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var reportId))
{
await arg.RespondAsync("Invalid report action.", ephemeral: true).ConfigureAwait(false);
return;
}
var action = parts[^2];
await using var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
var report = await dbContext.ReportedChatMessages.SingleOrDefaultAsync(r => r.ReportId == reportId).ConfigureAwait(false);
if (report is null)
{
await arg.RespondAsync("This report could not be found.", ephemeral: true).ConfigureAwait(false);
return;
}
if (report.Resolved)
{
await arg.RespondAsync("This report has already been processed.", ephemeral: true).ConfigureAwait(false);
return;
}
string resolutionLabel;
switch (action)
{
case "resolve":
resolutionLabel = "Resolved";
break;
case "dismiss":
resolutionLabel = "Dismissed";
break;
case "banchat":
resolutionLabel = "Chat access revoked";
if (!string.IsNullOrEmpty(report.ReportedUserUid))
{
var targetUser = await dbContext.Users.SingleOrDefaultAsync(u => u.UID == report.ReportedUserUid).ConfigureAwait(false);
if (targetUser is not null && !targetUser.ChatBanned)
{
targetUser.ChatBanned = true;
dbContext.Update(targetUser);
}
}
break;
default:
await arg.RespondAsync("Unknown action.", ephemeral: true).ConfigureAwait(false);
return;
}
try
{
await UpdateChatReportMessageAsync(report, action, guildUser).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to update Discord message for resolved report {ReportId}", report.ReportId);
}
dbContext.ReportedChatMessages.Remove(report);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
string responseText = action switch
{
"resolve" => "resolved",
"dismiss" => "dismissed",
"banchat" => "chat access revoked",
_ => "processed"
};
await arg.RespondAsync($"Report {report.ReportId} {responseText}.", ephemeral: true).ConfigureAwait(false);
}
private async Task UpdateChatReportMessageAsync(ReportedChatMessage report, string action, SocketGuildUser moderator)
{
if (report.DiscordMessageId is null)
{
return;
}
var reportChannelId = _configurationService.GetValueOrDefault(nameof(ServicesConfiguration.DiscordChannelForChatReports), (ulong?)null);
if (reportChannelId is null)
{
return;
}
var channel = await _discordClient.Rest.GetChannelAsync(reportChannelId.Value).ConfigureAwait(false) as RestTextChannel;
if (channel is null)
{
return;
}
var message = await channel.GetMessageAsync(report.DiscordMessageId.Value).ConfigureAwait(false) as IUserMessage;
if (message is null)
{
return;
}
var existingEmbed = message.Embeds.FirstOrDefault();
var embedBuilder = existingEmbed is Embed richEmbed
? richEmbed.ToEmbedBuilder()
: new EmbedBuilder().WithTitle("Chat Report");
embedBuilder.Fields.RemoveAll(f => string.Equals(f.Name, "Resolution", StringComparison.OrdinalIgnoreCase));
var resolutionText = action switch
{
"resolve" => "Resolved",
"dismiss" => "Dismissed",
"banchat" => "Chat access revoked",
_ => "Processed"
};
var resolutionColor = action switch
{
"resolve" => Color.DarkRed,
"dismiss" => Color.Green,
"banchat" => Color.DarkRed,
_ => Color.LightGrey
};
embedBuilder.AddField("Resolution", $"{resolutionText} by {moderator.Mention} at <t:{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}:F>");
embedBuilder.WithColor(resolutionColor);
await message.ModifyAsync(props =>
{
props.Embed = embedBuilder.Build();
props.Components = new ComponentBuilder().Build();
}).ConfigureAwait(false);
}
private async Task UpdateVanityRoles(RestGuild guild, CancellationToken token)
{
while (!token.IsCancellationRequested)
@@ -385,13 +756,50 @@ internal class DiscordBot : IHostedService
_logger.LogInformation($"Checking Group: {group.GID} [{group.Alias}], owned by {group.OwnerUID} ({groupPrimaryUser}), User in Roles: {string.Join(", ", discordUser?.RoleIds ?? new List<ulong>())}");
if (lodestoneUser == null || discordUser == null || !discordUser.RoleIds.Any(allowedRoleIds.Keys.Contains))
var hasAllowedRole = lodestoneUser != null && discordUser != null && discordUser.RoleIds.Any(allowedRoleIds.Keys.Contains);
if (!hasAllowedRole)
{
await _botServices.LogToChannel($"VANITY GID REMOVAL: <@{lodestoneUser?.DiscordId ?? 0}> ({lodestoneUser?.User?.UID}) - GID: {group.GID}, Vanity: {group.Alias}").ConfigureAwait(false);
_logger.LogInformation($"User {lodestoneUser?.User?.UID ?? "unknown"} not in allowed roles, deleting group alias for {group.GID}");
group.Alias = null;
db.Update(group);
if (lodestoneUser?.User != null)
{
lodestoneUser.User.HasVanity = false;
db.Update(lodestoneUser.User);
var secondaryUsers = await db.Auth.Include(u => u.User)
.Where(u => u.PrimaryUserUID == lodestoneUser.User.UID).ToListAsync().ConfigureAwait(false);
foreach (var secondaryUser in secondaryUsers)
{
secondaryUser.User.HasVanity = false;
db.Update(secondaryUser.User);
}
}
await db.SaveChangesAsync(token).ConfigureAwait(false);
}
else if (lodestoneUser?.User != null && !lodestoneUser.User.HasVanity)
{
lodestoneUser.User.HasVanity = true;
db.Update(lodestoneUser.User);
var secondaryUsers = await db.Auth.Include(u => u.User)
.Where(u => u.PrimaryUserUID == lodestoneUser.User.UID).ToListAsync().ConfigureAwait(false);
foreach (var secondaryUser in secondaryUsers)
{
if (!secondaryUser.User.HasVanity)
{
secondaryUser.User.HasVanity = true;
db.Update(secondaryUser.User);
}
}
await db.SaveChangesAsync(token).ConfigureAwait(false);
}
}
@@ -401,34 +809,77 @@ internal class DiscordBot : IHostedService
var discordUser = await restGuild.GetUserAsync(lodestoneAuth.DiscordId).ConfigureAwait(false);
_logger.LogInformation($"Checking User: {lodestoneAuth.DiscordId}, {lodestoneAuth.User.UID} ({lodestoneAuth.User.Alias}), User in Roles: {string.Join(", ", discordUser?.RoleIds ?? new List<ulong>())}");
if (discordUser == null || !discordUser.RoleIds.Any(u => allowedRoleIds.Keys.Contains(u)))
var hasAllowedRole = discordUser != null && discordUser.RoleIds.Any(u => allowedRoleIds.Keys.Contains(u));
if (!hasAllowedRole)
{
_logger.LogInformation($"User {lodestoneAuth.User.UID} not in allowed roles, deleting alias");
await _botServices.LogToChannel($"VANITY UID REMOVAL: <@{lodestoneAuth.DiscordId}> - UID: {lodestoneAuth.User.UID}, Vanity: {lodestoneAuth.User.Alias}").ConfigureAwait(false);
lodestoneAuth.User.Alias = null;
lodestoneAuth.User.HasVanity = false;
var secondaryUsers = await db.Auth.Include(u => u.User).Where(u => u.PrimaryUserUID == lodestoneAuth.User.UID).ToListAsync().ConfigureAwait(false);
foreach (var secondaryUser in secondaryUsers)
{
_logger.LogInformation($"Secondary User {secondaryUser.User.UID} not in allowed roles, deleting alias");
secondaryUser.User.Alias = null;
secondaryUser.User.HasVanity = false;
db.Update(secondaryUser.User);
}
db.Update(lodestoneAuth.User);
await db.SaveChangesAsync(token).ConfigureAwait(false);
}
}
private async Task UpdateStatusAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
else
{
var endPoint = _connectionMultiplexer.GetEndPoints().First();
var onlineUsers = await _connectionMultiplexer.GetServer(endPoint).KeysAsync(pattern: "UID:*").CountAsync().ConfigureAwait(false);
var secondaryUsers = await db.Auth.Include(u => u.User)
.Where(u => u.PrimaryUserUID == lodestoneAuth.User.UID).ToListAsync().ConfigureAwait(false);
_logger.LogInformation("Users online: " + onlineUsers);
await _discordClient.SetActivityAsync(new Game("Lightless for " + onlineUsers + " Users")).ConfigureAwait(false);
await Task.Delay(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
var hasChanges = false;
if (!lodestoneAuth.User.HasVanity)
{
lodestoneAuth.User.HasVanity = true;
db.Update(lodestoneAuth.User);
hasChanges = true;
}
foreach (var secondaryUser in secondaryUsers)
{
if (!secondaryUser.User.HasVanity)
{
secondaryUser.User.HasVanity = true;
db.Update(secondaryUser.User);
hasChanges = true;
}
}
if (hasChanges)
{
await db.SaveChangesAsync(token).ConfigureAwait(false);
}
}
}
}
private sealed record ChatReportSnapshotItem(
string MessageId,
DateTime SentAtUtc,
string SenderUserUid,
string? SenderAlias,
bool SenderIsLightfinder,
string? SenderHashedCid,
string Message);
private async Task UpdateStatusAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
var endPoint = _connectionMultiplexer.GetEndPoints().First();
var keys = _connectionMultiplexer.GetServer(endPoint).KeysAsync(pattern: "UID:*");
var onlineUsers = await keys.CountAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Users online: " + onlineUsers);
await _discordClient.SetActivityAsync(new Game("Lightless for " + onlineUsers + " Users")).ConfigureAwait(false);
await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,655 @@
using Discord;
using Discord.Interactions;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User;
using LightlessSyncShared.Data;
using LightlessSyncShared.Models;
using LightlessSyncShared.Services;
using LightlessSyncShared.Utils;
using LightlessSyncShared.Utils.Configuration;
using Microsoft.EntityFrameworkCore;
using Prometheus;
using StackExchange.Redis;
using System.Net.Http.Json;
namespace LightlessSyncServices.Discord;
public class LightlessModule : InteractionModuleBase
{
private readonly ILogger<LightlessModule> _logger;
private readonly IServiceProvider _services;
private readonly IConfigurationService<ServicesConfiguration> _lightlessServicesConfiguration;
private readonly IConnectionMultiplexer _connectionMultiplexer;
private readonly ServerTokenGenerator _serverTokenGenerator;
public LightlessModule(ILogger<LightlessModule> logger, IServiceProvider services,
IConfigurationService<ServicesConfiguration> lightlessServicesConfiguration,
IConnectionMultiplexer connectionMultiplexer, ServerTokenGenerator serverTokenGenerator)
{
_logger = logger;
_services = services;
_lightlessServicesConfiguration = lightlessServicesConfiguration;
_connectionMultiplexer = connectionMultiplexer;
_serverTokenGenerator = serverTokenGenerator;
}
[SlashCommand("userinfo", "Shows you your user information")]
public async Task UserInfo([Summary("secondary_uid", "(Optional) Your secondary UID")] string? secondaryUid = null,
[Summary("discord_user", "ADMIN ONLY: Discord User to check for")] IUser? discordUser = null,
[Summary("uid", "ADMIN ONLY: UID to check for")] string? uid = null)
{
_logger.LogInformation("SlashCommand:{userId}:{Method}",
Context.Interaction.User.Id, nameof(UserInfo));
try
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<LightlessDbContext>();
await using (db.ConfigureAwait(false))
{
var (mainEmbed, profileEmbed) = await HandleUserInfo(db, Context.User.Id, secondaryUid, discordUser?.Id ?? null, uid);
string uidToGet = await GetUserUID(db, secondaryUid, discordUser?.Id ?? null, uid).ConfigureAwait(false);
var profileData = await GetUserProfileData(db, uidToGet).ConfigureAwait(false);
List<Embed> embeds = new() { mainEmbed };
if (profileEmbed != null)
{
embeds.Add(profileEmbed);
}
if (profileData != null)
{
byte[] profileImage = GetProfileImage(profileData);
byte[] bannerImage = GetBannerImage(profileData);
using MemoryStream profileImgStream = new(profileImage);
using MemoryStream bannerImgStream = new(bannerImage);
var mainEmbedData = embeds[0];
var mainEmbedBuilder = new EmbedBuilder()
.WithTitle(mainEmbedData.Title)
.WithDescription(mainEmbedData.Description)
.WithThumbnailUrl("attachment://profileimage.png")
.WithImageUrl("attachment://bannerimage.png");
if (mainEmbedData.Fields != null)
{
foreach (var field in mainEmbedData.Fields)
{
mainEmbedBuilder.AddField(field.Name, field.Value, field.Inline);
}
}
embeds[0] = mainEmbedBuilder.Build();
await RespondWithFilesAsync(
new[] { new FileAttachment(profileImgStream, "profileimage.png"), new FileAttachment(bannerImgStream, "bannerimage.png") },
embeds: embeds.ToArray(),
ephemeral: true).ConfigureAwait(false);
}
else
{
await RespondAsync(
embeds: embeds.ToArray(),
ephemeral: true).ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
EmbedBuilder eb = new();
eb.WithTitle("An error occured");
eb.WithDescription("Please report this error to bug-reports: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine);
await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false);
}
}
[SlashCommand("groupinfo", "Shows you your group profile information")]
public async Task GroupInfo([Summary("gid", "ADMIN ONLY: GID to check for")] string? uid = null)
{
_logger.LogInformation("SlashCommand:{userId}:{Method}",
Context.Interaction.User.Id, nameof(GroupInfo));
try
{
EmbedBuilder eb = new();
//eb = await HandleUserInfo(eb, Context.User.Id, secondaryUid, discordUser?.Id ?? null, uid);
await RespondAsync(embeds: new[] { eb.Build() }, ephemeral: true).ConfigureAwait(false);
}
catch (Exception ex)
{
EmbedBuilder eb = new();
eb.WithTitle("An error occured");
eb.WithDescription("Please report this error to bug-reports: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine);
await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false);
}
}
[SlashCommand("useradd", "ADMIN ONLY: add a user unconditionally to the Database")]
public async Task UserAdd([Summary("desired_uid", "Desired UID")] string desiredUid)
{
_logger.LogInformation("SlashCommand:{userId}:{Method}:{params}",
Context.Interaction.User.Id, nameof(UserAdd),
string.Join(",", new[] { $"{nameof(desiredUid)}:{desiredUid}" }));
try
{
var embed = await HandleUserAdd(desiredUid, Context.User.Id);
await RespondAsync(embeds: new[] { embed }, ephemeral: true).ConfigureAwait(false);
}
catch (Exception ex)
{
EmbedBuilder eb = new();
eb.WithTitle("An error occured");
eb.WithDescription("Please report this error to bug-reports: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine);
await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false);
}
}
[SlashCommand("message", "ADMIN ONLY: sends a message to clients")]
public async Task SendMessageToClients([Summary("message", "Message to send")] string message,
[Summary("severity", "Severity of the message")] MessageSeverity messageType = MessageSeverity.Information,
[Summary("uid", "User ID to the person to send the message to")] string? uid = null)
{
_logger.LogInformation("SlashCommand:{userId}:{Method}:{message}:{type}:{uid}", Context.Interaction.User.Id, nameof(SendMessageToClients), message, messageType, uid);
using var scope = _services.CreateScope();
using var db = scope.ServiceProvider.GetService<LightlessDbContext>();
if (!(await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(a => a.DiscordId == Context.Interaction.User.Id))?.User?.IsAdmin ?? true)
{
await RespondAsync("No permission", ephemeral: true).ConfigureAwait(false);
return;
}
if (!string.IsNullOrEmpty(uid) && !await db.Users.AnyAsync(u => u.UID == uid))
{
await RespondAsync("Specified UID does not exist", ephemeral: true).ConfigureAwait(false);
return;
}
try
{
using HttpClient c = new HttpClient();
c.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _serverTokenGenerator.Token);
var testUri = new Uri(_lightlessServicesConfiguration.GetValue<Uri>
(nameof(ServicesConfiguration.MainServerAddress)), "/msgc/sendMessage");
using (await c.PostAsJsonAsync(
new Uri(_lightlessServicesConfiguration.GetValue<Uri>(nameof(ServicesConfiguration.MainServerAddress)), "/msgc/sendMessage"),
new ClientMessage(messageType, message, uid ?? string.Empty)
).ConfigureAwait(false)) { }
var discordChannelForMessages = _lightlessServicesConfiguration.GetValueOrDefault<ulong?>(nameof(ServicesConfiguration.DiscordChannelForMessages), null);
if (uid == null && discordChannelForMessages != null)
{
var discordChannel = await Context.Guild.GetChannelAsync(discordChannelForMessages.Value) as IMessageChannel;
if (discordChannel != null)
{
var embedColor = messageType switch
{
MessageSeverity.Information => Color.Blue,
MessageSeverity.Warning => new Color(255, 255, 0),
MessageSeverity.Error => Color.Red,
_ => Color.Blue
};
EmbedBuilder eb = new();
eb.WithTitle(messageType + " server message");
eb.WithColor(embedColor);
eb.WithDescription(message);
await discordChannel.SendMessageAsync(embed: eb.Build());
}
}
await RespondAsync("Message sent", ephemeral: true).ConfigureAwait(false);
}
catch (Exception ex)
{
await RespondAsync("Failed to send message: " + ex.ToString(), ephemeral: true).ConfigureAwait(false);
}
}
[SlashCommand("serviceunban", "ADMIN ONLY: Unban a user by their discord ID or user ID [CHOOSE ONE ONLY]")]
public async Task ServiceUnban(
[Summary("discord_id", "Discord ID to unban")] string? discordId = null,
[Summary("uid", "UID to unban")] string? uid = null
)
{
_logger.LogInformation("SlashCommand:{userId}:{Method}:{params}",
Context.Interaction.User.Id, nameof(ServiceUnban),
string.Join(",", new[] { $"{nameof(discordId)}:{discordId}", $"{nameof(uid)}:{uid}" }));
try
{
using HttpClient c = new HttpClient();
c.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _serverTokenGenerator.Token);
string endpoint;
UnbanRequest unbanRequest;
if (!string.IsNullOrEmpty(uid))
{
endpoint = "/user/unbanUID";
unbanRequest = new UnbanRequest(uid, string.Empty);
}
else if (!string.IsNullOrEmpty(discordId))
{
endpoint = "/user/unbanDiscord";
unbanRequest = new UnbanRequest(string.Empty, discordId);
}
else
{
await RespondAsync("You must provide either a UID or Discord ID.", ephemeral: true).ConfigureAwait(false);
return;
}
using (await c.PostAsJsonAsync(
new Uri(_lightlessServicesConfiguration.GetValue<Uri>(nameof(ServicesConfiguration.MainServerAddress)), endpoint),
unbanRequest).ConfigureAwait(false))
{
}
var discordChannelForMessages = _lightlessServicesConfiguration.GetValueOrDefault<ulong?>(nameof(ServicesConfiguration.DiscordChannelForMessages), null);
if (discordChannelForMessages != null)
{
var discordChannel = await Context.Guild.GetChannelAsync(discordChannelForMessages.Value).ConfigureAwait(false) as IMessageChannel;
if (discordChannel != null)
{
var embedColor = Color.Blue;
String idToUse = !string.IsNullOrEmpty(uid) ? uid : discordId;
EmbedBuilder eb = new();
eb.WithTitle("Unban Alert!");
eb.WithColor(embedColor);
eb.WithDescription(idToUse + " has been unbanned");
await discordChannel.SendMessageAsync(embed: eb.Build()).ConfigureAwait(false);
}
}
await RespondAsync("Message sent", ephemeral: true).ConfigureAwait(false);
}
catch (Exception ex)
{
EmbedBuilder eb = new();
eb.WithTitle("An error occured");
eb.WithDescription("Please report this: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine);
await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false);
}
}
[SlashCommand("serviceban", "ADMIN ONLY: ban a user by their uid")]
public async Task ServiceBan([Summary("uid", "uid to ban")] string uid)
{
_logger.LogInformation("SlashCommand:{userId}:{Method}:{params}",
Context.Interaction.User.Id, nameof(ServiceBan),
string.Join(",", new[] { $"{nameof(uid)}:{uid}" }));
try
{
using HttpClient c = new HttpClient();
c.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _serverTokenGenerator.Token);
using (await c.PostAsJsonAsync(new Uri(_lightlessServicesConfiguration.GetValue<Uri>
(nameof(ServicesConfiguration.MainServerAddress)), "/user/ban"), new BanRequest(uid))
.ConfigureAwait(false)) { }
var discordChannelForMessages = _lightlessServicesConfiguration.GetValueOrDefault<ulong?>(nameof(ServicesConfiguration.DiscordChannelForMessages), null);
if (discordChannelForMessages != null)
{
var discordChannel = await Context.Guild.GetChannelAsync(discordChannelForMessages.Value).ConfigureAwait(false) as IMessageChannel;
if (discordChannel != null)
{
var embedColor = Color.Blue;
EmbedBuilder eb = new();
eb.WithTitle("Ban Alert!");
eb.WithColor(embedColor);
eb.WithDescription(uid + " has been marked for ban");
await discordChannel.SendMessageAsync(embed: eb.Build()).ConfigureAwait(false);
}
}
await RespondAsync("Message sent", ephemeral: true).ConfigureAwait(false);
}
catch (Exception ex)
{
EmbedBuilder eb = new();
eb.WithTitle("An error occured");
eb.WithDescription("Please report this: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine);
await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false);
}
}
[SlashCommand("toggleuserprofile", "ADMIN ONLY: disable a user profile by their uid")]
public async Task ToggleUserProfile(
[Summary("uid", "uid to disable")] string uid,
[Summary("toggle", "Enable or Disable the profile")]
[Choice("Enable", "Enable")]
[Choice("Disable", "Disable")] string toggle
)
{
_logger.LogInformation("SlashCommand:{userId}:{Method}:{params}",
Context.Interaction.User.Id, nameof(ToggleUserProfile),
string.Join(",", new[] { $"{nameof(uid)}:{uid}" }));
try
{
using HttpClient c = new HttpClient();
c.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _serverTokenGenerator.Token);
string endpoint = string.Equals(toggle, "Enable", StringComparison.Ordinal) ? "/user/enableProfile" : "/user/disableProfile";
using (await c.PostAsJsonAsync(new Uri(_lightlessServicesConfiguration.GetValue<Uri>
(nameof(ServicesConfiguration.MainServerAddress)), endpoint), new UserProfileAvailabilityRequest(uid))
.ConfigureAwait(false)) { }
var discordChannelForMessages = _lightlessServicesConfiguration.GetValueOrDefault<ulong?>(nameof(ServicesConfiguration.DiscordChannelForMessages), null);
if (discordChannelForMessages != null)
{
var discordChannel = await Context.Guild.GetChannelAsync(discordChannelForMessages.Value).ConfigureAwait(false) as IMessageChannel;
if (discordChannel != null)
{
var embedColor = Color.Blue;
var action = string.Equals(toggle, "Enable", StringComparison.Ordinal) ? "enabled" : "disabled";
EmbedBuilder eb = new();
eb.WithTitle($"Profile {action}");
eb.WithColor(embedColor);
eb.WithDescription($"{uid}'s profile has been {action}");
await discordChannel.SendMessageAsync(embed: eb.Build()).ConfigureAwait(false);
}
}
await RespondAsync("Message sent", ephemeral: true).ConfigureAwait(false);
}
catch (Exception ex)
{
EmbedBuilder eb = new();
eb.WithTitle("An error occured");
eb.WithDescription("Please report this: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine);
await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false);
}
}
[SlashCommand("togglegroupprofile", "ADMIN ONLY: toggle a group profile by their gid")]
public async Task ToggleGroupProfile(
[Summary("gid", "gid to disable")] string gid,
[Summary("toggle", "Enable or Disable the profile")]
[Choice("Enable", "Enable")]
[Choice("Disable", "Disable")] string toggle
)
{
_logger.LogInformation("SlashCommand:{userId}:{Method}:{params}",
Context.Interaction.User.Id, nameof(ToggleUserProfile),
string.Join(",", new[] { $"{nameof(gid)}:{gid}" }));
try
{
using HttpClient c = new HttpClient();
c.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _serverTokenGenerator.Token);
string endpoint = string.Equals(toggle, "Enable", StringComparison.Ordinal) ? "/group/enableProfile" : "/group/disableProfile";
using (await c.PostAsJsonAsync(new Uri(_lightlessServicesConfiguration.GetValue<Uri>
(nameof(ServicesConfiguration.MainServerAddress)), endpoint), new GroupProfileAvailabilityRequest(gid))
.ConfigureAwait(false)) { }
var discordChannelForMessages = _lightlessServicesConfiguration.GetValueOrDefault<ulong?>(nameof(ServicesConfiguration.DiscordChannelForMessages), null);
if (discordChannelForMessages != null)
{
var discordChannel = await Context.Guild.GetChannelAsync(discordChannelForMessages.Value).ConfigureAwait(false) as IMessageChannel;
if (discordChannel != null)
{
var embedColor = Color.Blue;
var action = string.Equals(toggle, "Enable", StringComparison.Ordinal) ? "enabled" : "disabled";
EmbedBuilder eb = new();
eb.WithTitle($"Profile {action}");
eb.WithColor(embedColor);
eb.WithDescription($"{gid}'s profile has been {action}");
await discordChannel.SendMessageAsync(embed: eb.Build()).ConfigureAwait(false);
}
}
await RespondAsync("Message sent", ephemeral: true).ConfigureAwait(false);
}
catch (Exception ex)
{
EmbedBuilder eb = new();
eb.WithTitle("An error occured");
eb.WithDescription("Please report this: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine);
await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false);
}
}
public async Task<Embed> HandleUserAdd(string desiredUid, ulong discordUserId)
{
var embed = new EmbedBuilder();
using var scope = _services.CreateScope();
using var db = scope.ServiceProvider.GetService<LightlessDbContext>();
if (!(await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(a => a.DiscordId == discordUserId))?.User?.IsAdmin ?? true)
{
embed.WithTitle("Failed to add user");
embed.WithDescription("No permission");
}
else if (db.Users.Any(u => u.UID == desiredUid || u.Alias == desiredUid))
{
embed.WithTitle("Failed to add user");
embed.WithDescription("Already in Database");
}
else
{
User newUser = new()
{
IsAdmin = false,
IsModerator = false,
LastLoggedIn = DateTime.UtcNow,
UID = desiredUid,
};
var computedHash = StringUtils.Sha256String(StringUtils.GenerateRandomString(64) + DateTime.UtcNow.ToString());
var auth = new Auth()
{
HashedKey = StringUtils.Sha256String(computedHash),
User = newUser,
};
await db.Users.AddAsync(newUser);
await db.Auth.AddAsync(auth);
await db.SaveChangesAsync();
embed.WithTitle("Successfully added " + desiredUid);
embed.WithDescription("Secret Key: " + computedHash);
}
return embed.Build();
}
private async Task<(Embed mainEmbed, Embed? profileEmbed)> HandleUserInfo(LightlessDbContext db, ulong id, string? secondaryUserUid = null, ulong? optionalUser = null, string? uid = null)
{
bool showForSecondaryUser = secondaryUserUid != null;
var primaryUser = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == id).ConfigureAwait(false);
ulong userToCheckForDiscordId = id;
if (primaryUser == null)
{
EmbedBuilder eb = new();
eb.WithTitle("No account");
eb.WithDescription("No Lightless account was found associated to your Discord user");
return (eb.Build(), null);
}
bool isAdminCall = primaryUser.User.IsModerator || primaryUser.User.IsAdmin;
if ((optionalUser != null || uid != null) && !isAdminCall)
{
EmbedBuilder eb = new();
eb.WithTitle("Unauthorized");
eb.WithDescription("You are not authorized to view another users' information");
return (eb.Build(), null);
}
else if ((optionalUser != null || uid != null) && isAdminCall)
{
LodeStoneAuth userInDb = null;
if (optionalUser != null)
{
userInDb = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == optionalUser).ConfigureAwait(false);
}
else if (uid != null)
{
userInDb = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.User.UID == uid || u.User.Alias == uid).ConfigureAwait(false);
}
if (userInDb == null)
{
EmbedBuilder eb = new();
eb.WithTitle("No account");
eb.WithDescription("The Discord user has no valid Lightless account");
return (eb.Build(), null);
}
userToCheckForDiscordId = userInDb.DiscordId;
}
var lodestoneUser = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == userToCheckForDiscordId).ConfigureAwait(false);
var dbUser = lodestoneUser.User;
if (showForSecondaryUser)
{
dbUser = (await db.Auth.Include(u => u.User).SingleOrDefaultAsync(u => u.PrimaryUserUID == dbUser.UID && u.UserUID == secondaryUserUid))?.User;
if (dbUser == null)
{
EmbedBuilder eb = new();
eb.WithTitle("No such secondary UID");
eb.WithDescription($"A secondary UID {secondaryUserUid} was not found attached to your primary UID {primaryUser.User.UID}.");
return (eb.Build(), null);
}
}
var auth = await db.Auth.Include(u => u.PrimaryUser).SingleOrDefaultAsync(u => u.UserUID == dbUser.UID).ConfigureAwait(false);
var groups = await db.Groups.Where(g => g.OwnerUID == dbUser.UID).ToListAsync().ConfigureAwait(false);
var groupsJoined = await db.GroupPairs.Where(g => g.GroupUserUID == dbUser.UID).ToListAsync().ConfigureAwait(false);
var profile = await db.UserProfileData.Where(u => u.UserUID == dbUser.UID).SingleOrDefaultAsync().ConfigureAwait(false);
var identity = await _connectionMultiplexer.GetDatabase().StringGetAsync("UID:" + dbUser.UID).ConfigureAwait(false);
EmbedBuilder mainEmbed = new();
mainEmbed.WithTitle("User Information");
mainEmbed.WithDescription("This is the user information for Discord User <@" + userToCheckForDiscordId + ">" + Environment.NewLine + Environment.NewLine
+ "If you want to verify your secret key is valid, go to https://emn178.github.io/online-tools/sha256.html and copy your secret key into there and compare it to the Hashed Secret Key provided below.");
mainEmbed.AddField("UID", dbUser.UID);
if (!string.IsNullOrEmpty(dbUser.Alias))
{
mainEmbed.AddField("Vanity UID", dbUser.Alias);
}
if (showForSecondaryUser)
{
mainEmbed.AddField("Primary UID for " + dbUser.UID, auth.PrimaryUserUID);
}
else
{
var secondaryUIDs = await db.Auth.Where(p => p.PrimaryUserUID == dbUser.UID).Select(p => p.UserUID).ToListAsync();
if (secondaryUIDs.Any())
{
mainEmbed.AddField("Secondary UIDs", string.Join(Environment.NewLine, secondaryUIDs));
}
}
mainEmbed.AddField("Last Online (UTC)", dbUser.LastLoggedIn.ToString("U"));
mainEmbed.AddField("Currently online ", !string.IsNullOrEmpty(identity));
mainEmbed.AddField("Hashed Secret Key", auth.HashedKey);
mainEmbed.AddField("Joined Syncshells", groupsJoined.Count);
mainEmbed.AddField("Owned Syncshells", groups.Count);
foreach (var group in groups)
{
var syncShellUserCount = await db.GroupPairs.CountAsync(g => g.GroupGID == group.GID).ConfigureAwait(false);
if (!string.IsNullOrEmpty(group.Alias))
{
mainEmbed.AddField("Owned Syncshell " + group.GID + " Vanity ID", group.Alias);
}
mainEmbed.AddField("Owned Syncshell " + group.GID + " User Count", syncShellUserCount);
}
if (isAdminCall && !string.IsNullOrEmpty(identity))
{
mainEmbed.AddField("Character Ident", identity);
}
Embed? profileEmbedResult = null;
if (profile != null)
{
EmbedBuilder profileEmbedBuilder = new();
profileEmbedBuilder.WithTitle("User Profile");
profileEmbedBuilder.WithDescription("Profile Description: " + (string.IsNullOrEmpty(profile.UserDescription) ? "(No description set)" : profile.UserDescription));
profileEmbedBuilder.AddField("Profile NSFW", profile.IsNSFW);
profileEmbedBuilder.AddField("Profile Disabled", profile.ProfileDisabled);
profileEmbedBuilder.AddField("Profile Flagged for Report", profile.FlaggedForReport);
profileEmbedBuilder.AddField("Profile Tags", profile.Tags != null && profile.Tags.Length > 0 ? string.Join(", ", profile.Tags) : "(No tags set)");
profileEmbedResult = profileEmbedBuilder.Build();
}
return (mainEmbed.Build(), profileEmbedResult);
}
private async Task<string> GetUserUID(LightlessDbContext db, string? secondaryUserUid = null, ulong? optionalUser = null, string? uid = null)
{
var primaryUser = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == Context.User.Id).ConfigureAwait(false);
ulong userToCheckForDiscordId = Context.User.Id;
if ((optionalUser != null || uid != null))
{
LodeStoneAuth userInDb = null;
if (optionalUser != null)
{
userInDb = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == optionalUser).ConfigureAwait(false);
}
else if (uid != null)
{
userInDb = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.User.UID == uid || u.User.Alias == uid). ConfigureAwait(false);
}
if (userInDb == null)
{
throw new Exception("The Discord user has no valid Lightless account");
}
userToCheckForDiscordId = userInDb.DiscordId;
}
var lodestoneUser = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == userToCheckForDiscordId).ConfigureAwait(false);
var dbUser = lodestoneUser.User;
if (secondaryUserUid != null)
{
dbUser = (await db.Auth.Include(u => u.User).SingleOrDefaultAsync(u => u.PrimaryUserUID == dbUser.UID && u.UserUID == secondaryUserUid))?.User;
if (dbUser == null)
{
throw new Exception($"A secondary UID {secondaryUserUid} was not found attached to your primary UID {primaryUser.User.UID}.");
}
}
return dbUser.UID;
}
private byte[] GetProfileImage(UserProfileData profile)
{
if (profile != null && profile.Base64ProfileImage != null && profile.Base64ProfileImage.Length > 0)
{
return Convert.FromBase64String(profile.Base64ProfileImage);
}
return Array.Empty<byte>();
}
private byte[] GetBannerImage(UserProfileData profile)
{
if (profile != null && profile.Base64BannerImage != null && profile.Base64BannerImage.Length > 0)
{
return Convert.FromBase64String(profile.Base64BannerImage);
}
return Array.Empty<byte>();
}
private async Task<UserProfileData> GetUserProfileData(LightlessDbContext db, string uid)
{
var profile = await db.UserProfileData.Where(u => u.UserUID == uid).SingleOrDefaultAsync().ConfigureAwait(false);
return profile;
}
}

View File

@@ -1,4 +1,4 @@
using Discord.Interactions;
using Discord.Interactions;
using Discord;
using Microsoft.EntityFrameworkCore;
using System.Text.RegularExpressions;
@@ -92,13 +92,22 @@ public partial class LightlessWizardModule
var desiredVanityUid = modal.DesiredVanityUID;
using var db = await GetDbContext().ConfigureAwait(false);
bool canAddVanityId = !db.Users.Any(u => u.UID == modal.DesiredVanityUID || u.Alias == modal.DesiredVanityUID);
var forbiddenWords = new[] { "null", "nil" };
Regex rgx = new(@"^[_\-a-zA-Z0-9]{5,15}$", RegexOptions.ECMAScript);
Regex rgx = new(@"^[_\-a-zA-Z0-9\?]{3,15}$", RegexOptions.ECMAScript);
if (!rgx.Match(desiredVanityUid).Success)
{
eb.WithColor(Color.Red);
eb.WithTitle("Invalid Vanity UID");
eb.WithDescription("A Vanity UID must be between 5 and 15 characters long and only contain the letters A-Z, numbers 0-9, dashes (-) and underscores (_).");
eb.WithDescription("A Vanity UID must be between 3 and 15 characters long and only contain the letters A-Z, numbers 0-9, dashes (-) and underscores (_).");
cb.WithButton("Cancel", "wizard-vanity", ButtonStyle.Secondary, emote: new Emoji("❌"));
cb.WithButton("Pick Different UID", "wizard-vanity-uid-set:" + uid, ButtonStyle.Primary, new Emoji("💅"));
}
else if (forbiddenWords.Contains(desiredVanityUid.Trim().ToLowerInvariant()))
{
eb.WithColor(Color.Red);
eb.WithTitle("Invalid Vanity UID");
eb.WithDescription("You cannot use 'Null' or 'Nil' (any case) as a Vanity UID. Please pick a different one.");
cb.WithButton("Cancel", "wizard-vanity", ButtonStyle.Secondary, emote: new Emoji("❌"));
cb.WithButton("Pick Different UID", "wizard-vanity-uid-set:" + uid, ButtonStyle.Primary, new Emoji("💅"));
}
@@ -114,6 +123,20 @@ public partial class LightlessWizardModule
{
var user = await db.Users.SingleAsync(u => u.UID == uid).ConfigureAwait(false);
user.Alias = desiredVanityUid;
user.HasVanity = true;
var secondaryUsers = await db.Auth.Include(u => u.User)
.Where(u => u.PrimaryUserUID == user.UID).ToListAsync().ConfigureAwait(false);
foreach (var secondaryUser in secondaryUsers)
{
if (!secondaryUser.User.HasVanity)
{
secondaryUser.User.HasVanity = true;
db.Update(secondaryUser.User);
}
}
db.Update(user);
await db.SaveChangesAsync().ConfigureAwait(false);
eb.WithColor(Color.Green);
@@ -190,6 +213,25 @@ public partial class LightlessWizardModule
{
var group = await db.Groups.SingleAsync(u => u.GID == gid).ConfigureAwait(false);
group.Alias = desiredVanityGid;
var ownerAuth = await db.Auth.SingleOrDefaultAsync(u => u.UserUID == group.OwnerUID).ConfigureAwait(false);
var ownerUid = string.IsNullOrEmpty(ownerAuth?.PrimaryUserUID) ? group.OwnerUID : ownerAuth.PrimaryUserUID;
var ownerUser = await db.Users.SingleAsync(u => u.UID == ownerUid).ConfigureAwait(false);
ownerUser.HasVanity = true;
db.Update(ownerUser);
var secondaryUsers = await db.Auth.Include(u => u.User)
.Where(u => u.PrimaryUserUID == ownerUser.UID).ToListAsync().ConfigureAwait(false);
foreach (var secondaryUser in secondaryUsers)
{
if (!secondaryUser.User.HasVanity)
{
secondaryUser.User.HasVanity = true;
db.Update(secondaryUser.User);
}
}
db.Update(group);
await db.SaveChangesAsync().ConfigureAwait(false);
eb.WithColor(Color.Green);

View File

@@ -194,7 +194,7 @@ public partial class LightlessWizardModule : InteractionModuleBase
public string Title => "Set Vanity UID";
[InputLabel("Set your Vanity UID")]
[ModalTextInput("vanity_uid", TextInputStyle.Short, "5-15 characters, underscore, dash", 5, 15)]
[ModalTextInput("vanity_uid", TextInputStyle.Short, "3-15 characters, underscore, dash", 3, 15)]
public string DesiredVanityUID { get; set; }
}
@@ -329,13 +329,12 @@ public partial class LightlessWizardModule : InteractionModuleBase
private int? ParseCharacterIdFromLodestoneUrl(string lodestoneUrl)
{
var regex = new Regex(@"https:\/\/(na|eu|de|fr|jp)\.finalfantasyxiv\.com\/lodestone\/character\/\d+");
var regex = new Regex(@"^https:\/\/(na|eu|de|fr|jp)\.finalfantasyxiv\.com\/lodestone\/character\/(\d+)/?$");
var matches = regex.Match(lodestoneUrl);
var isLodestoneUrl = matches.Success;
if (!isLodestoneUrl || matches.Groups.Count < 1) return null;
var stringId = matches.Groups[2].ToString();
lodestoneUrl = matches.Groups[0].ToString();
var stringId = lodestoneUrl.Split('/', StringSplitOptions.RemoveEmptyEntries).Last();
if (!int.TryParse(stringId, out int lodestoneId))
{
return null;

View File

@@ -1,298 +0,0 @@
using Discord;
using Discord.Interactions;
using LightlessSyncShared.Data;
using Microsoft.EntityFrameworkCore;
using Prometheus;
using LightlessSyncShared.Models;
using LightlessSyncShared.Utils;
using LightlessSyncShared.Services;
using StackExchange.Redis;
using LightlessSync.API.Data.Enum;
using LightlessSyncShared.Utils.Configuration;
namespace LightlessSyncServices.Discord;
public class LightlessModule : InteractionModuleBase
{
private readonly ILogger<LightlessModule> _logger;
private readonly IServiceProvider _services;
private readonly IConfigurationService<ServicesConfiguration> _lightlessServicesConfiguration;
private readonly IConnectionMultiplexer _connectionMultiplexer;
private readonly ServerTokenGenerator _serverTokenGenerator;
public LightlessModule(ILogger<LightlessModule> logger, IServiceProvider services,
IConfigurationService<ServicesConfiguration> lightlessServicesConfiguration,
IConnectionMultiplexer connectionMultiplexer, ServerTokenGenerator serverTokenGenerator)
{
_logger = logger;
_services = services;
_lightlessServicesConfiguration = lightlessServicesConfiguration;
_connectionMultiplexer = connectionMultiplexer;
_serverTokenGenerator = serverTokenGenerator;
}
[SlashCommand("userinfo", "Shows you your user information")]
public async Task UserInfo([Summary("secondary_uid", "(Optional) Your secondary UID")] string? secondaryUid = null,
[Summary("discord_user", "ADMIN ONLY: Discord User to check for")] IUser? discordUser = null,
[Summary("uid", "ADMIN ONLY: UID to check for")] string? uid = null)
{
_logger.LogInformation("SlashCommand:{userId}:{Method}",
Context.Interaction.User.Id, nameof(UserInfo));
try
{
EmbedBuilder eb = new();
eb = await HandleUserInfo(eb, Context.User.Id, secondaryUid, discordUser?.Id ?? null, uid);
await RespondAsync(embeds: new[] { eb.Build() }, ephemeral: true).ConfigureAwait(false);
}
catch (Exception ex)
{
EmbedBuilder eb = new();
eb.WithTitle("An error occured");
eb.WithDescription("Please report this error to bug-reports: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine);
await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false);
}
}
[SlashCommand("useradd", "ADMIN ONLY: add a user unconditionally to the Database")]
public async Task UserAdd([Summary("desired_uid", "Desired UID")] string desiredUid)
{
_logger.LogInformation("SlashCommand:{userId}:{Method}:{params}",
Context.Interaction.User.Id, nameof(UserAdd),
string.Join(",", new[] { $"{nameof(desiredUid)}:{desiredUid}" }));
try
{
var embed = await HandleUserAdd(desiredUid, Context.User.Id);
await RespondAsync(embeds: new[] { embed }, ephemeral: true).ConfigureAwait(false);
}
catch (Exception ex)
{
EmbedBuilder eb = new();
eb.WithTitle("An error occured");
eb.WithDescription("Please report this error to bug-reports: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine);
await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false);
}
}
[SlashCommand("message", "ADMIN ONLY: sends a message to clients")]
public async Task SendMessageToClients([Summary("message", "Message to send")] string message,
[Summary("severity", "Severity of the message")] MessageSeverity messageType = MessageSeverity.Information,
[Summary("uid", "User ID to the person to send the message to")] string? uid = null)
{
_logger.LogInformation("SlashCommand:{userId}:{Method}:{message}:{type}:{uid}", Context.Interaction.User.Id, nameof(SendMessageToClients), message, messageType, uid);
using var scope = _services.CreateScope();
using var db = scope.ServiceProvider.GetService<LightlessDbContext>();
if (!(await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(a => a.DiscordId == Context.Interaction.User.Id))?.User?.IsAdmin ?? true)
{
await RespondAsync("No permission", ephemeral: true).ConfigureAwait(false);
return;
}
if (!string.IsNullOrEmpty(uid) && !await db.Users.AnyAsync(u => u.UID == uid))
{
await RespondAsync("Specified UID does not exist", ephemeral: true).ConfigureAwait(false);
return;
}
try
{
using HttpClient c = new HttpClient();
c.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _serverTokenGenerator.Token);
await c.PostAsJsonAsync(
new Uri(_lightlessServicesConfiguration.GetValue<Uri>(nameof(ServicesConfiguration.MainServerAddress)), "/msgc/sendMessage"),
new ClientMessage(messageType, message, uid ?? string.Empty)
).ConfigureAwait(false);
var discordChannelForMessages = _lightlessServicesConfiguration.GetValueOrDefault<ulong?>(nameof(ServicesConfiguration.DiscordChannelForMessages), null);
if (uid == null && discordChannelForMessages != null)
{
var discordChannel = await Context.Guild.GetChannelAsync(discordChannelForMessages.Value) as IMessageChannel;
if (discordChannel != null)
{
var embedColor = messageType switch
{
MessageSeverity.Information => Color.Blue,
MessageSeverity.Warning => new Color(255, 255, 0),
MessageSeverity.Error => Color.Red,
_ => Color.Blue
};
EmbedBuilder eb = new();
eb.WithTitle(messageType + " server message");
eb.WithColor(embedColor);
eb.WithDescription(message);
await discordChannel.SendMessageAsync(embed: eb.Build());
}
}
await RespondAsync("Message sent", ephemeral: true).ConfigureAwait(false);
}
catch (Exception ex)
{
await RespondAsync("Failed to send message: " + ex.ToString(), ephemeral: true).ConfigureAwait(false);
}
}
public async Task<Embed> HandleUserAdd(string desiredUid, ulong discordUserId)
{
var embed = new EmbedBuilder();
using var scope = _services.CreateScope();
using var db = scope.ServiceProvider.GetService<LightlessDbContext>();
if (!(await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(a => a.DiscordId == discordUserId))?.User?.IsAdmin ?? true)
{
embed.WithTitle("Failed to add user");
embed.WithDescription("No permission");
}
else if (db.Users.Any(u => u.UID == desiredUid || u.Alias == desiredUid))
{
embed.WithTitle("Failed to add user");
embed.WithDescription("Already in Database");
}
else
{
User newUser = new()
{
IsAdmin = false,
IsModerator = false,
LastLoggedIn = DateTime.UtcNow,
UID = desiredUid,
};
var computedHash = StringUtils.Sha256String(StringUtils.GenerateRandomString(64) + DateTime.UtcNow.ToString());
var auth = new Auth()
{
HashedKey = StringUtils.Sha256String(computedHash),
User = newUser,
};
await db.Users.AddAsync(newUser);
await db.Auth.AddAsync(auth);
await db.SaveChangesAsync();
embed.WithTitle("Successfully added " + desiredUid);
embed.WithDescription("Secret Key: " + computedHash);
}
return embed.Build();
}
private async Task<EmbedBuilder> HandleUserInfo(EmbedBuilder eb, ulong id, string? secondaryUserUid = null, ulong? optionalUser = null, string? uid = null)
{
bool showForSecondaryUser = secondaryUserUid != null;
using var scope = _services.CreateScope();
await using var db = scope.ServiceProvider.GetRequiredService<LightlessDbContext>();
var primaryUser = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == id).ConfigureAwait(false);
ulong userToCheckForDiscordId = id;
if (primaryUser == null)
{
eb.WithTitle("No account");
eb.WithDescription("No Lightless account was found associated to your Discord user");
return eb;
}
bool isAdminCall = primaryUser.User.IsModerator || primaryUser.User.IsAdmin;
if ((optionalUser != null || uid != null) && !isAdminCall)
{
eb.WithTitle("Unauthorized");
eb.WithDescription("You are not authorized to view another users' information");
return eb;
}
else if ((optionalUser != null || uid != null) && isAdminCall)
{
LodeStoneAuth userInDb = null;
if (optionalUser != null)
{
userInDb = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == optionalUser).ConfigureAwait(false);
}
else if (uid != null)
{
userInDb = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.User.UID == uid || u.User.Alias == uid).ConfigureAwait(false);
}
if (userInDb == null)
{
eb.WithTitle("No account");
eb.WithDescription("The Discord user has no valid Lightless account");
return eb;
}
userToCheckForDiscordId = userInDb.DiscordId;
}
var lodestoneUser = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == userToCheckForDiscordId).ConfigureAwait(false);
var dbUser = lodestoneUser.User;
if (showForSecondaryUser)
{
dbUser = (await db.Auth.Include(u => u.User).SingleOrDefaultAsync(u => u.PrimaryUserUID == dbUser.UID && u.UserUID == secondaryUserUid))?.User;
if (dbUser == null)
{
eb.WithTitle("No such secondary UID");
eb.WithDescription($"A secondary UID {secondaryUserUid} was not found attached to your primary UID {primaryUser.User.UID}.");
return eb;
}
}
var auth = await db.Auth.Include(u => u.PrimaryUser).SingleOrDefaultAsync(u => u.UserUID == dbUser.UID).ConfigureAwait(false);
var groups = await db.Groups.Where(g => g.OwnerUID == dbUser.UID).ToListAsync().ConfigureAwait(false);
var groupsJoined = await db.GroupPairs.Where(g => g.GroupUserUID == dbUser.UID).ToListAsync().ConfigureAwait(false);
var identity = await _connectionMultiplexer.GetDatabase().StringGetAsync("UID:" + dbUser.UID).ConfigureAwait(false);
eb.WithTitle("User Information");
eb.WithDescription("This is the user information for Discord User <@" + userToCheckForDiscordId + ">" + Environment.NewLine + Environment.NewLine
+ "If you want to verify your secret key is valid, go to https://emn178.github.io/online-tools/sha256.html and copy your secret key into there and compare it to the Hashed Secret Key provided below.");
eb.AddField("UID", dbUser.UID);
if (!string.IsNullOrEmpty(dbUser.Alias))
{
eb.AddField("Vanity UID", dbUser.Alias);
}
if (showForSecondaryUser)
{
eb.AddField("Primary UID for " + dbUser.UID, auth.PrimaryUserUID);
}
else
{
var secondaryUIDs = await db.Auth.Where(p => p.PrimaryUserUID == dbUser.UID).Select(p => p.UserUID).ToListAsync();
if (secondaryUIDs.Any())
{
eb.AddField("Secondary UIDs", string.Join(Environment.NewLine, secondaryUIDs));
}
}
eb.AddField("Last Online (UTC)", dbUser.LastLoggedIn.ToString("U"));
eb.AddField("Currently online ", !string.IsNullOrEmpty(identity));
eb.AddField("Hashed Secret Key", auth.HashedKey);
eb.AddField("Joined Syncshells", groupsJoined.Count);
eb.AddField("Owned Syncshells", groups.Count);
foreach (var group in groups)
{
var syncShellUserCount = await db.GroupPairs.CountAsync(g => g.GroupGID == group.GID).ConfigureAwait(false);
if (!string.IsNullOrEmpty(group.Alias))
{
eb.AddField("Owned Syncshell " + group.GID + " Vanity ID", group.Alias);
}
eb.AddField("Owned Syncshell " + group.GID + " User Count", syncShellUserCount);
}
if (isAdminCall && !string.IsNullOrEmpty(identity))
{
eb.AddField("Character Ident", identity);
}
return eb;
}
}

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -21,7 +21,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Discord.Net" Version="3.17.0" />
<PackageReference Include="Discord.Net" Version="3.18.0" />
<PackageReference Include="IDisposableAnalyzers" Version="4.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -32,6 +32,7 @@
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="9.0.0" />
<PackageReference Include="System.Linq.Async" Version="7.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -45,6 +45,7 @@ public class LightlessDbContext : DbContext
public DbSet<UserProfileData> UserProfileData { get; set; }
public DbSet<User> Users { get; set; }
public DbSet<UserPermissionSet> Permissions { get; set; }
public DbSet<ReportedChatMessage> ReportedChatMessages { get; set; }
public DbSet<GroupPairPreferredPermission> GroupPairPreferredPermissions { get; set; }
public DbSet<UserDefaultPreferredPermission> UserDefaultPreferredPermissions { get; set; }
public DbSet<CharaData> CharaData { get; set; }
@@ -53,6 +54,7 @@ public class LightlessDbContext : DbContext
public DbSet<CharaDataOriginalFile> CharaDataOriginalFiles { get; set; }
public DbSet<CharaDataPose> CharaDataPoses { get; set; }
public DbSet<CharaDataAllowance> CharaDataAllowances { get; set; }
public DbSet<GroupProfile> GroupProfiles { get; set; }
protected override void OnModelCreating(ModelBuilder mb)
{
@@ -70,6 +72,17 @@ public class LightlessDbContext : DbContext
mb.Entity<BannedRegistrations>().ToTable("banned_registrations");
mb.Entity<Group>().ToTable("groups");
mb.Entity<Group>().HasIndex(c => c.OwnerUID);
mb.Entity<Group>()
.Property(g => g.CreatedDate)
.HasDefaultValueSql("CURRENT_TIMESTAMP");
mb.Entity<Group>()
.HasOne(g => g.Profile)
.WithOne(p => p.Group)
.HasForeignKey<GroupProfile>(p => p.GroupGID)
.IsRequired(false);
mb.Entity<Group>()
.Property(g => g.ChatEnabled)
.HasDefaultValue(true);
mb.Entity<GroupPair>().ToTable("group_pairs");
mb.Entity<GroupPair>().HasKey(u => new { u.GroupGID, u.GroupUserUID });
mb.Entity<GroupPair>().HasIndex(c => c.GroupUserUID);
@@ -78,6 +91,15 @@ public class LightlessDbContext : DbContext
mb.Entity<GroupBan>().HasKey(u => new { u.GroupGID, u.BannedUserUID });
mb.Entity<GroupBan>().HasIndex(c => c.BannedUserUID);
mb.Entity<GroupBan>().HasIndex(c => c.GroupGID);
mb.Entity<GroupProfile>().ToTable("group_profiles");
mb.Entity<GroupProfile>().HasKey(u => u.GroupGID);
mb.Entity<GroupProfile>().HasIndex(c => c.GroupGID);
mb.Entity<Group>()
.HasOne(g => g.Profile)
.WithOne(p => p.Group)
.HasForeignKey<GroupProfile>(p => p.GroupGID)
.IsRequired(false)
.OnDelete(DeleteBehavior.Cascade);
mb.Entity<GroupTempInvite>().ToTable("group_temp_invites");
mb.Entity<GroupTempInvite>().HasKey(u => new { u.GroupGID, u.Invite });
mb.Entity<GroupTempInvite>().HasIndex(c => c.GroupGID);
@@ -141,5 +163,11 @@ public class LightlessDbContext : DbContext
mb.Entity<CharaDataAllowance>().HasIndex(c => c.ParentId);
mb.Entity<CharaDataAllowance>().HasOne(u => u.AllowedGroup).WithMany().HasForeignKey(u => u.AllowedGroupGID).OnDelete(DeleteBehavior.Cascade);
mb.Entity<CharaDataAllowance>().HasOne(u => u.AllowedUser).WithMany().HasForeignKey(u => u.AllowedUserUID).OnDelete(DeleteBehavior.Cascade);
mb.Entity<ReportedChatMessage>().ToTable("reported_chat_messages");
mb.Entity<ReportedChatMessage>().HasIndex(r => r.ReporterUserUid);
mb.Entity<ReportedChatMessage>().HasIndex(r => r.ReportedUserUid);
mb.Entity<ReportedChatMessage>().HasIndex(r => r.MessageId).IsUnique();
mb.Entity<ReportedChatMessage>().HasIndex(r => r.DiscordMessageId);
}
}

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -47,7 +47,6 @@
<PackageReference Include="StackExchange.Redis.Extensions.Core" Version="10.2.0" />
<PackageReference Include="StackExchange.Redis.Extensions.System.Text.Json" Version="10.2.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.3.0" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -20,7 +20,7 @@ public class LightlessMetrics
if (!string.Equals(gauge, MetricsAPI.GaugeConnections, StringComparison.OrdinalIgnoreCase))
_gauges.Add(gauge, Prometheus.Metrics.CreateGauge(gauge, gauge));
else
_gauges.Add(gauge, Prometheus.Metrics.CreateGauge(gauge, gauge, new[] { "continent" }));
_gauges.Add(gauge, Prometheus.Metrics.CreateGauge(gauge, gauge, ["continent", "country"]));
}
}

View File

@@ -9,6 +9,9 @@ public class MetricsAPI
public const string GaugeAvailableIOWorkerThreads = "lightless_available_threadpool_io";
public const string GaugeUsersRegistered = "lightless_users_registered";
public const string CounterUsersRegisteredDeleted = "lightless_users_registered_deleted";
public const string GaugeLightFinderConnections = "lightless_lightfinder_connections";
public const string GaugeLightFinderGroups = "lightless_lightfinder_groups";
public const string GaugeGroupAutoPrunesEnabled = "lightless_group_autoprunes_enabled";
public const string GaugePairs = "lightless_pairs";
public const string GaugePairsPaused = "lightless_pairs_paused";
public const string GaugeFilesTotal = "lightless_files";

View File

@@ -3,6 +3,7 @@ using System;
using LightlessSyncShared.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
@@ -11,9 +12,11 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace LightlessSyncServer.Migrations
{
[DbContext(typeof(LightlessDbContext))]
partial class LightlessDbContextModelSnapshot : ModelSnapshot
[Migration("20250905192853_AddBannedUid")]
partial class AddBannedUid
{
protected override void BuildModel(ModelBuilder modelBuilder)
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
@@ -64,6 +67,10 @@ namespace LightlessSyncServer.Migrations
.HasColumnType("character varying(100)")
.HasColumnName("character_identification");
b.Property<string>("BannedUid")
.HasColumnType("text")
.HasColumnName("banned_uid");
b.Property<string>("Reason")
.HasColumnType("text")
.HasColumnName("reason");

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LightlessSyncServer.Migrations
{
/// <inheritdoc />
public partial class AddBannedUid : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "banned_uid",
table: "banned_users",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "banned_uid",
table: "banned_users");
}
}
}

View File

@@ -0,0 +1,79 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LightlessSyncServer.Migrations
{
/// <inheritdoc />
public partial class AddGroupProfilesAndDates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "created_date",
table: "groups",
type: "timestamp with time zone",
nullable: false,
defaultValueSql: "CURRENT_TIMESTAMP");
migrationBuilder.AddColumn<bool>(
name: "from_finder",
table: "group_pairs",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "joined_group_on",
table: "group_pairs",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateTable(
name: "group_profiles",
columns: table => new
{
group_gid = table.Column<string>(type: "character varying(20)", nullable: false),
description = table.Column<string>(type: "text", nullable: true),
tags = table.Column<string>(type: "text", nullable: true),
base64group_profile_image = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_group_profiles", x => x.group_gid);
table.ForeignKey(
name: "fk_group_profiles_groups_group_gid",
column: x => x.group_gid,
principalTable: "groups",
principalColumn: "gid",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_group_profiles_group_gid",
table: "group_profiles",
column: "group_gid");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "group_profiles");
migrationBuilder.DropColumn(
name: "created_date",
table: "groups");
migrationBuilder.DropColumn(
name: "from_finder",
table: "group_pairs");
migrationBuilder.DropColumn(
name: "joined_group_on",
table: "group_pairs");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LightlessSyncServer.Migrations
{
/// <inheritdoc />
public partial class AddProfilesToGroup : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_group_profiles_groups_group_gid",
table: "group_profiles");
migrationBuilder.AddForeignKey(
name: "fk_group_profiles_groups_group_gid",
table: "group_profiles",
column: "group_gid",
principalTable: "groups",
principalColumn: "gid");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_group_profiles_groups_group_gid",
table: "group_profiles");
migrationBuilder.AddForeignKey(
name: "fk_group_profiles_groups_group_gid",
table: "group_profiles",
column: "group_gid",
principalTable: "groups",
principalColumn: "gid",
onDelete: ReferentialAction.Cascade);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LightlessSyncServer.Migrations
{
/// <inheritdoc />
public partial class AddUserVanity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "has_vanity",
table: "users",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "text_color_hex",
table: "users",
type: "character varying(9)",
maxLength: 9,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "text_glow_color_hex",
table: "users",
type: "character varying(9)",
maxLength: 9,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "has_vanity",
table: "users");
migrationBuilder.DropColumn(
name: "text_color_hex",
table: "users");
migrationBuilder.DropColumn(
name: "text_glow_color_hex",
table: "users");
}
}
}

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LightlessSyncServer.Migrations
{
/// <inheritdoc />
public partial class AddGroupDisabledAndNSFW : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "is_nsfw",
table: "group_profiles",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "profile_disabled",
table: "group_profiles",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "is_nsfw",
table: "group_profiles");
migrationBuilder.DropColumn(
name: "profile_disabled",
table: "group_profiles");
}
}
}

View File

@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LightlessSyncServer.Migrations
{
/// <inheritdoc />
public partial class AddAndChangeTagsUserGroupProfile : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int[]>(
name: "tags",
table: "user_profile_data",
type: "integer[]",
nullable: true);
migrationBuilder.Sql(
"ALTER TABLE group_profiles ALTER COLUMN tags TYPE integer[] USING string_to_array(tags, ',')::integer[];"
);
migrationBuilder.AlterColumn<int[]>(
name: "tags",
table: "group_profiles",
type: "integer[]",
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "tags",
table: "user_profile_data");
migrationBuilder.AlterColumn<string>(
name: "tags",
table: "group_profiles",
type: "text",
nullable: true,
oldClrType: typeof(int[]),
oldType: "integer[]",
oldNullable: true);
}
}
}

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LightlessSyncServer.Migrations
{
/// <inheritdoc />
public partial class AddBannerForProfiles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "base64banner_image",
table: "user_profile_data",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "base64group_banner_image",
table: "group_profiles",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "base64banner_image",
table: "user_profile_data");
migrationBuilder.DropColumn(
name: "base64group_banner_image",
table: "group_profiles");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace LightlessSyncServer.Migrations
{
/// <inheritdoc />
public partial class ChatReports : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "reported_chat_messages",
columns: table => new
{
report_id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
report_time_utc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
reporter_user_uid = table.Column<string>(type: "text", nullable: false),
reported_user_uid = table.Column<string>(type: "text", nullable: true),
channel_type = table.Column<byte>(type: "smallint", nullable: false),
world_id = table.Column<int>(type: "integer", nullable: false),
zone_id = table.Column<int>(type: "integer", nullable: false),
channel_key = table.Column<string>(type: "text", nullable: false),
message_id = table.Column<string>(type: "text", nullable: false),
message_sent_at_utc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
message_content = table.Column<string>(type: "text", nullable: false),
sender_token = table.Column<string>(type: "text", nullable: false),
sender_hashed_cid = table.Column<string>(type: "text", nullable: true),
sender_display_name = table.Column<string>(type: "text", nullable: true),
sender_was_lightfinder = table.Column<bool>(type: "boolean", nullable: false),
snapshot_json = table.Column<string>(type: "text", nullable: true),
reason = table.Column<string>(type: "text", nullable: true),
additional_context = table.Column<string>(type: "text", nullable: true),
discord_message_id = table.Column<decimal>(type: "numeric(20,0)", nullable: true),
discord_message_posted_at_utc = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
resolved = table.Column<bool>(type: "boolean", nullable: false),
resolved_at_utc = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
resolution_notes = table.Column<string>(type: "text", nullable: true),
resolved_by_user_uid = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_reported_chat_messages", x => x.report_id);
});
migrationBuilder.AddColumn<bool>(
name: "chat_banned",
table: "users",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateIndex(
name: "ix_reported_chat_messages_discord_message_id",
table: "reported_chat_messages",
column: "discord_message_id");
migrationBuilder.CreateIndex(
name: "ix_reported_chat_messages_message_id",
table: "reported_chat_messages",
column: "message_id",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_reported_chat_messages_reported_user_uid",
table: "reported_chat_messages",
column: "reported_user_uid");
migrationBuilder.CreateIndex(
name: "ix_reported_chat_messages_reporter_user_uid",
table: "reported_chat_messages",
column: "reporter_user_uid");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "reported_chat_messages");
migrationBuilder.DropColumn(
name: "chat_banned",
table: "users");
}
}
}

View File

@@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LightlessSyncServer.Migrations
{
/// <inheritdoc />
public partial class FixForeignKeyGroupProfiles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_group_profiles_groups_group_gid",
table: "group_profiles");
migrationBuilder.AddForeignKey(
name: "fk_group_profiles_groups_group_gid",
table: "group_profiles",
column: "group_gid",
principalTable: "groups",
principalColumn: "gid",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_group_profiles_groups_group_gid",
table: "group_profiles");
migrationBuilder.AddForeignKey(
name: "fk_group_profiles_groups_group_gid",
table: "group_profiles",
column: "group_gid",
principalTable: "groups",
principalColumn: "gid");
}
}
}

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LightlessSyncServer.Migrations
{
/// <inheritdoc />
public partial class AddAutoPruneInGroup : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "auto_prune_days",
table: "groups",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<bool>(
name: "auto_prune_enabled",
table: "groups",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "auto_prune_days",
table: "groups");
migrationBuilder.DropColumn(
name: "auto_prune_enabled",
table: "groups");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LightlessSyncServer.Migrations
{
/// <inheritdoc />
public partial class ChatReportFixes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "sender_token",
table: "reported_chat_messages");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "sender_token",
table: "reported_chat_messages",
type: "text",
nullable: false,
defaultValue: "");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LightlessSyncServer.Migrations
{
/// <inheritdoc />
public partial class DisableChatGroups : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "chat_enabled",
table: "groups",
type: "boolean",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "chat_enabled",
table: "groups");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LightlessSyncServer.Migrations
{
/// <inheritdoc />
public partial class ShareLocation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "share_location_until",
table: "user_permission_sets",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "share_location_until",
table: "user_permission_sets");
}
}
}

View File

@@ -7,6 +7,7 @@ public class Banned
[Key]
[MaxLength(100)]
public string CharacterIdentification { get; set; }
public string BannedUid { get; set; }
public string Reason { get; set; }
[Timestamp]
public byte[] Timestamp { get; set; }

View File

@@ -11,9 +11,14 @@ public class Group
public User Owner { get; set; }
[MaxLength(50)]
public string Alias { get; set; }
public GroupProfile? Profile { get; set; }
public bool AutoPruneEnabled { get; set; } = false;
public int AutoPruneDays { get; set; } = 0;
public bool InvitesEnabled { get; set; }
public string HashedPassword { get; set; }
public bool PreferDisableSounds { get; set; }
public bool PreferDisableAnimations { get; set; }
public bool PreferDisableVFX { get; set; }
public bool ChatEnabled { get; set; } = true;
public DateTime CreatedDate { get; set; } = DateTime.UtcNow;
}

View File

@@ -8,4 +8,6 @@ public class GroupPair
public User GroupUser { get; set; }
public bool IsPinned { get; set; }
public bool IsModerator { get; set; }
public bool FromFinder { get; set; } = false;
public DateTime? JoinedGroupOn { get; set; }
}

View File

@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace LightlessSyncShared.Models;
public class GroupProfile
{
[Key]
[MaxLength(20)]
public string GroupGID { get; set; }
public Group Group { get; set; }
public string Description { get; set; }
public int[] Tags { get; set; }
public string Base64GroupProfileImage { get; set; }
public string Base64GroupBannerImage { get; set; }
public bool IsNSFW { get; set; } = false;
public bool ProfileDisabled { get; set; } = false;
}

Some files were not shown because too many files have changed in this diff Show More