Compare commits

...

109 Commits

Author SHA1 Message Date
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
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
54 changed files with 7696 additions and 555 deletions

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);
}

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,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,6 +44,7 @@ public class GeoIPService : IHostedService
if (_dbReader!.TryCity(ip, out var response))
{
string? continent = response?.Continent.Code;
if (!string.IsNullOrEmpty(continent) &&
string.Equals(continent, "NA", StringComparison.Ordinal)
&& response?.Location.Longitude != null)
@@ -140,4 +144,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

@@ -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,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

@@ -10,41 +10,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");

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!);
}
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
);
}

View File

@@ -1,12 +1,20 @@
using LightlessSync.API.Data.Enum;
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 LightlessSyncServer.Models;
using LightlessSyncServer.Services;
using LightlessSyncServer.Utils;
using LightlessSyncShared.Models;
using LightlessSyncShared.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json.Linq;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using System.Reflection;
using System.Security.Cryptography;
namespace LightlessSyncServer.Hubs;
@@ -57,7 +65,7 @@ public partial class LightlessHub
group.PreferDisableAnimations = dto.Permissions.HasFlag(GroupPermissions.PreferDisableAnimations);
group.PreferDisableVFX = dto.Permissions.HasFlag(GroupPermissions.PreferDisableVFX);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
await DbContext.SaveChangesAsync(RequestAbortedToken).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);
@@ -135,7 +143,7 @@ public partial class LightlessHub
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);
var sharedData = await DbContext.CharaDataAllowances.Where(u => u.AllowedGroup != null && u.AllowedGroupGID == dto.GID && u.ParentUploaderUID == pair.GroupUserUID).ToListAsync(cancellationToken: RequestAbortedToken).ConfigureAwait(false);
DbContext.CharaDataAllowances.RemoveRange(sharedData);
foreach (var groupUserPair in groupPairs.Where(p => !string.Equals(p.GroupUserUID, pair.GroupUserUID, StringComparison.Ordinal)))
@@ -147,29 +155,76 @@ public partial class LightlessHub
await DbContext.SaveChangesAsync().ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task GroupClearFinder(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 finder_only = groupPairs.Where(g => g.FromFinder && !g.IsPinned && !g.IsModerator).ToList();
if (finder_only.Count == 0)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "No Users To Clear"));
return;
}
await Clients.Users(finder_only.Select(g => g.GroupUserUID)).Client_GroupDelete(new GroupDto(group.ToGroupData())).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Cleared Finder users ", finder_only.Count));
DbContext.GroupPairs.RemoveRange(finder_only);
foreach (var pair in finder_only)
{
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(cancellationToken: RequestAbortedToken).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);
var existingGroupsByUser = await DbContext.Groups.CountAsync(u => u.OwnerUID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
var existingJoinedGroups = await DbContext.GroupPairs.CountAsync(u => u.GroupUserUID == UserUID, cancellationToken: RequestAbortedToken).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))
while (await DbContext.Groups.AnyAsync(g => g.GID == "LLS-" + gid, cancellationToken: RequestAbortedToken).ConfigureAwait(false))
{
gid = StringUtils.GenerateRandomString(12);
}
gid = "MSS-" + gid;
gid = "LLS-" + gid;
var passwd = StringUtils.GenerateRandomString(16);
using var sha = SHA256.Create();
var hashedPw = StringUtils.Sha256String(passwd);
var currentTime = DateTime.UtcNow;
UserDefaultPreferredPermission defaultPermissions = await DbContext.UserDefaultPreferredPermissions.SingleAsync(u => u.UserUID == UserUID).ConfigureAwait(false);
UserDefaultPreferredPermission defaultPermissions = await DbContext.UserDefaultPreferredPermissions.SingleAsync(u => u.UserUID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
Group newGroup = new()
{
@@ -179,7 +234,8 @@ public partial class LightlessHub
OwnerUID = UserUID,
PreferDisableAnimations = defaultPermissions.DisableGroupAnimations,
PreferDisableSounds = defaultPermissions.DisableGroupSounds,
PreferDisableVFX = defaultPermissions.DisableGroupVFX
PreferDisableVFX = defaultPermissions.DisableGroupVFX,
CreatedDate = currentTime,
};
GroupPair initialPair = new()
@@ -187,6 +243,8 @@ public partial class LightlessHub
GroupGID = newGroup.GID,
GroupUserUID = UserUID,
IsPinned = true,
JoinedGroupOn = currentTime,
FromFinder = false,
};
GroupPairPreferredPermission initialPrefPermissions = new()
@@ -195,20 +253,20 @@ public partial class LightlessHub
GroupGID = newGroup.GID,
DisableSounds = defaultPermissions.DisableGroupSounds,
DisableAnimations = defaultPermissions.DisableGroupAnimations,
DisableVFX = 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);
await DbContext.Groups.AddAsync(newGroup, RequestAbortedToken).ConfigureAwait(false);
await DbContext.GroupPairs.AddAsync(initialPair, RequestAbortedToken).ConfigureAwait(false);
await DbContext.GroupPairPreferredPermissions.AddAsync(initialPrefPermissions, RequestAbortedToken).ConfigureAwait(false);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
var self = await DbContext.Users.SingleAsync(u => u.UID == UserUID).ConfigureAwait(false);
var self = await DbContext.Users.SingleAsync(u => u.UID == UserUID, cancellationToken: RequestAbortedToken).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());
@@ -262,10 +320,10 @@ public partial class LightlessHub
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
var groupPairs = await DbContext.GroupPairs.Where(p => p.GroupGID == dto.Group.GID).ToListAsync().ConfigureAwait(false);
var groupPairs = await DbContext.GroupPairs.Where(p => p.GroupGID == dto.Group.GID).ToListAsync(cancellationToken: RequestAbortedToken).ConfigureAwait(false);
DbContext.RemoveRange(groupPairs);
DbContext.Remove(group);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
await Clients.Users(groupPairs.Select(g => g.GroupUserUID)).Client_GroupDelete(new GroupDto(group.ToGroupData())).ConfigureAwait(false);
@@ -278,9 +336,9 @@ public partial class LightlessHub
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (userHasRights, group) = await TryValidateGroupModeratorOrOwner(dto.GID).ConfigureAwait(false);
if (!userHasRights) return new List<BannedGroupUserDto>();
if (!userHasRights) return [];
var banEntries = await DbContext.GroupBans.Include(b => b.BannedUser).Where(g => g.GroupGID == dto.Group.GID).AsNoTracking().ToListAsync().ConfigureAwait(false);
var banEntries = await DbContext.GroupBans.Include(b => b.BannedUser).Where(g => g.GroupGID == dto.Group.GID).AsNoTracking().ToListAsync(cancellationToken: RequestAbortedToken).ConfigureAwait(false);
List<BannedGroupUserDto> bannedGroupUsers = banEntries.Select(b =>
new BannedGroupUserDto(group.ToGroupData(), b.BannedUser.ToUserData(), b.BannedReason, b.BannedOn,
@@ -298,14 +356,14 @@ public partial class LightlessHub
_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 group = await DbContext.Groups.Include(g => g.Owner).AsNoTracking().SingleOrDefaultAsync(g => g.GID == aliasOrGid || g.Alias == aliasOrGid, cancellationToken: RequestAbortedToken).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 existingPair = await DbContext.GroupPairs.AsNoTracking().SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.GroupUserUID == UserUID, cancellationToken: RequestAbortedToken).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);
var existingUserCount = await DbContext.GroupPairs.AsNoTracking().CountAsync(g => g.GroupGID == groupGid, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
var joinedGroups = await DbContext.GroupPairs.CountAsync(g => g.GroupUserUID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
var isBanned = await DbContext.GroupBans.AnyAsync(g => g.GroupGID == groupGid && g.BannedUserUID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
var oneTimeInvite = await DbContext.GroupTempInvites.SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.Invite == hashedPw, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
if (group == null
|| (!string.Equals(group.HashedPassword, hashedPw, StringComparison.Ordinal) && oneTimeInvite == null)
@@ -326,10 +384,13 @@ public partial class LightlessHub
_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 group = await DbContext.Groups.Include(g => g.Owner).AsNoTracking().SingleOrDefaultAsync(g => g.GID == aliasOrGid || g.Alias == aliasOrGid, cancellationToken: RequestAbortedToken).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 isHashedPassword = dto.Password.Length == 64 && dto.Password.All(Uri.IsHexDigit);
var hashedPw = isHashedPassword
? dto.Password
: 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);
@@ -357,9 +418,11 @@ public partial class LightlessHub
{
GroupGID = group.GID,
GroupUserUID = UserUID,
JoinedGroupOn = DateTime.UtcNow,
FromFinder = isHashedPassword
};
var preferredPermissions = await DbContext.GroupPairPreferredPermissions.SingleOrDefaultAsync(u => u.UserUID == UserUID && u.GroupGID == group.GID).ConfigureAwait(false);
var preferredPermissions = await DbContext.GroupPairPreferredPermissions.SingleOrDefaultAsync(u => u.UserUID == UserUID && u.GroupGID == group.GID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
if (preferredPermissions == null)
{
GroupPairPreferredPermission newPerms = new()
@@ -369,7 +432,7 @@ public partial class LightlessHub
DisableSounds = dto.GroupUserPreferredPermissions.IsDisableSounds(),
DisableVFX = dto.GroupUserPreferredPermissions.IsDisableVFX(),
DisableAnimations = dto.GroupUserPreferredPermissions.IsDisableAnimations(),
IsPaused = false
IsPaused = false,
};
DbContext.Add(newPerms);
@@ -384,13 +447,13 @@ public partial class LightlessHub
DbContext.Update(preferredPermissions);
}
await DbContext.GroupPairs.AddAsync(newPair).ConfigureAwait(false);
await DbContext.GroupPairs.AddAsync(newPair, RequestAbortedToken).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(aliasOrGid, "Success"));
await DbContext.SaveChangesAsync().ConfigureAwait(false);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
var groupInfos = await DbContext.GroupPairs.Where(u => u.GroupGID == group.GID && (u.IsPinned || u.IsModerator)).ToListAsync().ConfigureAwait(false);
var groupInfos = await DbContext.GroupPairs.Where(u => u.GroupGID == group.GID && (u.IsPinned || u.IsModerator)).ToListAsync(cancellationToken: RequestAbortedToken).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);
@@ -518,11 +581,92 @@ public partial class LightlessHub
}
}
await DbContext.SaveChangesAsync().ConfigureAwait(false);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
return true;
}
[Authorize(Policy = "Identified")]
public async Task<GroupJoinInfoDto> GroupJoinHashed(GroupJoinHashedDto 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 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 == dto.HashedPassword)
.ConfigureAwait(false);
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);
if (group == null)
{
await Clients.User(UserUID).Client_ReceiveServerMessage(MessageSeverity.Warning, "Syncshell not found.");
return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false);
}
if (!string.Equals(group.HashedPassword, dto.HashedPassword, StringComparison.Ordinal) && oneTimeInvite == null)
{
await Clients.User(UserUID).Client_ReceiveServerMessage(MessageSeverity.Warning, "Incorrect or expired password.");
return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false);
}
if (existingPair != null)
{
await Clients.User(UserUID).Client_ReceiveServerMessage(MessageSeverity.Warning, "You are already a member of this syncshell.");
return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false);
}
if (existingUserCount >= _maxGroupUserCount)
{
await Clients.User(UserUID).Client_ReceiveServerMessage(MessageSeverity.Warning, "This syncshell is full.");
return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false);
}
if (!group.InvitesEnabled)
{
await Clients.User(UserUID).Client_ReceiveServerMessage(MessageSeverity.Warning, "Invites to this syncshell are currently disabled.");
return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false);
}
if (joinedGroups >= _maxJoinedGroupsByUser)
{
await Clients.User(UserUID).Client_ReceiveServerMessage(MessageSeverity.Warning, "You have reached the maximum number of syncshells you can join.");
return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false);
}
if (isBanned)
{
await Clients.User(UserUID).Client_ReceiveServerMessage(MessageSeverity.Warning, "You are banned from this syncshell.");
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 GroupLeave(GroupDto dto)
{
@@ -541,8 +685,8 @@ public partial class LightlessHub
.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
&& !string.Equals(p.GroupUserUID, UserUID, StringComparison.Ordinal)
&& !string.Equals(p.Group.OwnerUID, p.GroupUserUID, StringComparison.Ordinal)
&& p.GroupUser.LastLoggedIn.AddDays(days) < DateTime.UtcNow);
if (!execute) return usersToPrune.Count();
@@ -555,7 +699,7 @@ public partial class LightlessHub
.Client_GroupPairLeft(new GroupPairDto(dto.Group, pair.GroupUser.ToUserData())).ConfigureAwait(false);
}
await DbContext.SaveChangesAsync().ConfigureAwait(false);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
return usersToPrune.Count();
}
@@ -579,15 +723,15 @@ public partial class LightlessHub
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);
var sharedData = await DbContext.CharaDataAllowances.Where(u => u.AllowedGroup != null && u.AllowedGroupGID == dto.GID && u.ParentUploaderUID == dto.UID).ToListAsync(cancellationToken: RequestAbortedToken).ConfigureAwait(false);
DbContext.CharaDataAllowances.RemoveRange(sharedData);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
var userIdent = await GetUserIdent(dto.User.UID).ConfigureAwait(false);
if (userIdent == null)
{
await DbContext.SaveChangesAsync().ConfigureAwait(false);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
return;
}
@@ -600,6 +744,137 @@ public partial class LightlessHub
}
}
[Authorize(Policy = "Identified")]
public async Task<GroupProfileDto> GroupGetProfile(GroupDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var cancellationToken = RequestAbortedToken;
if (dto?.Group == null)
{
_logger.LogCallWarning(LightlessHubLogger.Args("GroupGetProfile: dto.Group is null"));
return new GroupProfileDto(Group: null, Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: false, IsDisabled: false);
}
var data = await DbContext.GroupProfiles
.Include(gp => gp.Group)
.FirstOrDefaultAsync(
g => g.Group.GID == dto.Group.GID || g.Group.Alias == dto.Group.AliasOrGID,
cancellationToken
)
.ConfigureAwait(false);
if (data == null)
{
return new GroupProfileDto(dto.Group, Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: false, IsDisabled: false);
}
if (data.ProfileDisabled)
{
return new GroupProfileDto(Group: dto.Group, Description: "This profile was permanently disabled", Tags: [], PictureBase64: null, BannerBase64: null, IsNsfw: false, IsDisabled: true);
}
try
{
return data.ToDTO();
}
catch (Exception ex)
{
_logger.LogCallWarning(LightlessHubLogger.Args(ex, "GroupGetProfile: failed to map GroupProfileDto for {Group}", dto.Group.GID ?? dto.Group.AliasOrGID));
return new GroupProfileDto(dto.Group, Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: false, IsDisabled: false);
}
}
[Authorize(Policy = "Identified")]
public async Task GroupSetProfile(GroupProfileDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var cancellationToken = RequestAbortedToken;
if (dto.Group == null) return;
var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (!hasRights) return;
var groupProfileDb = await DbContext.GroupProfiles
.Include(g => g.Group)
.FirstOrDefaultAsync(g => g.GroupGID == dto.Group.GID, cancellationToken)
.ConfigureAwait(false);
ImageCheckService.ImageLoadResult profileResult = new();
ImageCheckService.ImageLoadResult bannerResult = new();
//Avatar image validation
if (!string.IsNullOrEmpty(dto.PictureBase64))
{
profileResult = await ImageCheckService.ValidateImageAsync(dto.PictureBase64, banner: false, RequestAbortedToken).ConfigureAwait(false);
if (!profileResult.Success)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, profileResult.ErrorMessage).ConfigureAwait(false);
return;
}
}
//Banner image validation
if (!string.IsNullOrEmpty(dto.BannerBase64))
{
bannerResult = await ImageCheckService.ValidateImageAsync(dto.BannerBase64, banner: true, RequestAbortedToken).ConfigureAwait(false);
if (!bannerResult.Success)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, bannerResult.ErrorMessage).ConfigureAwait(false);
return;
}
}
var sanitizedProfileImage = profileResult?.Base64Image;
var sanitizedBannerImage = bannerResult?.Base64Image;
if (groupProfileDb == null)
{
groupProfileDb = new GroupProfile
{
GroupGID = dto.Group.GID,
Group = group,
ProfileDisabled = false,
IsNSFW = dto.IsNsfw ?? false,
};
groupProfileDb.UpdateProfileFromDto(dto, sanitizedProfileImage, sanitizedBannerImage);
await DbContext.GroupProfiles.AddAsync(groupProfileDb, cancellationToken).ConfigureAwait(false);
}
else
{
groupProfileDb.Group ??= group;
if (groupProfileDb?.ProfileDisabled ?? false)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your profile was permanently disabled and cannot be edited").ConfigureAwait(false);
return;
}
groupProfileDb.UpdateProfileFromDto(dto, sanitizedProfileImage, sanitizedBannerImage);
}
var userIds = await DbContext.GroupPairs
.Where(p => p.GroupGID == groupProfileDb.GroupGID)
.Select(p => p.GroupUserUID)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (userIds.Count > 0)
{
var profileDto = groupProfileDb.ToDTO();
await Clients.Users(userIds).Client_GroupSendProfile(profileDto)
.ConfigureAwait(false);
}
await DbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task GroupSetUserInfo(GroupPairUserInfoDto dto)
{
@@ -629,9 +904,9 @@ public partial class LightlessHub
userPair.IsModerator = false;
}
await DbContext.SaveChangesAsync().ConfigureAwait(false);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
var groupPairs = await DbContext.GroupPairs.AsNoTracking().Where(p => p.GroupGID == dto.Group.GID).Select(p => p.GroupUserUID).ToListAsync().ConfigureAwait(false);
var groupPairs = await DbContext.GroupPairs.AsNoTracking().Where(p => p.GroupGID == dto.Group.GID).Select(p => p.GroupUserUID).ToListAsync(cancellationToken: RequestAbortedToken).ConfigureAwait(false);
await Clients.Users(groupPairs).Client_GroupPairChangeUserInfo(new GroupPairUserInfoDto(dto.Group, dto.User, userPair.ToEnum())).ConfigureAwait(false);
}
@@ -640,17 +915,48 @@ public partial class LightlessHub
{
_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);
var ct = RequestAbortedToken;
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();
var result = await (
from gp in DbContext.GroupPairs
.Include(gp => gp.Group)
.ThenInclude(g => g.Owner)
join pp in DbContext.GroupPairPreferredPermissions
on new { gp.GroupGID, UserUID } equals new { pp.GroupGID, pp.UserUID }
where gp.GroupUserUID == UserUID
select new
{
GroupPair = gp,
PreferredPermission = pp,
GroupInfos = DbContext.GroupPairs
.Where(x => x.GroupGID == gp.GroupGID && (x.IsPinned || x.IsModerator))
.Select(x => new { x.GroupUserUID, EnumValue = x.ToEnum() })
.ToList(),
})
.AsNoTracking()
.ToListAsync()
.ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(result));
List<GroupFullInfoDto> List = [.. result.Select(r =>
{
var groupInfoDict = r.GroupInfos
.ToDictionary(x => x.GroupUserUID, x => x.EnumValue, StringComparer.Ordinal);
_logger.LogCallInfo(LightlessHubLogger.Args(r));
return new GroupFullInfoDto(
r.GroupPair.Group.ToGroupData(),
r.GroupPair.Group.Owner.ToUserData(),
r.GroupPair.Group.ToEnum(),
r.PreferredPermission.ToEnum(),
r.GroupPair.ToEnum(),
groupInfoDict
);
}),];
return List;
}
[Authorize(Policy = "Identified")]
@@ -661,12 +967,95 @@ public partial class LightlessHub
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);
var banEntry = await DbContext.GroupBans.SingleOrDefaultAsync(g => g.GroupGID == dto.Group.GID && g.BannedUserUID == dto.User.UID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
if (banEntry == null) return;
DbContext.Remove(banEntry);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
}
[Authorize(Policy = "Identified")]
public async Task<bool> SetGroupBroadcastStatus(GroupBroadcastRequestDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
if (string.IsNullOrEmpty(dto.HashedCID))
{
_logger.LogCallWarning(LightlessHubLogger.Args("missing CID in syncshell broadcast request", "User", UserUID, "GID", dto.GID));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Internal error: missing CID.");
return false;
}
if (!_broadcastConfiguration.EnableBroadcasting || !_broadcastConfiguration.EnableSyncshellBroadcastPayloads)
{
_logger.LogCallWarning(LightlessHubLogger.Args("syncshell broadcast disabled", "User", UserUID, "GID", dto.GID));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Syncshell broadcasting is currently disabled.").ConfigureAwait(false);
return false;
}
var (isOwner, _) = await TryValidateOwner(dto.GID).ConfigureAwait(false);
if (!isOwner)
{
_logger.LogCallWarning(LightlessHubLogger.Args("Unauthorized syncshell broadcast change", "User", UserUID, "GID", dto.GID));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "You must be the owner of the syncshell to broadcast it.");
return false;
}
return true;
}
[Authorize(Policy = "Identified")]
public async Task<List<GroupJoinDto>> GetBroadcastedGroups(List<BroadcastStatusInfoDto> broadcastEntries)
{
_logger.LogCallInfo(LightlessHubLogger.Args("Requested Syncshells", broadcastEntries.Select(b => b.GID)));
if (!_broadcastConfiguration.EnableBroadcasting || !_broadcastConfiguration.EnableSyncshellBroadcastPayloads)
return new List<GroupJoinDto>();
var results = new List<GroupJoinDto>();
var gidsToValidate = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in broadcastEntries)
{
if (string.IsNullOrWhiteSpace(entry.HashedCID) || string.IsNullOrWhiteSpace(entry.GID))
continue;
var redisKey = _broadcastConfiguration.BuildRedisKey(entry.HashedCID);
var redisEntry = await _redis.GetAsync<BroadcastRedisEntry>(redisKey).ConfigureAwait(false);
if (redisEntry is null)
continue;
if (!string.IsNullOrEmpty(redisEntry.HashedCID) && !string.Equals(redisEntry.HashedCID, entry.HashedCID, StringComparison.Ordinal))
{
_logger.LogCallWarning(LightlessHubLogger.Args("mismatched broadcast cid for group lookup", "Requested", entry.HashedCID, "EntryCID", redisEntry.HashedCID));
continue;
}
if (redisEntry.GID != null && string.Equals(redisEntry.GID, entry.GID, StringComparison.OrdinalIgnoreCase))
gidsToValidate.Add(entry.GID);
}
if (gidsToValidate.Count == 0)
return results;
var groups = await DbContext.Groups
.AsNoTracking()
.Where(g => gidsToValidate.Contains(g.GID) && g.InvitesEnabled)
.ToListAsync()
.ConfigureAwait(false);
foreach (var group in groups)
{
results.Add(new GroupJoinDto(
Group: new GroupData(group.GID, group.Alias),
Password: group.HashedPassword,
GroupUserPreferredPermissions: new GroupUserPreferredPermissions()
));
}
return results;
}
}

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;
@@ -24,10 +25,12 @@ 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 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;
@@ -41,11 +44,13 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
private readonly int _maxCharaDataByUser;
private readonly int _maxCharaDataByUserVanity;
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)
{
_lightlessMetrics = lightlessMetrics;
_systemInfoService = systemInfoService;
@@ -64,6 +69,8 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
_gPoseLobbyDistributionService = gPoseLobbyDistributionService;
_logger = new LightlessHubLogger(this, logger);
_dbContextLazy = new Lazy<LightlessDbContext>(() => lightlessDbContextFactory.CreateDbContext());
_broadcastConfiguration = broadcastConfiguration;
_pairService = pairService;
}
protected override void Dispose(bool disposing)
@@ -109,6 +116,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 +160,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 +189,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,6 +206,8 @@ 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);

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

@@ -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,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

@@ -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,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,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

@@ -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,
@@ -66,10 +74,12 @@ public sealed class SystemInfoService : BackgroundService
using var db = await _dbContextFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeAuthorizedConnections, onlineUsers);
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeLightFinderConnections, countLightFinderUsers);
_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

@@ -2,6 +2,7 @@ using AspNetCoreRateLimit;
using LightlessSync.API.SignalR;
using LightlessSyncAuthService.Controllers;
using LightlessSyncServer.Controllers;
using LightlessSyncServer.Configuration;
using LightlessSyncServer.Hubs;
using LightlessSyncServer.Services;
using LightlessSyncShared.Data;
@@ -87,7 +88,9 @@ public class Startup
services.Configure<ServerConfiguration>(Configuration.GetRequiredSection("LightlessSync"));
services.Configure<LightlessConfigurationBase>(Configuration.GetRequiredSection("LightlessSync"));
services.Configure<BroadcastOptions>(Configuration.GetSection("Broadcast"));
services.AddSingleton<IBroadcastConfiguration, BroadcastConfiguration>();
services.AddSingleton<ServerTokenGenerator>();
services.AddSingleton<SystemInfoService>();
services.AddSingleton<OnlineSyncedPairCacheService>();
@@ -105,6 +108,7 @@ public class Startup
services.AddSingleton<CharaDataCleanupService>();
services.AddHostedService(provider => provider.GetService<CharaDataCleanupService>());
services.AddHostedService<ClientPairPermissionsCleanupService>();
services.AddScoped<PairService>();
}
services.AddSingleton<GPoseLobbyDistributionService>();
@@ -291,6 +295,8 @@ public class Startup
}, new List<string>
{
MetricsAPI.GaugeAuthorizedConnections,
MetricsAPI.GaugeLightFinderConnections,
MetricsAPI.GaugeLightFinderGroups,
MetricsAPI.GaugeConnections,
MetricsAPI.GaugePairs,
MetricsAPI.GaugePairsPaused,

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,96 @@ 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;
}
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);
}

View File

@@ -1,5 +1,4 @@
using LightlessSync.API.SignalR;
using LightlessSyncServer.Hubs;
using LightlessSyncServer.Hubs;
using System.Runtime.CompilerServices;
namespace LightlessSyncServer.Utils;

View File

@@ -29,6 +29,17 @@
"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
},
"AllowedHosts": "*",
"Kestrel": {
"Endpoints": {

View File

@@ -1,4 +1,4 @@
using Discord;
using Discord;
using Discord.Interactions;
using Discord.Rest;
using Discord.WebSocket;
@@ -384,13 +384,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);
}
}
@@ -400,22 +437,55 @@ 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);
}
else
{
var secondaryUsers = await db.Auth.Include(u => u.User)
.Where(u => u.PrimaryUserUID == lodestoneAuth.User.UID).ToListAsync().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 async Task UpdateStatusAsync(CancellationToken token)

View File

@@ -1,4 +1,4 @@
using Discord.Interactions;
using Discord.Interactions;
using Discord;
using Microsoft.EntityFrameworkCore;
using System.Text.RegularExpressions;
@@ -123,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);
@@ -199,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

@@ -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,8 @@ 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 GaugePairs = "lightless_pairs";
public const string GaugePairsPaused = "lightless_pairs_paused";
public const string GaugeFilesTotal = "lightless_files";

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");
}
}
}

View File

@@ -589,6 +589,10 @@ namespace LightlessSyncServer.Migrations
.HasColumnType("character varying(20)")
.HasColumnName("group_gid");
b.Property<string>("Base64GroupBannerImage")
.HasColumnType("text")
.HasColumnName("base64group_banner_image");
b.Property<string>("Base64GroupProfileImage")
.HasColumnType("text")
.HasColumnName("base64group_profile_image");
@@ -597,8 +601,16 @@ namespace LightlessSyncServer.Migrations
.HasColumnType("text")
.HasColumnName("description");
b.Property<string>("Tags")
.HasColumnType("text")
b.Property<bool>("IsNSFW")
.HasColumnType("boolean")
.HasColumnName("is_nsfw");
b.Property<bool>("ProfileDisabled")
.HasColumnType("boolean")
.HasColumnName("profile_disabled");
b.PrimitiveCollection<int[]>("Tags")
.HasColumnType("integer[]")
.HasColumnName("tags");
b.HasKey("GroupGID")
@@ -683,6 +695,10 @@ namespace LightlessSyncServer.Migrations
.HasColumnType("character varying(15)")
.HasColumnName("alias");
b.Property<bool>("HasVanity")
.HasColumnType("boolean")
.HasColumnName("has_vanity");
b.Property<bool>("IsAdmin")
.HasColumnType("boolean")
.HasColumnName("is_admin");
@@ -695,6 +711,16 @@ namespace LightlessSyncServer.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("last_logged_in");
b.Property<string>("TextColorHex")
.HasMaxLength(9)
.HasColumnType("character varying(9)")
.HasColumnName("text_color_hex");
b.Property<string>("TextGlowColorHex")
.HasMaxLength(9)
.HasColumnType("character varying(9)")
.HasColumnName("text_glow_color_hex");
b.Property<byte[]>("Timestamp")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
@@ -802,6 +828,10 @@ namespace LightlessSyncServer.Migrations
.HasColumnType("character varying(10)")
.HasColumnName("user_uid");
b.Property<string>("Base64BannerImage")
.HasColumnType("text")
.HasColumnName("base64banner_image");
b.Property<string>("Base64ProfileImage")
.HasColumnType("text")
.HasColumnName("base64profile_image");
@@ -818,6 +848,10 @@ namespace LightlessSyncServer.Migrations
.HasColumnType("boolean")
.HasColumnName("profile_disabled");
b.PrimitiveCollection<int[]>("Tags")
.HasColumnType("integer[]")
.HasColumnName("tags");
b.Property<string>("UserDescription")
.HasColumnType("text")
.HasColumnName("user_description");

View File

@@ -13,6 +13,9 @@ public class GroupProfile
public string GroupGID { get; set; }
public Group Group { get; set; }
public string Description { get; set; }
public string Tags { 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;
}

View File

@@ -1,4 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
namespace LightlessSyncShared.Models;
@@ -14,6 +14,14 @@ public class User
public bool IsAdmin { get; set; } = false;
public bool HasVanity { get; set; } = false;
[MaxLength(9)]
public string? TextColorHex { get; set; } = string.Empty;
[MaxLength(9)]
public string? TextGlowColorHex { get; set; } = string.Empty;
public DateTime LastLoggedIn { get; set; }
[MaxLength(15)]
public string Alias { get; set; }

View File

@@ -6,12 +6,14 @@ namespace LightlessSyncShared.Models;
public class UserProfileData
{
public string Base64ProfileImage { get; set; }
public string Base64BannerImage { get; set; }
public bool FlaggedForReport { get; set; }
public bool IsNSFW { get; set; }
public bool ProfileDisabled { get; set; }
public User User { get; set; }
public string UserDescription { get; set; }
public int[] Tags { get; set; }
[Required]
[Key]

View File

@@ -20,6 +20,8 @@ public class StaticFilesServerConfiguration : LightlessConfigurationBase
public string ColdStorageDirectory { get; set; } = null;
public double ColdStorageSizeHardLimitInGiB { get; set; } = -1;
public int ColdStorageUnusedFileRetentionPeriodInDays { get; set; } = 30;
public bool EnableDirectDownloads { get; set; } = true;
public int DirectDownloadTokenLifetimeSeconds { get; set; } = 300;
[RemoteConfiguration]
public double SpeedTestHoursRateLimit { get; set; } = 0.5;
[RemoteConfiguration]
@@ -40,6 +42,8 @@ public class StaticFilesServerConfiguration : LightlessConfigurationBase
sb.AppendLine($"{nameof(CacheDirectory)} => {CacheDirectory}");
sb.AppendLine($"{nameof(DownloadQueueSize)} => {DownloadQueueSize}");
sb.AppendLine($"{nameof(DownloadQueueReleaseSeconds)} => {DownloadQueueReleaseSeconds}");
sb.AppendLine($"{nameof(EnableDirectDownloads)} => {EnableDirectDownloads}");
sb.AppendLine($"{nameof(DirectDownloadTokenLifetimeSeconds)} => {DirectDownloadTokenLifetimeSeconds}");
return sb.ToString();
}
}

View File

@@ -8,6 +8,7 @@ public static class LightlessClaimTypes
public const string Internal = "internal";
public const string Expires = "expiration_date";
public const string Continent = "continent";
public const string Country = "country";
public const string DiscordUser = "discord_user";
public const string DiscordId = "discord_user_id";
public const string OAuthLoginToken = "oauth_login_token";

View File

@@ -10,6 +10,7 @@ using LightlessSyncShared.Services;
using LightlessSyncShared.Utils.Configuration;
using LightlessSyncStaticFilesServer.Services;
using LightlessSyncStaticFilesServer.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
@@ -32,12 +33,15 @@ public class ServerFilesController : ControllerBase
private readonly IDbContextFactory<LightlessDbContext> _lightlessDbContext;
private readonly LightlessMetrics _metricsClient;
private readonly MainServerShardRegistrationService _shardRegistrationService;
private readonly CDNDownloadUrlService _cdnDownloadUrlService;
private readonly CDNDownloadsService _cdnDownloadsService;
public ServerFilesController(ILogger<ServerFilesController> logger, CachedFileProvider cachedFileProvider,
IConfigurationService<StaticFilesServerConfiguration> configuration,
IHubContext<LightlessHub> hubContext,
IDbContextFactory<LightlessDbContext> lightlessDbContext, LightlessMetrics metricsClient,
MainServerShardRegistrationService shardRegistrationService) : base(logger)
MainServerShardRegistrationService shardRegistrationService, CDNDownloadUrlService cdnDownloadUrlService,
CDNDownloadsService cdnDownloadsService) : base(logger)
{
_basePath = configuration.GetValueOrDefault(nameof(StaticFilesServerConfiguration.UseColdStorage), false)
? configuration.GetValue<string>(nameof(StaticFilesServerConfiguration.ColdStorageDirectory))
@@ -48,6 +52,8 @@ public class ServerFilesController : ControllerBase
_lightlessDbContext = lightlessDbContext;
_metricsClient = metricsClient;
_shardRegistrationService = shardRegistrationService;
_cdnDownloadUrlService = cdnDownloadUrlService;
_cdnDownloadsService = cdnDownloadsService;
}
[HttpPost(LightlessFiles.ServerFiles_DeleteAll)]
@@ -105,6 +111,16 @@ public class ServerFilesController : ControllerBase
baseUrl = shard.Value ?? _configuration.GetValue<Uri>(nameof(StaticFilesServerConfiguration.CdnFullUrl));
}
var cdnDownloadUrl = string.Empty;
if (forbiddenFile == null)
{
var directUri = _cdnDownloadUrlService.TryCreateDirectDownloadUri(baseUrl, file.Hash);
if (directUri != null)
{
cdnDownloadUrl = directUri.ToString();
}
}
response.Add(new DownloadFileDto
{
FileExists = file.Size > 0,
@@ -113,6 +129,7 @@ public class ServerFilesController : ControllerBase
Hash = file.Hash,
Size = file.Size,
Url = baseUrl?.ToString() ?? string.Empty,
CDNDownloadUrl = cdnDownloadUrl,
RawSize = file.RawSize
});
}
@@ -127,6 +144,22 @@ public class ServerFilesController : ControllerBase
return Ok(JsonSerializer.Serialize(allFileShards.SelectMany(t => t.RegionUris.Select(v => v.Value.ToString()))));
}
[HttpGet(LightlessFiles.ServerFiles_DirectDownload + "/{hash}")]
[AllowAnonymous]
public async Task<IActionResult> DownloadFileDirect(string hash, [FromQuery] long expires, [FromQuery] string signature)
{
var result = await _cdnDownloadsService.GetDownloadAsync(hash, expires, signature).ConfigureAwait(false);
return result.Status switch
{
CDNDownloadsService.ResultStatus.Disabled => NotFound(),
CDNDownloadsService.ResultStatus.Unauthorized => Unauthorized(),
CDNDownloadsService.ResultStatus.NotFound => NotFound(),
CDNDownloadsService.ResultStatus.Success => PhysicalFile(result.File!.FullName, "application/octet-stream"),
_ => NotFound()
};
}
[HttpPost(LightlessFiles.ServerFiles_FilesSend)]
public async Task<IActionResult> FilesSend([FromBody] FilesSendDto filesSendDto)
{
@@ -360,4 +393,4 @@ public class ServerFilesController : ControllerBase
buffer[i] ^= 42;
}
}
}
}

View File

@@ -0,0 +1,34 @@
using LightlessSync.API.Routes;
using LightlessSyncStaticFilesServer.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace LightlessSyncStaticFilesServer.Controllers;
[Route(LightlessFiles.ServerFiles)]
public class ShardServerFilesController : ControllerBase
{
private readonly CDNDownloadsService _cdnDownloadsService;
public ShardServerFilesController(ILogger<ShardServerFilesController> logger,
CDNDownloadsService cdnDownloadsService) : base(logger)
{
_cdnDownloadsService = cdnDownloadsService;
}
[HttpGet(LightlessFiles.ServerFiles_DirectDownload + "/{hash}")]
[AllowAnonymous]
public async Task<IActionResult> DownloadFileDirect(string hash, [FromQuery] long expires, [FromQuery] string signature)
{
var result = await _cdnDownloadsService.GetDownloadAsync(hash, expires, signature).ConfigureAwait(false);
return result.Status switch
{
CDNDownloadsService.ResultStatus.Disabled => NotFound(),
CDNDownloadsService.ResultStatus.Unauthorized => Unauthorized(),
CDNDownloadsService.ResultStatus.NotFound => NotFound(),
CDNDownloadsService.ResultStatus.Success => PhysicalFile(result.File!.FullName, "application/octet-stream"),
_ => NotFound()
};
}
}

View File

@@ -0,0 +1,108 @@
using LightlessSync.API.Routes;
using LightlessSyncShared.Services;
using LightlessSyncShared.Utils.Configuration;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.WebUtilities;
using System.Security.Cryptography;
using System.Text;
using System.Globalization;
using System.Linq;
namespace LightlessSyncStaticFilesServer.Services;
public class CDNDownloadUrlService
{
private readonly IConfigurationService<StaticFilesServerConfiguration> _staticConfig;
private readonly IConfigurationService<LightlessConfigurationBase> _globalConfig;
private readonly ILogger<CDNDownloadUrlService> _logger;
public CDNDownloadUrlService(IConfigurationService<StaticFilesServerConfiguration> staticConfig,
IConfigurationService<LightlessConfigurationBase> globalConfig, ILogger<CDNDownloadUrlService> logger)
{
_staticConfig = staticConfig;
_globalConfig = globalConfig;
_logger = logger;
}
public bool DirectDownloadsEnabled =>
_staticConfig.GetValueOrDefault(nameof(StaticFilesServerConfiguration.EnableDirectDownloads), false);
public Uri? TryCreateDirectDownloadUri(Uri? baseUri, string hash)
{
if (!DirectDownloadsEnabled || baseUri == null)
{
return null;
}
if (!IsSupportedHash(hash))
{
_logger.LogDebug("Skipping direct download link generation for invalid hash {hash}", hash);
return null;
}
var normalizedHash = hash.ToUpperInvariant();
var lifetimeSeconds = Math.Max(5,
_staticConfig.GetValueOrDefault(nameof(StaticFilesServerConfiguration.DirectDownloadTokenLifetimeSeconds), 300));
var expiresAt = DateTimeOffset.UtcNow.AddSeconds(lifetimeSeconds);
var signature = CreateSignature(normalizedHash, expiresAt.ToUnixTimeSeconds());
var directPath = $"{LightlessFiles.ServerFiles}/{LightlessFiles.ServerFiles_DirectDownload}/{normalizedHash}";
var builder = new UriBuilder(new Uri(baseUri, directPath));
var query = new QueryBuilder
{
{ "expires", expiresAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) },
{ "signature", signature }
};
builder.Query = query.ToQueryString().Value!.TrimStart('?');
return builder.Uri;
}
public bool TryValidateSignature(string hash, long expiresUnixSeconds, string signature)
{
if (!DirectDownloadsEnabled)
{
return false;
}
if (string.IsNullOrEmpty(signature) || !IsSupportedHash(hash))
{
return false;
}
var normalizedHash = hash.ToUpperInvariant();
DateTimeOffset expiresAt;
try
{
expiresAt = DateTimeOffset.FromUnixTimeSeconds(expiresUnixSeconds);
}
catch (ArgumentOutOfRangeException)
{
return false;
}
if (expiresAt < DateTimeOffset.UtcNow)
{
return false;
}
var expected = CreateSignature(normalizedHash, expiresAt.ToUnixTimeSeconds());
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(signature));
}
private string CreateSignature(string hash, long expiresUnixSeconds)
{
var signingKey = _globalConfig.GetValue<string>(nameof(LightlessConfigurationBase.Jwt));
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(signingKey));
var payload = Encoding.UTF8.GetBytes($"{hash}:{expiresUnixSeconds}");
return WebEncoders.Base64UrlEncode(hmac.ComputeHash(payload));
}
private static bool IsSupportedHash(string hash)
{
return hash.Length == 40 && hash.All(char.IsAsciiLetterOrDigit);
}
}

View File

@@ -0,0 +1,56 @@
using System.IO;
using System.Threading.Tasks;
namespace LightlessSyncStaticFilesServer.Services;
public class CDNDownloadsService
{
public enum ResultStatus
{
Disabled,
Unauthorized,
NotFound,
Success
}
public readonly record struct Result(ResultStatus Status, FileInfo? File);
private readonly CDNDownloadUrlService _cdnDownloadUrlService;
private readonly CachedFileProvider _cachedFileProvider;
public CDNDownloadsService(CDNDownloadUrlService cdnDownloadUrlService, CachedFileProvider cachedFileProvider)
{
_cdnDownloadUrlService = cdnDownloadUrlService;
_cachedFileProvider = cachedFileProvider;
}
public bool DownloadsEnabled => _cdnDownloadUrlService.DirectDownloadsEnabled;
public async Task<Result> GetDownloadAsync(string hash, long expiresUnixSeconds, string signature)
{
if (!_cdnDownloadUrlService.DirectDownloadsEnabled)
{
return new Result(ResultStatus.Disabled, null);
}
if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(hash))
{
return new Result(ResultStatus.Unauthorized, null);
}
hash = hash.ToUpperInvariant();
if (!_cdnDownloadUrlService.TryValidateSignature(hash, expiresUnixSeconds, signature))
{
return new Result(ResultStatus.Unauthorized, null);
}
var fileInfo = await _cachedFileProvider.DownloadAndGetLocalFileInfo(hash).ConfigureAwait(false);
if (fileInfo == null)
{
return new Result(ResultStatus.NotFound, null);
}
return new Result(ResultStatus.Success, fileInfo);
}
}

View File

@@ -87,6 +87,8 @@ public class Startup
services.AddSingleton<RequestFileStreamResultFactory>();
services.AddSingleton<ServerTokenGenerator>();
services.AddSingleton<RequestQueueService>();
services.AddSingleton<CDNDownloadUrlService>();
services.AddSingleton<CDNDownloadsService>();
services.AddHostedService(p => p.GetService<RequestQueueService>());
services.AddHostedService(m => m.GetService<FileStatisticsService>());
services.AddSingleton<IConfigurationService<LightlessConfigurationBase>, LightlessConfigurationServiceClient<LightlessConfigurationBase>>();
@@ -204,11 +206,12 @@ public class Startup
}
else if (_isDistributionNode)
{
a.FeatureProviders.Add(new AllowedControllersFeatureProvider(typeof(CacheController), typeof(RequestController), typeof(DistributionController), typeof(SpeedTestController)));
a.FeatureProviders.Add(new AllowedControllersFeatureProvider(typeof(CacheController), typeof(RequestController),
typeof(DistributionController), typeof(ShardServerFilesController), typeof(SpeedTestController)));
}
else
{
a.FeatureProviders.Add(new AllowedControllersFeatureProvider(typeof(CacheController), typeof(RequestController), typeof(SpeedTestController)));
a.FeatureProviders.Add(new AllowedControllersFeatureProvider(typeof(CacheController), typeof(ShardServerFilesController), typeof(RequestController), typeof(SpeedTestController)));
}
});
@@ -233,7 +236,6 @@ public class Startup
}).AddJwtBearer();
services.AddAuthorization(options =>
{
options.FallbackPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build();
options.AddPolicy("Internal", new AuthorizationPolicyBuilder().RequireClaim(LightlessClaimTypes.Internal, "true").Build());
});
services.AddSingleton<IUserIdProvider, IdBasedUserIdProvider>();

View File

@@ -25,7 +25,9 @@
"UnusedFileRetentionPeriodInDays": 7,
"CacheDirectory": "G:\\ServerTest",
"ServiceAddress": "http://localhost:5002",
"RemoteCacheSourceUri": ""
"RemoteCacheSourceUri": "",
"EnableDirectDownloads": true,
"DirectDownloadTokenLifetimeSeconds": 300
},
"AllowedHosts": "*"
}