Compare commits

...

114 Commits

Author SHA1 Message Date
defnotken
86fd24f756 clean up logging 2025-11-20 09:33:18 -06:00
defnotken
5ed802e37c move western canadians to eastern file shard 2025-11-20 09:15:15 -06:00
63211b2e8b Merge pull request 'Fix Fk' (#34) from fkfix into master
Reviewed-on: #34
2025-11-17 18:34:18 +01:00
defnotken
a1280d58bf Fix Fk 2025-11-17 09:34:33 -06:00
34f0223a85 revert revert regex 2025-11-13 15:50:19 +01:00
69f06f5868 Merge pull request 'revert regex' (#33) from revert-regex into master
Reviewed-on: #33
2025-11-13 15:22:20 +01:00
066f56e5a2 Merge branch 'master' into revert-regex 2025-11-13 15:22:05 +01:00
defnotken
287f72b6ad revert regex 2025-11-13 08:21:37 -06:00
ef13566b7a Merge pull request 'Fix chat stuff' (#32) from chat into master
Reviewed-on: #32
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-11-12 01:53:11 +01:00
084b4711b0 Merge branch 'master' into chat 2025-11-12 00:36:29 +01:00
azyges
9d496ee8e9 fix server message oops 2025-11-12 07:22:16 +09:00
azyges
0632c24a08 Merge branch 'chat' of https://git.lightless-sync.org/Lightless-Sync/LightlessServer into chat 2025-11-12 04:40:03 +09:00
azyges
8821f1d450 adjustments and add rate limit 2025-11-12 04:39:32 +09:00
3b0e80f92b Merge pull request 'Updated Lodestone URL regex' (#30) from lodestone-auth-regex-adjust into master
Reviewed-on: #30
2025-11-11 19:11:43 +01:00
586b5d0dd5 Chat Support for Server.
Reviewed-on: #31
2025-11-11 19:09:28 +01:00
defnotken
6858431c2d Merge branch 'master' into chat 2025-11-11 12:05:59 -06:00
cake
50c9268e76 updated submodule 2025-11-11 18:59:50 +01:00
625caa1e6a Refactor Discord Bot to make sense..
Reviewed-on: #29
2025-11-11 18:48:58 +01:00
defnotken
10cb6e9c2c update reference 2025-11-11 11:47:44 -06:00
defnotken
72b5b21624 Working User Info Changes. 2025-11-11 11:29:09 -06:00
defnotken
ac37020429 Lets try banners 2025-11-11 11:16:12 -06:00
defnotken
3b93ebb9e5 more logging 2025-11-11 11:04:39 -06:00
defnotken
44dc35ff99 logging image 2025-11-11 10:59:02 -06:00
defnotken
fac5b3caef Log warning 2025-11-11 10:54:24 -06:00
defnotken
f4ac99ba05 reeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee 2025-11-11 10:50:54 -06:00
defnotken
a63742df83 Lets try this again 2025-11-11 10:44:57 -06:00
defnotken
47fec97e81 Testing user info part 2 2025-11-11 10:29:54 -06:00
defnotken
32a9e93217 Testing profile in userinfo 2025-11-11 09:53:59 -06:00
azyges
cf5135f598 add generated world, territory registries and serverside verification for only legit territories and worlds defined by server 2025-11-08 07:38:35 +09:00
azyges
7cfe29e511 clean up structs and seperate zone definitions 2025-11-05 01:40:48 +09:00
0f95f26c1c Implemented match group instead of tinkering with the URL string
We're using regex already anyways, so might as well take advantage of matching groups. Group 1 will always be the country code and group 2 always the ID
2025-11-01 22:47:05 +01:00
8e36b062fd Updated Lodestone URL regex
Made it match the lodestone URL scheme exactly, with optional trailing "/" and nothing before or after the URL
2025-11-01 22:29:09 +01:00
defnotken
47b2a7a9b9 boolz 2025-10-29 18:26:41 -05:00
defnotken
7520bc1fa0 nullable 2025-10-29 18:09:06 -05:00
defnotken
700428eccb I'm blind 2025-10-29 17:56:09 -05:00
defnotken
9f97ab1eb4 Fixing some group controller stuff 2025-10-29 17:26:43 -05:00
defnotken
de0a37985a Update API 2025-10-29 17:23:32 -05:00
defnotken
1db5b7056d Initial Bot Cleanup + Profile Toggling 2025-10-29 17:23:07 -05:00
azyges
96627e3b85 bump submodule 2025-10-29 07:55:39 +09:00
azyges
dceaceb941 chat 2025-10-29 07:50:41 +09:00
ee69df8081 Merge pull request 'Fixed some issues on group/user profiles' (#25) from syncshells-images-combined into master
Reviewed-on: #25
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-27 20:19:27 +01:00
f29c874515 Merge branch 'master' into syncshells-images-combined 2025-10-27 20:19:03 +01:00
cake
b84d6c35d6 Moved notification on groups on new ones, Fixed new creation of profiles. 2025-10-27 19:08:25 +01:00
bd03fa6762 Syncshells Fix -
Reviewed-on: #24
written by: Abel / Cake
2025-10-26 19:48:18 +01:00
cake
8cde3b4933 Fixed image update from dto 2025-10-26 18:59:20 +01:00
defnotken
0c357aaf7c Merge remote-tracking branch 'origin/fix-images' into syncshells-images-combined 2025-10-26 12:31:34 -05:00
cake
ae09d79577 fix image results 2025-10-26 17:43:49 +01:00
azyges
cc24dc067e fix moderators + profiles 2025-10-27 00:59:56 +09:00
6ac56d38c0 Merge pull request 'lets try this' (#23) from sql-thing into master
Reviewed-on: #23
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-21 23:26:14 +02:00
defnotken
b7f7381dec lets try this 2025-10-21 16:16:45 -05:00
1ce7a718bb Merge pull request 'Banner Support for profiles, Some cleanup/refactoring. Country for metrics.' (#22) from server-changes into master
Reviewed-on: #22
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-21 22:50:54 +02:00
CakeAndBanana
46bb7a4a98 submodule update 2025-10-21 22:48:57 +02:00
CakeAndBanana
8752ce0e62 removal concept 2025-10-21 22:39:05 +02:00
CakeAndBanana
db0115316d Made new imageloadresult instead of null 2025-10-20 20:51:55 +02:00
CakeAndBanana
00d4632510 Reworked image handling, added banner for profiles. Made functions to start on xivauth. Refactored some code. 2025-10-20 20:46:20 +02:00
CakeAndBanana
e61e0db36b Removed logging 2025-10-20 15:52:47 +02:00
CakeAndBanana
8d82365d0e Made logging from information to warning 2025-10-20 15:43:29 +02:00
CakeAndBanana
b142329d09 Added some logging for country 2025-10-20 04:05:27 +02:00
CakeAndBanana
8a329ccbaa Added IP check on loopback 2025-10-20 03:27:47 +02:00
CakeAndBanana
23ee3f98b0 Removed some random characters 2025-10-20 03:15:37 +02:00
CakeAndBanana
f8e711f3c0 Redone array of labels for geoip 2025-10-20 03:12:27 +02:00
CakeAndBanana
73e7bb67bb Fixed metric for country 2025-10-20 03:07:07 +02:00
CakeAndBanana
70500b21e6 Add another label on guage metric 2025-10-20 03:03:59 +02:00
CakeAndBanana
698a9eddf7 Added new jwt claim for country, Moved models to correct folder instead of inside Lightlesshub.Groups 2025-10-20 02:30:40 +02:00
9cab73e8c8 Merge pull request 'Fallback Policy change' (#20) from authorization-shard into master
Reviewed-on: #20
2025-10-19 23:11:31 +02:00
5240beddf4 Merge branch 'master' into authorization-shard 2025-10-19 23:01:59 +02:00
cb4998e960 Merge pull request 'Reworked syncshell profile and user profile calls.' (#21) from syncshell-profiles-attempt-two into master
Reviewed-on: #21
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-19 21:41:38 +02:00
CakeAndBanana
884ad25c33 update submodule 2025-10-19 21:21:19 +02:00
CakeAndBanana
3926f3be89 remove blankspace 2025-10-19 21:04:22 +02:00
CakeAndBanana
d28198a9c8 fix migrations 2025-10-19 21:04:12 +02:00
CakeAndBanana
7cc6918b12 updated submodule 2025-10-19 20:58:40 +02:00
CakeAndBanana
dba7536a7f Added tags on call for user profile calls, added disabled on syncshell profiles. reworked the calls 2025-10-19 20:58:07 +02:00
CakeAndBanana
f35c0c4c2a updated submodule 2025-10-19 18:40:49 +02:00
CakeAndBanana
ad00f7b078 Changes in database for tags to be array integers instead of strings 2025-10-19 18:36:08 +02:00
CakeAndBanana
c30190704f Changed get/set profile with more safe handling 2025-10-19 17:53:20 +02:00
CakeAndBanana
bab81aaf51 Added null checks 2025-10-19 17:39:36 +02:00
CakeAndBanana
4fdc2a5c29 FIx to attempt to get group 2025-10-19 17:30:16 +02:00
CakeAndBanana
bbcf98576e Fixed so it can search on alias better 2025-10-19 17:22:25 +02:00
defnotken
1ac92f6da2 move shard controller. 2025-10-18 19:44:02 -05:00
defnotken
e7e4a4527a Testing something 2025-10-18 18:51:58 -05:00
583f1a8957 Merge pull request 'Fixed some issues with profiles on groups' (#15) from fix-profiles-syncshell into master
Reviewed-on: #15
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-17 00:09:46 +02:00
defnotken
2ebdd6e0c7 Merge branch 'master' into fix-profiles-syncshell 2025-10-16 16:32:31 -05:00
CakeAndBanana
2407259769 update submodule 2025-10-16 23:15:51 +02:00
03af0b853c Lightfinder-profiles
Reviewed-on: #18
2025-10-16 22:25:51 +02:00
azyges
53f663fcbf zzz 2025-10-17 00:32:43 +09:00
azyges
47a94cb79f shivering my timbers 2025-10-17 00:20:26 +09:00
f933b40368 LightfinderMetrics
Reviewed-on: #14
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-16 00:12:56 +02:00
CakeAndBanana
b670cb69dd Updated submodule for dto 2025-10-15 23:25:54 +02:00
CakeAndBanana
50f3b0d644 Added default values for nsfw and disabled on set profile for groups 2025-10-15 23:24:43 +02:00
CakeAndBanana
3a6203844e Added support for nsfw and disabled for groups 2025-10-15 23:22:07 +02:00
CakeAndBanana
80086f6817 Merge branch 'fix-profiles-syncshell' of https://git.lightless-sync.org/Lightless-Sync/LightlessServer into fix-profiles-syncshell 2025-10-15 23:09:12 +02:00
7e565ff85e CDN on shards
Reviewed-on: #17
2025-10-15 21:32:58 +02:00
defnotken
49177e639e conflicts 2025-10-15 14:15:30 -05:00
defnotken
b36b1fb8f9 Add nsfw and disabled in db 2025-10-15 12:39:46 -05:00
azyges
79483205f1 cdn download support on shards + clean up 2025-10-16 01:15:31 +09:00
CakeAndBanana
707c565ea9 Inverted boolean for syncshells 2025-10-15 04:02:27 +02:00
CakeAndBanana
6beda853f7 Added metric of broadcasted syncshells 2025-10-15 03:58:00 +02:00
CakeAndBanana
23dc6d7ef4 Merge branch 'metrics-lightfinder' of https://git.lightless-sync.org/Lightless-Sync/LightlessServer into metrics-lightfinder 2025-10-15 02:47:27 +02:00
CakeAndBanana
f686f7a6da Added lightfinder metric on startup 2025-10-15 02:47:18 +02:00
CakeAndBanana
280cc2ebbb Fix submodule 2025-10-14 20:21:25 +02:00
CakeAndBanana
7909850ad5 Changed cancellation token. 2025-10-14 19:01:58 +02:00
CakeAndBanana
f60994fa58 update 2025-10-14 19:00:56 +02:00
CakeAndBanana
96f230cd21 merge 2025-10-14 19:00:45 +02:00
0fe1a43fb2 Merge branch 'master' into metrics-lightfinder 2025-10-14 18:56:37 +02:00
CakeAndBanana
43b9c6f90e Added lightfinder users in metrics 2025-10-14 18:45:57 +02:00
CakeAndBanana
59f3739b9c Fixed some issues with profiles on groups 2025-10-14 18:19:46 +02:00
aadfaca629 .gitmodules updated
Specified branch to omit errors on pulling submodules
2025-10-13 00:07:54 +02:00
729d781fa3 Merge pull request 'Server - LightFinder Rework' (#13) from server-lightfinder-rewrite into master
Reviewed-on: #13
2025-10-12 15:14:21 +02:00
defnotken
be95f24dcd conflictrs 2025-10-10 15:06:53 -05:00
defnotken
a1f9526c23 quick quick quick cleanup 2025-10-09 18:03:30 -05:00
defnotken
0450255d6d Update Submodule Endpoint 2025-10-09 18:00:06 -05:00
azyges
b6907a2704 cdn downloads support 2025-10-10 07:37:33 +09:00
azyges
479b80a5a0 lightfinder changes:
- removed all ability to provide your cid to the server through params, cid is gained from JWT claims
- improved verification of who owns a cid, which includes locking a cid to a uid
- locks and persisting entries of broadcasting are cleaned up on disconnection
- method identification logic was rewritten to fit these changes
2025-10-08 08:40:56 +09:00
d4d6e21381 Merge pull request 'Removal of cancellation tokens' (#11) from removal-cancellation-tokens into master
Reviewed-on: #11
2025-10-07 06:16:59 +02:00
65 changed files with 11023 additions and 297 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,11 +44,14 @@ public class GeoIPService : IHostedService
if (_dbReader!.TryCity(ip, out var response))
{
string? continent = response?.Continent.Code;
string? countryIso = response?.Country.IsoCode;
if (!string.IsNullOrEmpty(continent) &&
string.Equals(continent, "NA", StringComparison.Ordinal)
&& response?.Location.Longitude != null)
{
if (response.Location.Longitude < -102)
if (response.Location.Longitude < -102 &&
!string.Equals(countryIso, "CA", StringComparison.OrdinalIgnoreCase))
{
continent = "NA-W";
}
@@ -53,6 +59,8 @@ public class GeoIPService : IHostedService
{
continent = "NA-E";
}
_logger.LogDebug("Connecting {countryIso} to {continent}", countryIso, continent);
}
return continent ?? "*";
@@ -140,4 +148,34 @@ public class GeoIPService : IHostedService
_dbReader?.Dispose();
return Task.CompletedTask;
}
internal async Task<string> GetCountryFromIP(IHttpContextAccessor httpContextAccessor)
{
if (!_useGeoIP)
return "*";
var ip = httpContextAccessor.GetClientIpAddress();
if (ip is null || IPAddress.IsLoopback(ip))
return "*";
try
{
using CancellationTokenSource waitCts = new(TimeSpan.FromSeconds(5));
while (_processingReload)
await Task.Delay(100, waitCts.Token).ConfigureAwait(false);
if (_dbReader!.TryCity(ip, out var response))
{
var country = response?.Country?.IsoCode;
return country ?? "*";
}
return "*";
}
catch (Exception ex)
{
_logger.LogError(ex, "GeoIP lookup failed for {Ip}", ip);
return "*";
}
}
}

View File

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

@@ -59,6 +59,14 @@ public class BroadcastConfiguration : IBroadcastConfiguration
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;
@@ -69,4 +77,7 @@ public class BroadcastConfiguration : IBroadcastConfiguration
return template;
}
public int PairRequestRateLimit => Options.PairRequestRateLimit > 0 ? Options.PairRequestRateLimit : 5;
public int PairRequestRateWindow => Options.PairRequestRateWindow > 0 ? Options.PairRequestRateWindow : 60;
}

View File

@@ -20,4 +20,10 @@ public class BroadcastOptions
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

@@ -12,5 +12,9 @@ public interface IBroadcastConfiguration
bool EnableSyncshellBroadcastPayloads { get; }
string BuildRedisKey(string hashedCid);
string BuildUserOwnershipKey(string userUid);
string BuildPairRequestNotification();
int PairRequestRateLimit { get; }
int PairRequestRateWindow { get; }
}

View File

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

View File

@@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace LightlessSyncAuthService.Controllers;
namespace LightlessSyncServer.Controllers;
[Route(LightlessAuth.User)]
[Authorize(Policy = "Internal")]
@@ -96,4 +96,38 @@ public class UserController : Controller
await dbContext.SaveChangesAsync();
}
[Route(LightlessAuth.Disable_Profile)]
[HttpPost]
public async Task DisableGroupProfile([FromBody] UserProfileAvailabilityRequest request)
{
using var dbContext = await LightlessDbContextFactory.CreateDbContextAsync();
Logger.LogInformation("Disabling profile for user with uid {UID}", request.UID);
var user = await dbContext.UserProfileData.FirstOrDefaultAsync(f => f.UserUID == request.UID);
if (user != null)
{
user.ProfileDisabled = true;
}
await dbContext.SaveChangesAsync();
}
[Route(LightlessAuth.Enable_Profile)]
[HttpPost]
public async Task EnableGroupProfile([FromBody] UserProfileAvailabilityRequest request)
{
using var dbContext = await LightlessDbContextFactory.CreateDbContextAsync();
Logger.LogInformation("Enabling profile for user with uid {UID}", request.UID);
var user = await dbContext.UserProfileData.FirstOrDefaultAsync(f => f.UserUID == request.UID);
if (user != null)
{
user.ProfileDisabled = false;
}
await dbContext.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,630 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto.Chat;
using LightlessSync.API.Dto.User;
using LightlessSyncServer.Models;
using LightlessSyncServer.Services;
using LightlessSyncServer.Utils;
using LightlessSyncShared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
namespace LightlessSyncServer.Hubs;
public partial class LightlessHub
{
private const int MaxChatMessageLength = 400;
private const int ChatRateLimitMessages = 7;
private static readonly TimeSpan ChatRateLimitWindow = TimeSpan.FromMinutes(1);
private static readonly ConcurrentDictionary<string, ChatRateLimitState> ChatRateLimiters = new(StringComparer.Ordinal);
private sealed class ChatRateLimitState
{
public readonly Queue<DateTime> Events = new();
public readonly object SyncRoot = new();
}
private static readonly JsonSerializerOptions ChatReportSnapshotSerializerOptions = new(JsonSerializerDefaults.General)
{
WriteIndented = false
};
[Authorize(Policy = "Identified")]
public Task<IReadOnlyList<ZoneChatChannelInfoDto>> GetZoneChatChannels()
{
return Task.FromResult(_chatChannelService.GetZoneChannelInfos());
}
[Authorize(Policy = "Identified")]
public async Task<IReadOnlyList<GroupChatChannelInfoDto>> GetGroupChatChannels()
{
var userUid = UserUID;
var groupInfos = await DbContext.Groups
.AsNoTracking()
.Where(g => g.OwnerUID == userUid
|| DbContext.GroupPairs.Any(p => p.GroupGID == g.GID && p.GroupUserUID == userUid))
.ToListAsync()
.ConfigureAwait(false);
return groupInfos
.Select(g =>
{
var displayName = string.IsNullOrWhiteSpace(g.Alias) ? g.GID : g.Alias!;
var descriptor = new ChatChannelDescriptor
{
Type = ChatChannelType.Group,
WorldId = 0,
ZoneId = 0,
CustomKey = g.GID
};
return new GroupChatChannelInfoDto(
descriptor,
displayName,
g.GID,
g.OwnerUID == userUid);
})
.OrderBy(info => info.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToList();
}
[Authorize(Policy = "Identified")]
public async Task UpdateChatPresence(ChatPresenceUpdateDto presence)
{
var channel = presence.Channel.WithNormalizedCustomKey();
var userRecord = await DbContext.Users
.AsNoTracking()
.SingleAsync(u => u.UID == UserUID, cancellationToken: RequestAbortedToken)
.ConfigureAwait(false);
if (userRecord.ChatBanned)
{
_chatChannelService.RemovePresence(UserUID);
await NotifyChatBanAsync(UserUID).ConfigureAwait(false);
return;
}
if (!presence.IsActive)
{
_chatChannelService.RemovePresence(UserUID, channel);
return;
}
switch (channel.Type)
{
case ChatChannelType.Zone:
if (!_chatChannelService.TryResolveZone(channel.CustomKey, out var definition))
{
throw new HubException("Unsupported chat channel.");
}
if (channel.WorldId == 0 || !WorldRegistry.IsKnownWorld(channel.WorldId))
{
throw new HubException("Unsupported chat channel.");
}
if (presence.TerritoryId == 0 || !definition.TerritoryIds.Contains(presence.TerritoryId))
{
throw new HubException("Zone chat is only available in supported territories.");
}
string? hashedCid = null;
var isLightfinder = false;
if (IsValidHashedCid(UserCharaIdent))
{
var (entry, expiry) = await TryGetBroadcastEntryAsync(UserCharaIdent).ConfigureAwait(false);
isLightfinder = HasActiveBroadcast(entry, expiry);
if (isLightfinder)
{
hashedCid = UserCharaIdent;
}
}
_chatChannelService.UpdateZonePresence(
UserUID,
definition,
channel.WorldId,
presence.TerritoryId,
hashedCid,
isLightfinder,
isActive: true);
break;
case ChatChannelType.Group:
var groupKey = channel.CustomKey ?? string.Empty;
if (string.IsNullOrEmpty(groupKey))
{
throw new HubException("Unsupported chat channel.");
}
var userData = userRecord.ToUserData();
var group = await DbContext.Groups
.AsNoTracking()
.SingleOrDefaultAsync(g => g.GID == groupKey, cancellationToken: RequestAbortedToken)
.ConfigureAwait(false);
if (group is null)
{
throw new HubException("Unsupported chat channel.");
}
var isMember = string.Equals(group.OwnerUID, UserUID, StringComparison.Ordinal)
|| await DbContext.GroupPairs
.AsNoTracking()
.AnyAsync(gp => gp.GroupGID == groupKey && gp.GroupUserUID == UserUID, cancellationToken: RequestAbortedToken)
.ConfigureAwait(false);
if (!isMember)
{
throw new HubException("Join the syncshell before using chat.");
}
var displayName = string.IsNullOrWhiteSpace(group.Alias) ? group.GID : group.Alias;
_chatChannelService.UpdateGroupPresence(
UserUID,
group.GID,
displayName,
userData,
IsValidHashedCid(UserCharaIdent) ? UserCharaIdent : null,
isActive: true);
break;
default:
throw new HubException("Unsupported chat channel.");
}
}
[Authorize(Policy = "Identified")]
public async Task SendChatMessage(ChatSendRequestDto request)
{
if (string.IsNullOrWhiteSpace(request.Message))
{
throw new HubException("Message cannot be empty.");
}
var channel = request.Channel.WithNormalizedCustomKey();
if (await HandleIfChatBannedAsync(UserUID).ConfigureAwait(false))
{
throw new HubException("Chat access has been revoked.");
}
if (!_chatChannelService.TryGetPresence(UserUID, channel, out var presence))
{
throw new HubException("Join a chat channel before sending messages.");
}
if (!UseChatRateLimit(UserUID))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You can send at most " + ChatRateLimitMessages + " chat messages per minute. Please wait before sending more.").ConfigureAwait(false);
return;
}
var sanitizedMessage = request.Message.Trim().ReplaceLineEndings(" ");
if (sanitizedMessage.Length > MaxChatMessageLength)
{
sanitizedMessage = sanitizedMessage[..MaxChatMessageLength];
}
var recipients = _chatChannelService.GetMembers(presence.Channel);
var recipientsList = recipients.ToList();
if (recipientsList.Count == 0)
{
return;
}
var bannedRecipients = recipientsList.Count == 0
? new List<string>()
: await DbContext.Users.AsNoTracking()
.Where(u => recipientsList.Contains(u.UID) && u.ChatBanned)
.Select(u => u.UID)
.ToListAsync(RequestAbortedToken)
.ConfigureAwait(false);
HashSet<string>? bannedSet = null;
if (bannedRecipients.Count > 0)
{
bannedSet = new HashSet<string>(bannedRecipients, StringComparer.Ordinal);
foreach (var bannedUid in bannedSet)
{
_chatChannelService.RemovePresence(bannedUid);
await NotifyChatBanAsync(bannedUid).ConfigureAwait(false);
}
}
var deliveryTargets = new Dictionary<string, (string Uid, bool IncludeSensitive)>(StringComparer.Ordinal);
foreach (var uid in recipientsList)
{
if (bannedSet != null && bannedSet.Contains(uid))
{
continue;
}
if (_userConnections.TryGetValue(uid, out var connectionId))
{
var includeSensitive = await AllowsLightfinderDetailsAsync(presence.Channel, uid).ConfigureAwait(false);
if (deliveryTargets.TryGetValue(connectionId, out var existing))
{
deliveryTargets[connectionId] = (existing.Uid, existing.IncludeSensitive || includeSensitive);
}
else
{
deliveryTargets[connectionId] = (uid, includeSensitive);
}
}
else
{
_chatChannelService.RemovePresence(uid);
}
}
if (deliveryTargets.Count == 0)
{
return;
}
var timestamp = DateTime.UtcNow;
var messageId = _chatChannelService.RecordMessage(presence.Channel, presence.Participant, sanitizedMessage, timestamp);
var sendTasks = new List<Task>(deliveryTargets.Count);
foreach (var (connectionId, target) in deliveryTargets)
{
var sender = BuildSenderDescriptor(presence.Channel, presence.Participant, target.IncludeSensitive);
var payload = new ChatMessageDto(
presence.Channel,
sender,
sanitizedMessage,
timestamp,
messageId);
sendTasks.Add(Clients.Client(connectionId).Client_ChatReceive(payload));
}
await Task.WhenAll(sendTasks).ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task<ChatParticipantResolveResultDto?> ResolveChatParticipant(ChatParticipantResolveRequestDto request)
{
var channel = request.Channel.WithNormalizedCustomKey();
if (!_chatChannelService.TryGetPresence(UserUID, channel, out _))
{
throw new HubException("Join the chat channel before resolving participants.");
}
if (!_chatChannelService.TryResolveParticipant(channel, request.Token, out var participant))
{
return null;
}
var viewerAllowsDetails = await ViewerAllowsLightfinderDetailsAsync(channel).ConfigureAwait(false);
var includeSensitiveInfo = channel.Type == ChatChannelType.Group || viewerAllowsDetails;
var sender = BuildSenderDescriptor(channel, participant, includeSensitiveInfo);
if (!includeSensitiveInfo)
{
return new ChatParticipantResolveResultDto(channel, sender, null);
}
UserProfileDto? profile = null;
if (channel.Type == ChatChannelType.Group)
{
profile = await LoadChatParticipantProfileAsync(participant.UserUid).ConfigureAwait(false);
}
else if (participant.IsLightfinder && !string.IsNullOrEmpty(participant.HashedCid))
{
profile = await LoadChatParticipantProfileAsync(participant.UserUid).ConfigureAwait(false);
}
return new ChatParticipantResolveResultDto(channel, sender, profile);
}
[Authorize(Policy = "Identified")]
public async Task ReportChatMessage(ChatReportSubmitDto request)
{
var channel = request.Channel.WithNormalizedCustomKey();
if (!_chatChannelService.TryGetPresence(UserUID, channel, out _))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Join the chat channel before reporting messages.").ConfigureAwait(false);
return;
}
if (!_chatChannelService.TryGetMessage(request.MessageId, out var messageEntry))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Unable to locate the reported message. It may have already expired.").ConfigureAwait(false);
return;
}
var requestedChannelKey = ChannelKey.FromDescriptor(channel);
var messageChannelKey = ChannelKey.FromDescriptor(messageEntry.Channel.WithNormalizedCustomKey());
if (!requestedChannelKey.Equals(messageChannelKey))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "The reported message no longer matches this channel.").ConfigureAwait(false);
return;
}
if (string.Equals(messageEntry.SenderUserUid, UserUID, StringComparison.Ordinal))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You cannot report your own message.").ConfigureAwait(false);
return;
}
var reason = request.Reason?.Trim();
if (string.IsNullOrWhiteSpace(reason))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "Please provide a short explanation for the report.").ConfigureAwait(false);
return;
}
const int MaxReasonLength = 500;
if (reason.Length > MaxReasonLength)
{
reason = reason[..MaxReasonLength];
}
var additionalContext = string.IsNullOrWhiteSpace(request.AdditionalContext)
? null
: request.AdditionalContext.Trim();
const int MaxContextLength = 1000;
if (!string.IsNullOrEmpty(additionalContext) && additionalContext.Length > MaxContextLength)
{
additionalContext = additionalContext[..MaxContextLength];
}
var alreadyReported = await DbContext.ReportedChatMessages
.AsNoTracking()
.AnyAsync(r => r.MessageId == request.MessageId && r.ReporterUserUid == UserUID && !r.Resolved, cancellationToken: RequestAbortedToken)
.ConfigureAwait(false);
if (alreadyReported)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You already reported this message and it is pending review.").ConfigureAwait(false);
return;
}
var oneHourAgo = DateTime.UtcNow - TimeSpan.FromHours(1);
var reportRateLimited = await DbContext.ReportedChatMessages
.AsNoTracking()
.AnyAsync(r => r.ReporterUserUid == UserUID && r.ReportTimeUtc >= oneHourAgo, cancellationToken: RequestAbortedToken)
.ConfigureAwait(false);
if (reportRateLimited)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You can file at most one chat report per hour.").ConfigureAwait(false);
return;
}
if (!string.IsNullOrEmpty(messageEntry.SenderUserUid))
{
var targetAlreadyPending = await DbContext.ReportedChatMessages
.AsNoTracking()
.AnyAsync(r => r.ReportedUserUid == messageEntry.SenderUserUid && !r.Resolved, cancellationToken: RequestAbortedToken)
.ConfigureAwait(false);
if (targetAlreadyPending)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "This user already has a report pending review.").ConfigureAwait(false);
return;
}
}
var snapshotEntries = _chatChannelService.GetRecentMessages(messageEntry.Channel, 25);
var snapshotItems = snapshotEntries
.Select(e => new ChatReportSnapshotItem(
e.MessageId,
e.SentAtUtc,
e.SenderUserUid,
e.SenderUser?.AliasOrUID,
e.SenderIsLightfinder,
e.SenderHashedCid,
e.Message))
.ToArray();
var snapshotJson = JsonSerializer.Serialize(snapshotItems, ChatReportSnapshotSerializerOptions);
var report = new ReportedChatMessage
{
ReportTimeUtc = DateTime.UtcNow,
ReporterUserUid = UserUID,
ReportedUserUid = messageEntry.SenderUserUid,
ChannelType = messageEntry.Channel.Type,
WorldId = messageEntry.Channel.WorldId,
ZoneId = messageEntry.Channel.ZoneId,
ChannelKey = messageEntry.Channel.CustomKey ?? string.Empty,
MessageId = messageEntry.MessageId,
MessageSentAtUtc = messageEntry.SentAtUtc,
MessageContent = messageEntry.Message,
SenderToken = messageEntry.SenderToken,
SenderHashedCid = messageEntry.SenderHashedCid,
SenderDisplayName = messageEntry.SenderUser?.AliasOrUID,
SenderWasLightfinder = messageEntry.SenderIsLightfinder,
SnapshotJson = snapshotJson,
Reason = reason,
AdditionalContext = additionalContext
};
DbContext.ReportedChatMessages.Add(report);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "Thank you. Your report has been queued for moderator review.").ConfigureAwait(false);
}
private ChatSenderDescriptor BuildSenderDescriptor(ChatChannelDescriptor descriptor, ChatParticipantInfo participant, bool includeSensitiveInfo = false)
{
var kind = descriptor.Type == ChatChannelType.Group
? ChatSenderKind.IdentifiedUser
: ChatSenderKind.Anonymous;
string? displayName;
if (kind == ChatSenderKind.IdentifiedUser)
{
displayName = participant.User?.Alias ?? participant.User?.UID ?? participant.UserUid;
}
else if (includeSensitiveInfo && participant.IsLightfinder && !string.IsNullOrEmpty(participant.HashedCid))
{
displayName = participant.HashedCid;
}
else
{
var source = participant.UserUid ?? string.Empty;
var suffix = source.Length >= 4 ? source[^4..] : source;
displayName = string.IsNullOrEmpty(suffix) ? "Anonymous" : $"Anon-{suffix}";
}
var hashedCid = includeSensitiveInfo && participant.IsLightfinder
? participant.HashedCid
: null;
var canResolveProfile = kind == ChatSenderKind.IdentifiedUser || participant.IsLightfinder;
return new ChatSenderDescriptor(
kind,
participant.Token,
displayName,
hashedCid,
descriptor.Type == ChatChannelType.Group ? participant.User : null,
canResolveProfile);
}
private async Task<UserProfileDto?> LoadChatParticipantProfileAsync(string userUid)
{
if (string.IsNullOrEmpty(userUid))
return null;
var targetUser = await DbContext.Users
.AsNoTracking()
.SingleOrDefaultAsync(u => u.UID == userUid, cancellationToken: RequestAbortedToken)
.ConfigureAwait(false);
if (targetUser is null)
return null;
var userData = targetUser.ToUserData();
var profileData = await DbContext.UserProfileData
.AsNoTracking()
.SingleOrDefaultAsync(p => p.UserUID == userUid, cancellationToken: RequestAbortedToken)
.ConfigureAwait(false);
if (profileData is null)
{
return new UserProfileDto(userData, Disabled: false, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: null, Tags: Array.Empty<int>());
}
if (profileData.FlaggedForReport)
{
return new UserProfileDto(userData, Disabled: true, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: "This profile is flagged for report and pending evaluation", Tags: Array.Empty<int>());
}
if (profileData.ProfileDisabled)
{
return new UserProfileDto(userData, Disabled: true, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: "This profile was permanently disabled", Tags: Array.Empty<int>());
}
return profileData.ToDTO();
}
private async Task<bool> ViewerAllowsLightfinderDetailsAsync(ChatChannelDescriptor descriptor)
{
if (descriptor.Type == ChatChannelType.Group)
{
return true;
}
var viewerCid = UserCharaIdent;
if (!IsValidHashedCid(viewerCid))
{
return false;
}
var (entry, expiry) = await TryGetBroadcastEntryAsync(viewerCid).ConfigureAwait(false);
return HasActiveBroadcast(entry, expiry);
}
private async Task<bool> AllowsLightfinderDetailsAsync(ChatChannelDescriptor descriptor, string userUid)
{
if (descriptor.Type == ChatChannelType.Group)
{
return true;
}
if (_chatChannelService.TryGetPresence(userUid, descriptor, out var presence))
{
if (!presence.Participant.IsLightfinder || !IsValidHashedCid(presence.Participant.HashedCid))
{
return false;
}
var (entry, expiry) = await TryGetBroadcastEntryAsync(presence.Participant.HashedCid!).ConfigureAwait(false);
if (!IsActiveBroadcastForUser(entry, expiry, userUid))
{
_chatChannelService.RefreshLightfinderState(userUid, null, isLightfinder: false);
return false;
}
return true;
}
return false;
}
private async Task<bool> HandleIfChatBannedAsync(string userUid)
{
var isBanned = await DbContext.Users
.AsNoTracking()
.AnyAsync(u => u.UID == userUid && u.ChatBanned, RequestAbortedToken)
.ConfigureAwait(false);
if (!isBanned)
return false;
_chatChannelService.RemovePresence(userUid);
await NotifyChatBanAsync(userUid).ConfigureAwait(false);
return true;
}
private async Task NotifyChatBanAsync(string userUid)
{
if (string.Equals(userUid, UserUID, StringComparison.Ordinal))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your chat access has been revoked.").ConfigureAwait(false);
}
else if (_userConnections.TryGetValue(userUid, out var connectionId))
{
await Clients.Client(connectionId).Client_ReceiveServerMessage(MessageSeverity.Error, "Your chat access has been revoked.").ConfigureAwait(false);
}
}
private static bool UseChatRateLimit(string userUid)
{
var state = ChatRateLimiters.GetOrAdd(userUid, _ => new ChatRateLimitState());
lock (state.SyncRoot)
{
var now = DateTime.UtcNow;
while (state.Events.Count > 0 && now - state.Events.Peek() >= ChatRateLimitWindow)
{
state.Events.Dequeue();
}
if (state.Events.Count >= ChatRateLimitMessages)
{
return false;
}
state.Events.Enqueue(now);
return true;
}
}
}

View File

@@ -1,6 +1,7 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto;
using LightlessSync.API.Dto.Chat;
using LightlessSync.API.Dto.CharaData;
using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User;
@@ -38,5 +39,6 @@ namespace LightlessSyncServer.Hubs
public Task Client_GposeLobbyPushCharacterData(CharaDataDownloadDto charaDownloadDto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GposeLobbyPushPoseData(UserData userData, PoseData poseData) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GposeLobbyPushWorldData(UserData userData, WorldData worldData) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_ChatReceive(ChatMessageDto message) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
}
}

View File

@@ -1,12 +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 System.Threading;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
using System.Text.Json;
namespace LightlessSyncServer.Hubs;
@@ -18,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);
@@ -95,13 +99,18 @@ 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
? _contextAccessor.HttpContext.RequestAborted
? RequestAbortedToken
: cancellationToken;
var user = await DbContext.Users.SingleOrDefaultAsync(u => u.UID == uid, cancellationToken).ConfigureAwait(false);
@@ -120,6 +129,47 @@ public partial class LightlessHub
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)
@@ -161,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);
@@ -169,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);
@@ -188,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)

View File

@@ -3,12 +3,18 @@ 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;
@@ -59,7 +65,7 @@ public partial class LightlessHub
group.PreferDisableAnimations = dto.Permissions.HasFlag(GroupPermissions.PreferDisableAnimations);
group.PreferDisableVFX = dto.Permissions.HasFlag(GroupPermissions.PreferDisableVFX);
await DbContext.SaveChangesAsync(_contextAccessor.HttpContext.RequestAborted).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);
@@ -137,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)))
@@ -181,7 +187,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);
@@ -199,15 +205,15 @@ public partial class LightlessHub
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 == "LLS-" + gid).ConfigureAwait(false))
while (await DbContext.Groups.AnyAsync(g => g.GID == "LLS-" + gid, cancellationToken: RequestAbortedToken).ConfigureAwait(false))
{
gid = StringUtils.GenerateRandomString(12);
}
@@ -218,7 +224,7 @@ public partial class LightlessHub
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()
{
@@ -250,12 +256,12 @@ public partial class LightlessHub
DisableVFX = defaultPermissions.DisableGroupAnimations,
};
await DbContext.Groups.AddAsync(newGroup, _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
await DbContext.GroupPairs.AddAsync(initialPair, _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
await DbContext.GroupPairPreferredPermissions.AddAsync(initialPrefPermissions, _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
await DbContext.SaveChangesAsync(_contextAccessor.HttpContext.RequestAborted).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)))
@@ -314,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(_contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
await Clients.Users(groupPairs.Select(g => g.GroupUserUID)).Client_GroupDelete(new GroupDto(group.ToGroupData())).ConfigureAwait(false);
@@ -332,7 +338,7 @@ public partial class LightlessHub
var (userHasRights, group) = await TryValidateGroupModeratorOrOwner(dto.GID).ConfigureAwait(false);
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,
@@ -350,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)
@@ -378,7 +384,7 @@ 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 isHashedPassword = dto.Password.Length == 64 && dto.Password.All(Uri.IsHexDigit);
@@ -416,7 +422,7 @@ public partial class LightlessHub
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()
@@ -441,13 +447,13 @@ public partial class LightlessHub
DbContext.Update(preferredPermissions);
}
await DbContext.GroupPairs.AddAsync(newPair, _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
await DbContext.GroupPairs.AddAsync(newPair, RequestAbortedToken).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(aliasOrGid, "Success"));
await DbContext.SaveChangesAsync(_contextAccessor.HttpContext.RequestAborted).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);
@@ -575,7 +581,7 @@ public partial class LightlessHub
}
}
await DbContext.SaveChangesAsync(_contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
return true;
}
@@ -693,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();
}
@@ -717,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;
}
@@ -743,27 +749,41 @@ public partial class LightlessHub
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var data = await DbContext.GroupProfiles
.FirstOrDefaultAsync(g => g.GroupGID == dto.Group.GID)
.ConfigureAwait(false);
var cancellationToken = RequestAbortedToken;
var profileDto = new GroupProfileDto(dto.Group, Description: null, Tags: null, PictureBase64: null);
if (data is not null)
if (dto?.Group == null)
{
profileDto = profileDto with
{
Description = data.Description,
Tags = data.Tags,
PictureBase64 = data.Base64GroupProfileImage,
};
await Clients.User(UserUID)
.Client_GroupSendProfile(profileDto)
.ConfigureAwait(false);
_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);
}
return profileDto;
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")]
@@ -771,37 +791,88 @@ public partial class LightlessHub
{
_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
.FirstOrDefaultAsync(g => g.GroupGID == dto.Group.GID)
.Include(g => g.Group)
.FirstOrDefaultAsync(g => g.GroupGID == dto.Group.GID, cancellationToken)
.ConfigureAwait(false);
if (groupProfileDb != null)
ImageCheckService.ImageLoadResult profileResult = new();
ImageCheckService.ImageLoadResult bannerResult = new();
//Avatar image validation
if (!string.IsNullOrEmpty(dto.PictureBase64))
{
groupProfileDb.Description = dto.Description;
groupProfileDb.Tags = dto.Tags;
groupProfileDb.Base64GroupProfileImage = 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
{
var groupProfile = new GroupProfile
{
GroupGID = dto.Group.GID,
Description = dto.Description,
Tags = dto.Tags,
Base64GroupProfileImage = dto.PictureBase64,
};
groupProfileDb.Group ??= group;
await DbContext.GroupProfiles.AddAsync(groupProfile,
_contextAccessor.HttpContext.RequestAborted)
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(_contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
await DbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
@@ -833,9 +904,9 @@ public partial class LightlessHub
userPair.IsModerator = false;
}
await DbContext.SaveChangesAsync(_contextAccessor.HttpContext.RequestAborted).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);
}
@@ -844,6 +915,8 @@ public partial class LightlessHub
{
_logger.LogCallInfo();
var ct = RequestAbortedToken;
var result = await (
from gp in DbContext.GroupPairs
.Include(gp => gp.Group)
@@ -894,11 +967,11 @@ 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(_contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
}

View File

@@ -1,10 +1,11 @@
using LightlessSync.API.Data;
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 LightlessSyncServer.Configuration;
using LightlessSyncShared.Metrics;
using LightlessSyncShared.Models;
using LightlessSyncShared.Utils;
@@ -34,7 +35,7 @@ public partial class LightlessHub
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);
var otherUser = await DbContext.Users.SingleOrDefaultAsync(u => u.UID == uid || u.Alias == uid, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
if (otherUser == null)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, $"Cannot pair with {dto.User.UID}, UID does not exist").ConfigureAwait(false);
@@ -50,7 +51,7 @@ public partial class LightlessHub
var existingEntry =
await DbContext.ClientPairs.AsNoTracking()
.FirstOrDefaultAsync(p =>
p.User.UID == UserUID && p.OtherUserUID == otherUser.UID).ConfigureAwait(false);
p.User.UID == UserUID && p.OtherUserUID == otherUser.UID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
if (existingEntry != null)
{
@@ -59,7 +60,7 @@ public partial class LightlessHub
}
// grab self create new client pair and save
var user = await DbContext.Users.SingleAsync(u => u.UID == UserUID).ConfigureAwait(false);
var user = await DbContext.Users.SingleAsync(u => u.UID == UserUID, cancellationToken: _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
@@ -73,9 +74,9 @@ public partial class LightlessHub
var existingData = await GetPairInfo(UserUID, otherUser.UID).ConfigureAwait(false);
var permissions = existingData?.OwnPermissions;
if (permissions == null || !permissions.Sticky)
if (permissions == null || !permissions.Sticky)
{
var ownDefaultPermissions = await DbContext.UserDefaultPreferredPermissions.AsNoTracking().SingleOrDefaultAsync(f => f.UserUID == UserUID).ConfigureAwait(false);
var ownDefaultPermissions = await DbContext.UserDefaultPreferredPermissions.AsNoTracking().SingleOrDefaultAsync(f => f.UserUID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
permissions = new UserPermissionSet()
{
@@ -88,7 +89,7 @@ public partial class LightlessHub
Sticky = true
};
var existingDbPerms = await DbContext.Permissions.SingleOrDefaultAsync(u => u.UserUID == UserUID && u.OtherUserUID == otherUser.UID).ConfigureAwait(false);
var existingDbPerms = await DbContext.Permissions.SingleOrDefaultAsync(u => u.UserUID == UserUID && u.OtherUserUID == otherUser.UID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
if (existingDbPerms == null)
{
await DbContext.Permissions.AddAsync(permissions).ConfigureAwait(false);
@@ -142,17 +143,15 @@ public partial class LightlessHub
}
[Authorize(Policy = "Identified")]
public async Task TryPairWithContentId(string otherCid, string myCid)
public async Task TryPairWithContentId(string otherCid)
{
var myCid = UserCharaIdent;
if (string.IsNullOrWhiteSpace(otherCid) || string.IsNullOrWhiteSpace(myCid))
return;
bool IsValidCid(string cid) => cid.Length == 64 && cid.All(Uri.IsHexDigit) && !cid.All(c => c == '0');
if (!IsValidCid(myCid) || !IsValidCid(otherCid))
{
if (!IsValidHashedCid(myCid) || !IsValidHashedCid(otherCid))
return;
}
if (string.Equals(otherCid, myCid, StringComparison.Ordinal))
{
@@ -180,14 +179,35 @@ public partial class LightlessHub
try
{
var payload = JsonSerializer.Deserialize<PairingPayload>(json);
if (payload?.UID == null)
if (payload?.UID == null || string.IsNullOrWhiteSpace(payload.HashedCid))
{
_logger.LogCallWarning(LightlessHubLogger.Args("invalid payload", reverseKey));
return;
}
var sender = await _pairService.TryAddPairAsync(UserUID, payload.UID);
var receiver = await _pairService.TryAddPairAsync(payload.UID, UserUID);
if (!IsValidHashedCid(payload.HashedCid) || !string.Equals(payload.HashedCid, otherCid, StringComparison.Ordinal))
{
_logger.LogCallWarning(LightlessHubLogger.Args("pairing cid mismatch", reverseKey, payload.HashedCid, otherCid));
return;
}
var expectedRequesterUid = await _redis.GetAsync<string>("CID:" + payload.HashedCid).ConfigureAwait(false);
if (!string.Equals(expectedRequesterUid, payload.UID, StringComparison.Ordinal))
{
_logger.LogCallWarning(LightlessHubLogger.Args("pairing uid mismatch", reverseKey, payload.HashedCid, payload.UID, expectedRequesterUid ?? "null"));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "Pair request could not be validated.").ConfigureAwait(false);
return;
}
if (payload.Timestamp == default || DateTime.UtcNow - payload.Timestamp > TimeSpan.FromMinutes(5))
{
_logger.LogCallWarning(LightlessHubLogger.Args("stale pairing payload", reverseKey, payload.Timestamp));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "Pair request expired.").ConfigureAwait(false);
return;
}
var sender = await _pairService.TryAddPairAsync(UserUID, payload.UID).ConfigureAwait(false);
var receiver = await _pairService.TryAddPairAsync(payload.UID, UserUID).ConfigureAwait(false);
var user = await DbContext.Users.SingleAsync(u => u.UID == UserUID).ConfigureAwait(false);
var otherUser = await DbContext.Users.SingleAsync(u => u.UID == payload.UID).ConfigureAwait(false);
@@ -250,9 +270,30 @@ public partial class LightlessHub
}
else
{
int maxRequests = _broadcastConfiguration.PairRequestRateLimit;
int requestWindow = _broadcastConfiguration.PairRequestRateWindow;
TimeSpan window = TimeSpan.FromSeconds(requestWindow);
var rateKey = $"pairing:limit:{UserUID}";
var db = _redis.Database;
var count = (long)await db.StringIncrementAsync(rateKey).ConfigureAwait(false);
if (count == 1)
{
await db.KeyExpireAsync(rateKey, window).ConfigureAwait(false);
}
if (count > maxRequests)
{
var ttl = await db.KeyTimeToLiveAsync(rateKey).ConfigureAwait(false);
var secondsLeft = ttl?.TotalSeconds > 0 ? (int)ttl.Value.TotalSeconds : requestWindow;
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, $"You have exceeded the pair request limit. Please wait {secondsLeft} seconds before trying again.").ConfigureAwait(false);
return;
}
var payload = new PairingPayload
{
UID = UserUID,
HashedCid = myCid,
Timestamp = DateTime.UtcNow
};
@@ -261,14 +302,15 @@ public partial class LightlessHub
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, $"Pair request sent. Waiting for the other player to confirm.").ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args("stored pairing request", myCid, otherCid));
await NotifyBroadcastOwnerOfPairRequest(myCid, otherCid).ConfigureAwait(false);
await NotifyBroadcastOwnerOfPairRequest(otherCid).ConfigureAwait(false);
}
}
private async Task NotifyBroadcastOwnerOfPairRequest(string myHashedCid, string targetHashedCid)
private async Task NotifyBroadcastOwnerOfPairRequest(string targetHashedCid)
{
if (string.IsNullOrWhiteSpace(targetHashedCid) || string.IsNullOrWhiteSpace(myHashedCid))
var myHashedCid = UserCharaIdent;
if (!IsValidHashedCid(targetHashedCid) || !IsValidHashedCid(myHashedCid))
return;
if (!_broadcastConfiguration.EnableBroadcasting || !_broadcastConfiguration.NotifyOwnerOnPairRequest)
@@ -319,26 +361,12 @@ public partial class LightlessHub
await Clients.User(entry.OwnerUID).Client_ReceiveBroadcastPairRequest(dto).ConfigureAwait(false);
}
private class PairingPayload
{
public string UID { get; set; } = string.Empty;
public DateTime Timestamp { get; set; }
}
public class BroadcastRedisEntry
{
public string HashedCID { get; set; } = string.Empty;
public string OwnerUID { get; set; } = string.Empty;
public string? GID { get; set; }
public bool OwnedBy(string userUid) => !string.IsNullOrEmpty(userUid) && string.Equals(OwnerUID, userUid, StringComparison.Ordinal);
public bool HasOwner() => !string.IsNullOrEmpty(OwnerUID);
}
[Authorize(Policy = "Identified")]
public async Task SetBroadcastStatus(string hashedCid, bool enabled, GroupBroadcastRequestDto? groupDto = null)
public async Task SetBroadcastStatus(bool enabled, GroupBroadcastRequestDto? groupDto = null)
{
var hashedCid = UserCharaIdent;
if (enabled && !_broadcastConfiguration.EnableBroadcasting)
{
_logger.LogCallWarning(LightlessHubLogger.Args("broadcast disabled", UserUID, "CID", hashedCid));
@@ -346,9 +374,9 @@ public partial class LightlessHub
return;
}
if (string.IsNullOrWhiteSpace(hashedCid) || hashedCid.Length != 64 || !hashedCid.All(c => Uri.IsHexDigit(c)))
if (!IsValidHashedCid(hashedCid))
{
_logger.LogCallWarning(LightlessHubLogger.Args("invalid cid format", UserUID, "CID", hashedCid));
_logger.LogCallWarning(LightlessHubLogger.Args("invalid cid format for user ident", UserUID, "CID", hashedCid));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Invalid CID format.").ConfigureAwait(false);
return;
}
@@ -360,11 +388,32 @@ public partial class LightlessHub
var db = _redis.Database;
var broadcastKey = _broadcastConfiguration.BuildRedisKey(hashedCid);
var ownershipKey = _broadcastConfiguration.BuildUserOwnershipKey(UserUID);
var ownedCidValue = await db.StringGetAsync(ownershipKey).ConfigureAwait(false);
var ownedCid = ownedCidValue.IsNullOrEmpty ? null : ownedCidValue.ToString();
if (enabled)
{
string? gid = null;
if (!string.IsNullOrEmpty(ownedCid) && !string.Equals(ownedCid, hashedCid, StringComparison.Ordinal))
{
var ownedBroadcastKey = _broadcastConfiguration.BuildRedisKey(ownedCid);
var ownedBroadcastValue = await db.StringGetAsync(ownedBroadcastKey).ConfigureAwait(false);
if (ownedBroadcastValue.IsNullOrEmpty)
{
await db.KeyDeleteAsync(ownershipKey, CommandFlags.FireAndForget).ConfigureAwait(false);
ownedCid = null;
}
else
{
_logger.LogCallWarning(LightlessHubLogger.Args("multiple broadcast lock attempt", UserUID, "ExistingCID", ownedCid, "AttemptedCID", hashedCid));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "You already have an active Lightfinder lock. Disable it before enabling another.").ConfigureAwait(false);
return;
}
}
if (groupDto is not null)
{
if (!_broadcastConfiguration.EnableSyncshellBroadcastPayloads)
@@ -399,7 +448,7 @@ public partial class LightlessHub
if (existingEntry is not null && existingEntry.HasOwner() && !existingEntry.OwnedBy(UserUID))
{
_logger.LogCallWarning(LightlessHubLogger.Args("unauthorized attempt to take broadcast ownership", UserUID, "CID", hashedCid, "ExistingOwner", existingEntry.OwnerUID));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Another user is already broadcasting with that CID.");
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Another user is already broadcasting with that CID.").ConfigureAwait(false);
return;
}
}
@@ -413,7 +462,9 @@ public partial class LightlessHub
var json = JsonSerializer.Serialize(entry);
await db.StringSetAsync(broadcastKey, json, _broadcastConfiguration.BroadcastEntryTtl).ConfigureAwait(false);
await db.StringSetAsync(ownershipKey, hashedCid, _broadcastConfiguration.BroadcastEntryTtl).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args("broadcast enabled", hashedCid, "GID", gid));
_chatChannelService.RefreshLightfinderState(UserUID, hashedCid, isLightfinder: true);
}
else
{
@@ -435,22 +486,82 @@ public partial class LightlessHub
if (entry is null || !string.Equals(entry.HashedCID, hashedCid, StringComparison.Ordinal))
{
_logger.LogCallWarning(LightlessHubLogger.Args("unauthorized attempt to remove broadcast", UserUID, "CID", hashedCid, "Stored", entry?.HashedCID));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "You can only disable your own broadcast. :3");
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "You can only disable your own broadcast. :3").ConfigureAwait(false);
return;
}
if (entry.HasOwner() && !entry.OwnedBy(UserUID))
{
_logger.LogCallWarning(LightlessHubLogger.Args("unauthorized attempt to remove broadcast", UserUID, "CID", hashedCid, "Owner", entry.OwnerUID));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "You can only disable your own broadcast. :3");
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "You can only disable your own broadcast. :3").ConfigureAwait(false);
return;
}
await db.KeyDeleteAsync(broadcastKey).ConfigureAwait(false);
if (!string.IsNullOrEmpty(ownedCid) && string.Equals(ownedCid, hashedCid, StringComparison.Ordinal))
{
await db.KeyDeleteAsync(ownershipKey).ConfigureAwait(false);
}
_logger.LogCallInfo(LightlessHubLogger.Args("broadcast disabled", hashedCid, "GID", entry.GID));
_chatChannelService.RefreshLightfinderState(UserUID, null, isLightfinder: false);
}
}
private async Task<(BroadcastRedisEntry? Entry, TimeSpan? Expiry)> TryGetBroadcastEntryAsync(string hashedCid)
{
var key = _broadcastConfiguration.BuildRedisKey(hashedCid);
RedisValueWithExpiry value;
try
{
value = await _redis.Database.StringGetWithExpiryAsync(key).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogCallWarning(LightlessHubLogger.Args("LightfinderProfileLookupFailed", "CID", hashedCid, "Error", ex));
return (null, null);
}
if (value.Value.IsNullOrEmpty || value.Expiry is null || value.Expiry <= TimeSpan.Zero)
{
return (null, value.Expiry);
}
BroadcastRedisEntry? entry;
try
{
entry = JsonSerializer.Deserialize<BroadcastRedisEntry>(value.Value!);
}
catch (Exception ex)
{
_logger.LogCallWarning(LightlessHubLogger.Args("LightfinderProfileDeserializeFailed", "CID", hashedCid, "Raw", value.Value.ToString(), "Error", ex));
return (null, value.Expiry);
}
if (entry is null || !string.Equals(entry.HashedCID, hashedCid, StringComparison.Ordinal))
{
_logger.LogCallWarning(LightlessHubLogger.Args("LightfinderProfileEntryMismatch", "CID", hashedCid, "EntryCID", entry?.HashedCID ?? "null"));
return (null, value.Expiry);
}
return (entry, value.Expiry);
}
private static bool HasActiveBroadcast(BroadcastRedisEntry? entry, TimeSpan? expiry) =>
entry?.HasOwner() == true && expiry.HasValue && expiry.Value > TimeSpan.Zero;
private static bool IsActiveBroadcastForUser(BroadcastRedisEntry? entry, TimeSpan? expiry, string userUid) =>
HasActiveBroadcast(entry, expiry) && entry!.OwnedBy(userUid);
private static bool IsValidHashedCid(string? cid)
{
if (string.IsNullOrWhiteSpace(cid))
return false;
return cid.Length == 64 && cid.All(Uri.IsHexDigit) && !cid.All(c => c == '0');
}
[Authorize(Policy = "Identified")]
public async Task<BroadcastStatusInfoDto?> IsUserBroadcasting(string hashedCid)
@@ -458,18 +569,13 @@ public partial class LightlessHub
if (!_broadcastConfiguration.EnableBroadcasting)
return null;
if (string.IsNullOrWhiteSpace(hashedCid) || hashedCid.Length != 64 || !hashedCid.All(c => Uri.IsHexDigit(c)))
if (!IsValidHashedCid(hashedCid))
{
_logger.LogCallWarning(LightlessHubLogger.Args("invalid cid format", UserUID, "CID", hashedCid));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Invalid CID format.").ConfigureAwait(false);
return null;
}
if (hashedCid.All(c => c == '0'))
{
return null;
}
var db = _redis.Database;
var key = _broadcastConfiguration.BuildRedisKey(hashedCid);
@@ -506,14 +612,16 @@ public partial class LightlessHub
}
[Authorize(Policy = "Identified")]
public async Task<TimeSpan?> GetBroadcastTtl(string hashedCid)
public async Task<TimeSpan?> GetBroadcastTtl()
{
if (!_broadcastConfiguration.EnableBroadcasting)
return null;
if (string.IsNullOrWhiteSpace(hashedCid) || hashedCid.Length != 64 || !hashedCid.All(c => Uri.IsHexDigit(c)))
var hashedCid = UserCharaIdent;
if (!IsValidHashedCid(hashedCid))
{
_logger.LogCallWarning(LightlessHubLogger.Args("invalid cid format", UserUID, "CID", hashedCid));
_logger.LogCallWarning(LightlessHubLogger.Args("invalid cid format for user ident", UserUID, "CID", hashedCid));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Invalid CID format.").ConfigureAwait(false);
return null;
}
@@ -586,9 +694,7 @@ public partial class LightlessHub
var tasks = new Dictionary<string, Task<RedisValueWithExpiry>>(hashedCids.Count);
foreach (var cid in hashedCids)
{
bool validHash = !string.IsNullOrWhiteSpace(cid) && cid.Length == 64 && cid.All(Uri.IsHexDigit) && !cid.All(c => c == '0');
if (!validHash)
if (!IsValidHashedCid(cid))
{
tasks[cid] = Task.FromResult(new RedisValueWithExpiry(RedisValue.Null, null));
continue;
@@ -656,7 +762,7 @@ public partial class LightlessHub
{
_logger.LogCallInfo();
var userEntry = await DbContext.Users.SingleAsync(u => u.UID == UserUID).ConfigureAwait(false);
var userEntry = await DbContext.Users.SingleAsync(u => u.UID == UserUID, cancellationToken: RequestAbortedToken).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)
{
@@ -706,16 +812,107 @@ public partial class LightlessHub
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.");
return new UserProfileDto(user.User, Disabled: false, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: "Due to the pause status you cannot access this users profile.", Tags: []);
}
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);
var data = await DbContext.UserProfileData.SingleOrDefaultAsync(u => u.UserUID == user.User.UID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
if (data == null) return new UserProfileDto(user.User, Disabled: false, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: null, Tags: []);
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");
if (data.FlaggedForReport) return new UserProfileDto(user.User, Disabled: true, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: "This profile is flagged for report and pending evaluation", Tags: []);
if (data.ProfileDisabled) return new UserProfileDto(user.User, Disabled: true, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: "This profile was permanently disabled", Tags: []);
return new UserProfileDto(user.User, false, data.IsNSFW, data.Base64ProfileImage, data.UserDescription);
return data.ToDTO();
}
[Authorize(Policy = "Identified")]
public async Task<UserProfileDto?> UserGetLightfinderProfile(string hashedCid)
{
_logger.LogCallInfo(LightlessHubLogger.Args("LightfinderProfile", hashedCid));
if (!_broadcastConfiguration.EnableBroadcasting)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Lightfinder is currently disabled.").ConfigureAwait(false);
return null;
}
if (!IsValidHashedCid(hashedCid))
{
_logger.LogCallWarning(LightlessHubLogger.Args("LightfinderProfileInvalidCid", hashedCid));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "Invalid Lightfinder target.").ConfigureAwait(false);
return null;
}
var viewerCid = UserCharaIdent;
if (!IsValidHashedCid(viewerCid))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You must be using Lightfinder to open player profiles.").ConfigureAwait(false);
return null;
}
var (viewerEntry, viewerExpiry) = await TryGetBroadcastEntryAsync(viewerCid).ConfigureAwait(false);
if (!IsActiveBroadcastForUser(viewerEntry, viewerExpiry, UserUID))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You must be using Lightfinder to open player profiles.").ConfigureAwait(false);
return null;
}
var (targetEntry, targetExpiry) = await TryGetBroadcastEntryAsync(hashedCid).ConfigureAwait(false);
if (!HasActiveBroadcast(targetEntry, targetExpiry))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "That player is not currently using Lightfinder.").ConfigureAwait(false);
return null;
}
if (string.IsNullOrEmpty(targetEntry!.OwnerUID))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "That player is not currently using Lightfinder.").ConfigureAwait(false);
return null;
}
var targetUser = await DbContext.Users.AsNoTracking()
.SingleOrDefaultAsync(u => u.UID == targetEntry.OwnerUID, cancellationToken: RequestAbortedToken)
.ConfigureAwait(false);
if (targetUser == null)
{
_logger.LogCallWarning(LightlessHubLogger.Args("LightfinderProfileMissingUser", hashedCid, "OwnerUID", targetEntry.OwnerUID));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "Unable to load the players profile at this time.").ConfigureAwait(false);
return null;
}
var displayAlias = string.IsNullOrWhiteSpace(targetUser.Alias)
? "LightfinderUser"
: targetUser.Alias;
var userData = new UserData(
UID: hashedCid,
Alias: displayAlias,
IsAdmin: false,
IsModerator: false,
HasVanity: false,
TextColorHex: targetUser.TextColorHex,
TextGlowColorHex: targetUser.TextGlowColorHex);
var profile = await DbContext.UserProfileData.AsNoTracking()
.SingleOrDefaultAsync(u => u.UserUID == targetEntry.OwnerUID, cancellationToken: RequestAbortedToken)
.ConfigureAwait(false);
if (profile == null)
{
return new UserProfileDto(userData, Disabled: false, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: null, Tags: []);
}
if (profile.FlaggedForReport)
{
return new UserProfileDto(userData, Disabled: true, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: "This profile is flagged for report and pending evaluation", Tags: []);
}
if (profile.ProfileDisabled)
{
return new UserProfileDto(userData, Disabled: true, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: "This profile was permanently disabled", Tags: []);
}
return profile.ToDTO();
}
[Authorize(Policy = "Identified")]
@@ -748,22 +945,36 @@ public partial class LightlessHub
}
bool hadInvalidData = false;
List<string> invalidGamePaths = new();
List<string> invalidFileSwapPaths = new();
List<string> invalidGamePaths = [];
List<string> invalidFileSwapPaths = [];
var gamePathRegex = GamePathRegex();
var hashRegex = HashRegex();
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)
var validGamePaths = replacement.GamePaths
.Where(p => gamePathRegex.IsMatch(p) &&
AllowedExtensionsForGamePaths.Any(e => p.EndsWith(e, StringComparison.OrdinalIgnoreCase)))
.ToArray();
var invalidPaths = replacement.GamePaths.Except(validGamePaths, StringComparer.OrdinalIgnoreCase).ToArray();
replacement.GamePaths = validGamePaths;
bool validHash = string.IsNullOrEmpty(replacement.Hash) || hashRegex.IsMatch(replacement.Hash);
bool validFileSwapPath = string.IsNullOrEmpty(replacement.FileSwapPath) || gamePathRegex.IsMatch(replacement.FileSwapPath);
bool validGamePathsFlag = validGamePaths.Length
!= 0;
if (!validGamePathsFlag || !validHash || !validFileSwapPath)
{
_logger.LogCallWarning(LightlessHubLogger.Args("Invalid Data", "GamePaths", validGamePaths, string.Join(",", invalidPaths), "Hash", validHash, replacement.Hash, "FileSwap", validFileSwapPath, replacement.FileSwapPath));
_logger.LogCallWarning(LightlessHubLogger.Args("Invalid Data", "GamePaths", validGamePathsFlag, 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 (!validGamePathsFlag) invalidGamePaths.AddRange(invalidPaths);
if (!validHash) invalidFileSwapPaths.Add(replacement.Hash);
}
}
@@ -866,7 +1077,7 @@ public partial class LightlessHub
// check if client pair even exists
ClientPair callerPair =
await DbContext.ClientPairs.SingleOrDefaultAsync(w => w.UserUID == UserUID && w.OtherUserUID == dto.User.UID).ConfigureAwait(false);
await DbContext.ClientPairs.SingleOrDefaultAsync(w => w.UserUID == UserUID && w.OtherUserUID == dto.User.UID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
if (callerPair == null) return;
var pairData = await GetPairInfo(UserUID, dto.User.UID).ConfigureAwait(false);
@@ -915,76 +1126,70 @@ public partial class LightlessHub
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var cancellationToken = RequestAbortedToken;
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);
var profileData = await DbContext.UserProfileData.SingleOrDefaultAsync(u => u.UserUID == dto.User.UID, cancellationToken: RequestAbortedToken).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;
}
ImageCheckService.ImageLoadResult profileResult = new();
ImageCheckService.ImageLoadResult bannerResult = new();
//Avatar image validation
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);
profileResult = await ImageCheckService.ValidateImageAsync(dto.ProfilePictureBase64, banner: false, RequestAbortedToken).ConfigureAwait(false);
if (image.Width > 256 || image.Height > 256 || (imageData.Length > 250 * 1024))
if (!profileResult.Success)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your provided image file is larger than 256x256 or more than 250KiB.").ConfigureAwait(false);
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, profileResult.ErrorMessage).ConfigureAwait(false);
return;
}
}
if (existingData != null)
//Banner image validation
if (!string.IsNullOrEmpty(dto.BannerPictureBase64))
{
if (string.Equals("", dto.ProfilePictureBase64, StringComparison.OrdinalIgnoreCase))
bannerResult = await ImageCheckService.ValidateImageAsync(dto.BannerPictureBase64, banner: true, RequestAbortedToken).ConfigureAwait(false);
if (!bannerResult.Success)
{
existingData.Base64ProfileImage = null;
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, bannerResult.ErrorMessage).ConfigureAwait(false);
return;
}
else if (dto.ProfilePictureBase64 != null)
}
if (profileData != null)
{
if (profileData.FlaggedForReport)
{
existingData.Base64ProfileImage = dto.ProfilePictureBase64;
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your profile is currently flagged for report and cannot be edited").ConfigureAwait(false);
return;
}
if (dto.IsNSFW != null)
if (profileData.ProfileDisabled)
{
existingData.IsNSFW = dto.IsNSFW.Value;
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your profile was permanently disabled and cannot be edited").ConfigureAwait(false);
return;
}
if (dto.Description != null)
{
existingData.UserDescription = dto.Description;
}
profileData.UpdateProfileFromDto(dto, profileResult.Base64Image, bannerResult.Base64Image);
}
else
{
UserProfileData userProfileData = new()
profileData = new()
{
UserUID = dto.User.UID,
Base64ProfileImage = dto.ProfilePictureBase64 ?? null,
UserDescription = dto.Description ?? null,
IsNSFW = dto.IsNSFW ?? false
IsNSFW = dto.IsNSFW ?? false,
};
await DbContext.UserProfileData.AddAsync(userProfileData).ConfigureAwait(false);
profileData.UpdateProfileFromDto(dto, profileResult.Base64Image, bannerResult.Base64Image);
await DbContext.UserProfileData.AddAsync(profileData, cancellationToken).ConfigureAwait(false);
}
await DbContext.SaveChangesAsync().ConfigureAwait(false);
await DbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
var allPairedUsers = await GetAllPairedUnpausedUsers().ConfigureAwait(false);
var pairs = await GetOnlineUsers(allPairedUsers).ConfigureAwait(false);
@@ -1041,4 +1246,4 @@ public partial class LightlessHub
private ClientPair OppositeEntry(string otherUID) =>
DbContext.ClientPairs.AsNoTracking().SingleOrDefault(w => w.User.UID == otherUID && w.OtherUser.UID == UserUID);
}
}

View File

@@ -1,6 +1,7 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto;
using LightlessSync.API.Dto.Chat;
using LightlessSync.API.SignalR;
using LightlessSyncServer.Services;
using LightlessSyncServer.Configuration;
@@ -16,6 +17,8 @@ using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis.Extensions.Core.Abstractions;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
namespace LightlessSyncServer.Hubs;
@@ -43,12 +46,16 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
private LightlessDbContext DbContext => _dbContextLazy.Value;
private readonly int _maxCharaDataByUser;
private readonly int _maxCharaDataByUserVanity;
private readonly ChatChannelService _chatChannelService;
private CancellationToken RequestAbortedToken => _contextAccessor.HttpContext?.RequestAborted ?? Context?.ConnectionAborted ?? CancellationToken.None;
public LightlessHub(LightlessMetrics lightlessMetrics,
IDbContextFactory<LightlessDbContext> lightlessDbContextFactory, ILogger<LightlessHub> logger, SystemInfoService systemInfoService,
IConfigurationService<ServerConfiguration> configuration, IHttpContextAccessor contextAccessor,
IRedisDatabase redisDb, OnlineSyncedPairCacheService onlineSyncedPairCacheService, LightlessCensus lightlessCensus,
GPoseLobbyDistributionService gPoseLobbyDistributionService, IBroadcastConfiguration broadcastConfiguration, PairService pairService)
GPoseLobbyDistributionService gPoseLobbyDistributionService, IBroadcastConfiguration broadcastConfiguration, PairService pairService,
ChatChannelService chatChannelService)
{
_lightlessMetrics = lightlessMetrics;
_systemInfoService = systemInfoService;
@@ -69,6 +76,7 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
_dbContextLazy = new Lazy<LightlessDbContext>(() => lightlessDbContextFactory.CreateDbContext());
_broadcastConfiguration = broadcastConfiguration;
_pairService = pairService;
_chatChannelService = chatChannelService;
}
protected override void Dispose(bool disposing)
@@ -158,8 +166,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));
@@ -182,7 +195,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
{
@@ -194,6 +212,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);
@@ -207,6 +227,7 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
catch { }
finally
{
_chatChannelService.RemovePresence(UserUID);
_userConnections.Remove(UserUID, out _);
}
}

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,461 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using LightlessSync.API.Data;
using LightlessSync.API.Dto.Chat;
using LightlessSyncServer.Models;
using Microsoft.Extensions.Logging;
namespace LightlessSyncServer.Services;
public sealed class ChatChannelService
{
private readonly ILogger<ChatChannelService> _logger;
private readonly Dictionary<string, ZoneChannelDefinition> _zoneDefinitions;
private readonly Dictionary<ChannelKey, HashSet<string>> _membersByChannel = new();
private readonly Dictionary<string, Dictionary<ChannelKey, ChatPresenceEntry>> _presenceByUser = new(StringComparer.Ordinal);
private readonly Dictionary<ChannelKey, Dictionary<string, ChatParticipantInfo>> _participantsByChannel = new();
private readonly Dictionary<ChannelKey, LinkedList<ChatMessageLogEntry>> _messagesByChannel = new();
private readonly Dictionary<string, (ChannelKey Channel, LinkedListNode<ChatMessageLogEntry> Node)> _messageIndex = new(StringComparer.Ordinal);
private readonly object _syncRoot = new();
private const int MaxMessagesPerChannel = 200;
public ChatChannelService(ILogger<ChatChannelService> logger)
{
_logger = logger;
_zoneDefinitions = ChatZoneDefinitions.Defaults
.ToDictionary(definition => definition.Key, StringComparer.OrdinalIgnoreCase);
}
public IReadOnlyList<ZoneChatChannelInfoDto> GetZoneChannelInfos() =>
_zoneDefinitions.Values
.Select(definition => new ZoneChatChannelInfoDto(
definition.Descriptor,
definition.DisplayName,
definition.TerritoryNames))
.ToArray();
public bool TryResolveZone(string? key, out ZoneChannelDefinition definition)
{
definition = default;
if (string.IsNullOrWhiteSpace(key))
return false;
return _zoneDefinitions.TryGetValue(key, out definition);
}
public ChatPresenceEntry? UpdateZonePresence(
string userUid,
ZoneChannelDefinition definition,
ushort worldId,
ushort territoryId,
string? hashedCid,
bool isLightfinder,
bool isActive)
{
if (worldId == 0 || !WorldRegistry.IsKnownWorld(worldId))
{
_logger.LogWarning("Rejected zone chat presence for {User} in {Zone}: unknown world {WorldId}", userUid, definition.Key, worldId);
return null;
}
if (!definition.TerritoryIds.Contains(territoryId))
{
_logger.LogWarning("Rejected zone chat presence for {User} in {Zone}: invalid territory {TerritoryId}", userUid, definition.Key, territoryId);
return null;
}
var descriptor = definition.Descriptor with { WorldId = worldId, ZoneId = territoryId };
var participant = new ChatParticipantInfo(
Token: string.Empty,
UserUid: userUid,
User: null,
HashedCid: isLightfinder ? hashedCid : null,
IsLightfinder: isLightfinder);
return UpdatePresence(
userUid,
descriptor,
definition.DisplayName,
participant,
isActive,
replaceExistingOfSameType: true);
}
public ChatPresenceEntry? UpdateGroupPresence(
string userUid,
string groupId,
string displayName,
UserData user,
string? hashedCid,
bool isActive)
{
var descriptor = new ChatChannelDescriptor
{
Type = ChatChannelType.Group,
WorldId = 0,
ZoneId = 0,
CustomKey = groupId
};
var participant = new ChatParticipantInfo(
Token: string.Empty,
UserUid: userUid,
User: user,
HashedCid: hashedCid,
IsLightfinder: !string.IsNullOrEmpty(hashedCid));
return UpdatePresence(
userUid,
descriptor,
displayName,
participant,
isActive,
replaceExistingOfSameType: false);
}
public bool TryGetPresence(string userUid, ChatChannelDescriptor channel, out ChatPresenceEntry presence)
{
var key = ChannelKey.FromDescriptor(channel);
lock (_syncRoot)
{
if (_presenceByUser.TryGetValue(userUid, out var entries) && entries.TryGetValue(key, out presence))
{
return true;
}
}
presence = default;
return false;
}
public IReadOnlyCollection<string> GetMembers(ChatChannelDescriptor channel)
{
var key = ChannelKey.FromDescriptor(channel);
lock (_syncRoot)
{
if (_membersByChannel.TryGetValue(key, out var members))
{
return members.ToArray();
}
}
return Array.Empty<string>();
}
public string RecordMessage(ChatChannelDescriptor channel, ChatParticipantInfo participant, string message, DateTime sentAtUtc)
{
var key = ChannelKey.FromDescriptor(channel);
var messageId = Guid.NewGuid().ToString("N");
var entry = new ChatMessageLogEntry(
messageId,
channel,
sentAtUtc,
participant.Token,
participant.UserUid,
participant.User,
participant.IsLightfinder,
participant.HashedCid,
message);
lock (_syncRoot)
{
if (!_messagesByChannel.TryGetValue(key, out var list))
{
list = new LinkedList<ChatMessageLogEntry>();
_messagesByChannel[key] = list;
}
var node = list.AddLast(entry);
_messageIndex[messageId] = (key, node);
while (list.Count > MaxMessagesPerChannel)
{
var removedNode = list.First;
if (removedNode is null)
{
break;
}
list.RemoveFirst();
_messageIndex.Remove(removedNode.Value.MessageId);
}
}
return messageId;
}
public bool TryGetMessage(string messageId, out ChatMessageLogEntry entry)
{
lock (_syncRoot)
{
if (_messageIndex.TryGetValue(messageId, out var located))
{
entry = located.Node.Value;
return true;
}
}
entry = default;
return false;
}
public IReadOnlyList<ChatMessageLogEntry> GetRecentMessages(ChatChannelDescriptor descriptor, int maxCount)
{
lock (_syncRoot)
{
var key = ChannelKey.FromDescriptor(descriptor);
if (!_messagesByChannel.TryGetValue(key, out var list) || list.Count == 0)
{
return Array.Empty<ChatMessageLogEntry>();
}
var take = Math.Min(maxCount, list.Count);
var result = new ChatMessageLogEntry[take];
var node = list.Last;
for (var i = take - 1; i >= 0 && node is not null; i--)
{
result[i] = node.Value;
node = node.Previous;
}
return result;
}
}
public bool RemovePresence(string userUid, ChatChannelDescriptor? channel = null)
{
ArgumentException.ThrowIfNullOrEmpty(userUid);
lock (_syncRoot)
{
if (!_presenceByUser.TryGetValue(userUid, out var entries))
{
return false;
}
if (channel is null)
{
foreach (var existing in entries.Keys.ToList())
{
RemovePresenceInternal(userUid, entries, existing);
}
_presenceByUser.Remove(userUid);
return true;
}
var key = ChannelKey.FromDescriptor(channel.Value);
var removed = RemovePresenceInternal(userUid, entries, key);
if (entries.Count == 0)
{
_presenceByUser.Remove(userUid);
}
return removed;
}
}
public bool TryResolveParticipant(ChatChannelDescriptor channel, string token, out ChatParticipantInfo participant)
{
var key = ChannelKey.FromDescriptor(channel);
lock (_syncRoot)
{
if (_participantsByChannel.TryGetValue(key, out var participants) &&
participants.TryGetValue(token, out participant))
{
return true;
}
}
participant = default;
return false;
}
public void RefreshLightfinderState(string userUid, string? hashedCid, bool isLightfinder)
{
ArgumentException.ThrowIfNullOrEmpty(userUid);
lock (_syncRoot)
{
if (!_presenceByUser.TryGetValue(userUid, out var entries) || entries.Count == 0)
{
return;
}
foreach (var (key, existing) in entries.ToArray())
{
var updatedParticipant = existing.Participant with
{
HashedCid = isLightfinder ? hashedCid : null,
IsLightfinder = isLightfinder
};
var updatedEntry = existing with
{
Participant = updatedParticipant,
UpdatedAt = DateTime.UtcNow
};
entries[key] = updatedEntry;
if (_participantsByChannel.TryGetValue(key, out var participants))
{
participants[updatedParticipant.Token] = updatedParticipant;
}
}
}
}
private ChatPresenceEntry? UpdatePresence(
string userUid,
ChatChannelDescriptor descriptor,
string displayName,
ChatParticipantInfo participant,
bool isActive,
bool replaceExistingOfSameType)
{
ArgumentException.ThrowIfNullOrEmpty(userUid);
var normalizedDescriptor = descriptor.WithNormalizedCustomKey();
var key = ChannelKey.FromDescriptor(normalizedDescriptor);
lock (_syncRoot)
{
if (!_presenceByUser.TryGetValue(userUid, out var entries))
{
if (!isActive)
return null;
entries = new Dictionary<ChannelKey, ChatPresenceEntry>();
_presenceByUser[userUid] = entries;
}
string? reusableToken = null;
if (entries.TryGetValue(key, out var existing))
{
reusableToken = existing.Participant.Token;
RemovePresenceInternal(userUid, entries, key);
}
if (replaceExistingOfSameType)
{
foreach (var candidate in entries.Keys.Where(k => k.Type == key.Type).ToList())
{
if (entries.TryGetValue(candidate, out var entry))
{
reusableToken ??= entry.Participant.Token;
}
RemovePresenceInternal(userUid, entries, candidate);
}
if (!isActive)
{
if (entries.Count == 0)
{
_presenceByUser.Remove(userUid);
}
_logger.LogDebug("Chat presence cleared for {User} ({Type})", userUid, normalizedDescriptor.Type);
return null;
}
}
else if (!isActive)
{
var removed = RemovePresenceInternal(userUid, entries, key);
if (removed)
{
_logger.LogDebug("Chat presence removed for {User} from {Channel}", userUid, Describe(key));
}
if (entries.Count == 0)
{
_presenceByUser.Remove(userUid);
}
return null;
}
var token = !string.IsNullOrEmpty(participant.Token)
? participant.Token
: reusableToken ?? GenerateToken();
var finalParticipant = participant with { Token = token };
var entryToStore = new ChatPresenceEntry(
normalizedDescriptor,
key,
displayName,
finalParticipant,
DateTime.UtcNow);
entries[key] = entryToStore;
if (!_membersByChannel.TryGetValue(key, out var members))
{
members = new HashSet<string>(StringComparer.Ordinal);
_membersByChannel[key] = members;
}
members.Add(userUid);
if (!_participantsByChannel.TryGetValue(key, out var participantsByToken))
{
participantsByToken = new Dictionary<string, ChatParticipantInfo>(StringComparer.Ordinal);
_participantsByChannel[key] = participantsByToken;
}
participantsByToken[token] = finalParticipant;
_logger.LogDebug("Chat presence updated for {User} in {Channel}", userUid, Describe(key));
return entryToStore;
}
}
private bool RemovePresenceInternal(string userUid, Dictionary<ChannelKey, ChatPresenceEntry> entries, ChannelKey key)
{
if (!entries.TryGetValue(key, out var existing))
{
return false;
}
entries.Remove(key);
if (_membersByChannel.TryGetValue(key, out var members))
{
members.Remove(userUid);
if (members.Count == 0)
{
_membersByChannel.Remove(key);
// Preserve message history even when a channel becomes empty so moderation can still resolve reports.
}
}
if (_participantsByChannel.TryGetValue(key, out var participants))
{
participants.Remove(existing.Participant.Token);
if (participants.Count == 0)
{
_participantsByChannel.Remove(key);
}
}
return true;
}
private static string GenerateToken()
{
Span<byte> buffer = stackalloc byte[8];
RandomNumberGenerator.Fill(buffer);
return Convert.ToHexString(buffer);
}
private static string Describe(ChannelKey key)
=> $"{key.Type}:{key.WorldId}:{key.CustomKey}";
}

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

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

@@ -1,6 +1,5 @@
using AspNetCoreRateLimit;
using LightlessSync.API.SignalR;
using LightlessSyncAuthService.Controllers;
using LightlessSyncServer.Controllers;
using LightlessSyncServer.Configuration;
using LightlessSyncServer.Hubs;
@@ -73,7 +72,7 @@ public class Startup
a.FeatureProviders.Remove(a.FeatureProviders.OfType<ControllerFeatureProvider>().First());
if (lightlessConfig.GetValue<Uri>(nameof(ServerConfiguration.MainServerAddress), defaultValue: null) == null)
{
a.FeatureProviders.Add(new AllowedControllersFeatureProvider(typeof(LightlessServerConfigurationController), typeof(LightlessBaseConfigurationController), typeof(ClientMessageController), typeof(UserController)));
a.FeatureProviders.Add(new AllowedControllersFeatureProvider(typeof(LightlessServerConfigurationController), typeof(LightlessBaseConfigurationController), typeof(ClientMessageController), typeof(UserController), typeof(GroupController)));
}
else
{
@@ -94,6 +93,7 @@ public class Startup
services.AddSingleton<ServerTokenGenerator>();
services.AddSingleton<SystemInfoService>();
services.AddSingleton<OnlineSyncedPairCacheService>();
services.AddSingleton<ChatChannelService>();
services.AddHostedService(provider => provider.GetService<SystemInfoService>());
// configure services based on main server status
ConfigureServicesBasedOnShardType(services, lightlessConfig, isMainServer);
@@ -295,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)
{
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

@@ -36,7 +36,9 @@
"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."
"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": {

View File

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

View File

@@ -1,15 +1,17 @@
using Discord;
using Discord.Interactions;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User;
using LightlessSyncShared.Data;
using LightlessSyncShared.Models;
using LightlessSyncShared.Services;
using LightlessSyncShared.Utils;
using LightlessSyncShared.Utils.Configuration;
using Microsoft.EntityFrameworkCore;
using Prometheus;
using LightlessSyncShared.Models;
using LightlessSyncShared.Utils;
using LightlessSyncShared.Services;
using StackExchange.Redis;
using LightlessSync.API.Data.Enum;
using LightlessSyncShared.Utils.Configuration;
using LightlessSync.API.Dto.User;
using System.Net.Http.Json;
namespace LightlessSyncServices.Discord;
@@ -43,7 +45,56 @@ public class LightlessModule : InteractionModuleBase
{
EmbedBuilder eb = new();
eb = await HandleUserInfo(eb, Context.User.Id, secondaryUid, discordUser?.Id ?? null, uid);
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<LightlessDbContext>();
await using (db.ConfigureAwait(false))
{
eb = await HandleUserInfo(eb, db, Context.User.Id, secondaryUid, discordUser?.Id ?? null, uid);
string uidToGet = await GetUserUID(db, secondaryUid, discordUser?.Id ?? null, uid).ConfigureAwait(false);
var profileData = await GetUserProfileData(db, uidToGet).ConfigureAwait(false);
if (profileData != null)
{
byte[] profileImage = GetProfileImage(profileData);
byte[] bannerImage = GetBannerImage(profileData);
using MemoryStream profileImgStream = new(profileImage);
using MemoryStream bannerImgStream = new(bannerImage);
eb.WithThumbnailUrl("attachment://profileimage.png");
eb.WithImageUrl("attachment://bannerimage.png");
await RespondWithFilesAsync(
new[] { new FileAttachment(profileImgStream, "profileimage.png"), new FileAttachment(bannerImgStream, "bannerimage.png") },
embeds: new[] { eb.Build() },
ephemeral: true).ConfigureAwait(false);
}
else
{
await RespondAsync(
embeds: new[] { eb.Build() },
ephemeral: true).ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
EmbedBuilder eb = new();
eb.WithTitle("An error occured");
eb.WithDescription("Please report this error to bug-reports: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine);
await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false);
}
}
[SlashCommand("groupinfo", "Shows you your group profile information")]
public async Task GroupInfo([Summary("gid", "ADMIN ONLY: GID to check for")] string? uid = null)
{
_logger.LogInformation("SlashCommand:{userId}:{Method}",
Context.Interaction.User.Id, nameof(GroupInfo));
try
{
EmbedBuilder eb = new();
//eb = await HandleUserInfo(eb, Context.User.Id, secondaryUid, discordUser?.Id ?? null, uid);
await RespondAsync(embeds: new[] { eb.Build() }, ephemeral: true).ConfigureAwait(false);
}
@@ -110,10 +161,10 @@ public class LightlessModule : InteractionModuleBase
var testUri = new Uri(_lightlessServicesConfiguration.GetValue<Uri>
(nameof(ServicesConfiguration.MainServerAddress)), "/msgc/sendMessage");
await c.PostAsJsonAsync(
using (await c.PostAsJsonAsync(
new Uri(_lightlessServicesConfiguration.GetValue<Uri>(nameof(ServicesConfiguration.MainServerAddress)), "/msgc/sendMessage"),
new ClientMessage(messageType, message, uid ?? string.Empty)
).ConfigureAwait(false);
).ConfigureAwait(false)) { }
var discordChannelForMessages = _lightlessServicesConfiguration.GetValueOrDefault<ulong?>(nameof(ServicesConfiguration.DiscordChannelForMessages), null);
if (uid == null && discordChannelForMessages != null)
@@ -146,20 +197,47 @@ public class LightlessModule : InteractionModuleBase
}
}
[SlashCommand("unbanbydiscord", "ADMIN ONLY: Unban a user by their discord ID")]
public async Task UnbanByDiscord([Summary("discord_id", "Discord ID to unban")] string discordId)
[SlashCommand("serviceunban", "ADMIN ONLY: Unban a user by their discord ID or user ID [CHOOSE ONE ONLY]")]
public async Task ServiceUnban(
[Summary("discord_id", "Discord ID to unban")] string? discordId = null,
[Summary("uid", "UID to unban")] string? uid = null
)
{
_logger.LogInformation("SlashCommand:{userId}:{Method}:{params}",
Context.Interaction.User.Id, nameof(UnbanByDiscord),
string.Join(",", new[] { $"{nameof(discordId)}:{discordId}" }));
Context.Interaction.User.Id, nameof(ServiceUnban),
string.Join(",", new[] { $"{nameof(discordId)}:{discordId}", $"{nameof(uid)}:{uid}" }));
try
{
using HttpClient c = new HttpClient();
c.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _serverTokenGenerator.Token);
await c.PostAsJsonAsync(new Uri(_lightlessServicesConfiguration.GetValue<Uri>
(nameof(ServicesConfiguration.MainServerAddress)), "/user/unbanDiscord"), new UnbanRequest(string.Empty, discordId))
.ConfigureAwait(false);
string endpoint;
UnbanRequest unbanRequest;
if (!string.IsNullOrEmpty(uid))
{
endpoint = "/user/unbanUID";
unbanRequest = new UnbanRequest(uid, string.Empty);
}
else if (!string.IsNullOrEmpty(discordId))
{
endpoint = "/user/unbanDiscord";
unbanRequest = new UnbanRequest(string.Empty, discordId);
}
else
{
await RespondAsync("You must provide either a UID or Discord ID.", ephemeral: true).ConfigureAwait(false);
return;
}
using (await c.PostAsJsonAsync(
new Uri(_lightlessServicesConfiguration.GetValue<Uri>(nameof(ServicesConfiguration.MainServerAddress)), endpoint),
unbanRequest).ConfigureAwait(false))
{
}
var discordChannelForMessages = _lightlessServicesConfiguration.GetValueOrDefault<ulong?>(nameof(ServicesConfiguration.DiscordChannelForMessages), null);
if (discordChannelForMessages != null)
{
@@ -168,10 +246,12 @@ public class LightlessModule : InteractionModuleBase
{
var embedColor = Color.Blue;
String idToUse = !string.IsNullOrEmpty(uid) ? uid : discordId;
EmbedBuilder eb = new();
eb.WithTitle("Unban Alert!");
eb.WithColor(embedColor);
eb.WithDescription(discordId + " has been unbanned");
eb.WithDescription(idToUse + " has been unbanned");
await discordChannel.SendMessageAsync(embed: eb.Build()).ConfigureAwait(false);
}
@@ -188,64 +268,20 @@ public class LightlessModule : InteractionModuleBase
}
}
[SlashCommand("unbanbyuid", "ADMIN ONLY: Unban a user by their uid")]
public async Task UnbanByUID([Summary("uid", "uid to unban")] string uid)
[SlashCommand("serviceban", "ADMIN ONLY: ban a user by their uid")]
public async Task ServiceBan([Summary("uid", "uid to ban")] string uid)
{
_logger.LogInformation("SlashCommand:{userId}:{Method}:{params}",
Context.Interaction.User.Id, nameof(UnbanByUID),
string.Join(",", new[] { $"{nameof(uid)}:{uid}" }));
try
{
using HttpClient c = new HttpClient();
var testUri = new Uri(_lightlessServicesConfiguration.GetValue<Uri>
(nameof(ServicesConfiguration.MainServerAddress)), "/user/unbanDiscord");
c.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _serverTokenGenerator.Token);
await c.PostAsJsonAsync(new Uri(_lightlessServicesConfiguration.GetValue<Uri>
(nameof(ServicesConfiguration.MainServerAddress)), "/user/unbanUID"), new UnbanRequest(uid, string.Empty))
.ConfigureAwait(false);
var discordChannelForMessages = _lightlessServicesConfiguration.GetValueOrDefault<ulong?>(nameof(ServicesConfiguration.DiscordChannelForMessages), null);
if (discordChannelForMessages != null)
{
var discordChannel = await Context.Guild.GetChannelAsync(discordChannelForMessages.Value).ConfigureAwait(false) as IMessageChannel;
if (discordChannel != null)
{
var embedColor = Color.Blue;
EmbedBuilder eb = new();
eb.WithTitle("Unban Alert!");
eb.WithColor(embedColor);
eb.WithDescription(uid + " has been unbanned");
await discordChannel.SendMessageAsync(embed: eb.Build()).ConfigureAwait(false);
}
}
await RespondAsync("Message sent", ephemeral: true).ConfigureAwait(false);
}
catch (Exception ex)
{
EmbedBuilder eb = new();
eb.WithTitle("An error occured");
eb.WithDescription("Please report this: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine);
await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false);
}
}
[SlashCommand("markforban", "ADMIN ONLY: ban a user by their uid")]
public async Task MarkUidForBan([Summary("uid", "uid to ban")] string uid)
{
_logger.LogInformation("SlashCommand:{userId}:{Method}:{params}",
Context.Interaction.User.Id, nameof(MarkUidForBan),
Context.Interaction.User.Id, nameof(ServiceBan),
string.Join(",", new[] { $"{nameof(uid)}:{uid}" }));
try
{
using HttpClient c = new HttpClient();
c.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _serverTokenGenerator.Token);
await c.PostAsJsonAsync(new Uri(_lightlessServicesConfiguration.GetValue<Uri>
using (await c.PostAsJsonAsync(new Uri(_lightlessServicesConfiguration.GetValue<Uri>
(nameof(ServicesConfiguration.MainServerAddress)), "/user/ban"), new BanRequest(uid))
.ConfigureAwait(false);
.ConfigureAwait(false)) { }
var discordChannelForMessages = _lightlessServicesConfiguration.GetValueOrDefault<ulong?>(nameof(ServicesConfiguration.DiscordChannelForMessages), null);
if (discordChannelForMessages != null)
{
@@ -274,6 +310,100 @@ public class LightlessModule : InteractionModuleBase
}
}
[SlashCommand("toggleuserprofile", "ADMIN ONLY: disable a user profile by their uid")]
public async Task ToggleUserProfile(
[Summary("uid", "uid to disable")] string uid,
[Summary("toggle", "Enable or Disable the profile")]
[Choice("Enable", "Enable")]
[Choice("Disable", "Disable")] string toggle
)
{
_logger.LogInformation("SlashCommand:{userId}:{Method}:{params}",
Context.Interaction.User.Id, nameof(ToggleUserProfile),
string.Join(",", new[] { $"{nameof(uid)}:{uid}" }));
try
{
using HttpClient c = new HttpClient();
c.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _serverTokenGenerator.Token);
string endpoint = string.Equals(toggle, "Enable", StringComparison.Ordinal) ? "/user/enableProfile" : "/user/disableProfile";
using (await c.PostAsJsonAsync(new Uri(_lightlessServicesConfiguration.GetValue<Uri>
(nameof(ServicesConfiguration.MainServerAddress)), endpoint), new UserProfileAvailabilityRequest(uid))
.ConfigureAwait(false)) { }
var discordChannelForMessages = _lightlessServicesConfiguration.GetValueOrDefault<ulong?>(nameof(ServicesConfiguration.DiscordChannelForMessages), null);
if (discordChannelForMessages != null)
{
var discordChannel = await Context.Guild.GetChannelAsync(discordChannelForMessages.Value).ConfigureAwait(false) as IMessageChannel;
if (discordChannel != null)
{
var embedColor = Color.Blue;
var action = string.Equals(toggle, "Enable", StringComparison.Ordinal) ? "enabled" : "disabled";
EmbedBuilder eb = new();
eb.WithTitle($"Profile {action}");
eb.WithColor(embedColor);
eb.WithDescription($"{uid}'s profile has been {action}");
await discordChannel.SendMessageAsync(embed: eb.Build()).ConfigureAwait(false);
}
}
await RespondAsync("Message sent", ephemeral: true).ConfigureAwait(false);
}
catch (Exception ex)
{
EmbedBuilder eb = new();
eb.WithTitle("An error occured");
eb.WithDescription("Please report this: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine);
await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false);
}
}
[SlashCommand("togglegroupprofile", "ADMIN ONLY: toggle a group profile by their gid")]
public async Task ToggleGroupProfile(
[Summary("gid", "gid to disable")] string gid,
[Summary("toggle", "Enable or Disable the profile")]
[Choice("Enable", "Enable")]
[Choice("Disable", "Disable")] string toggle
)
{
_logger.LogInformation("SlashCommand:{userId}:{Method}:{params}",
Context.Interaction.User.Id, nameof(ToggleUserProfile),
string.Join(",", new[] { $"{nameof(gid)}:{gid}" }));
try
{
using HttpClient c = new HttpClient();
c.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _serverTokenGenerator.Token);
string endpoint = string.Equals(toggle, "Enable", StringComparison.Ordinal) ? "/group/enableProfile" : "/group/disableProfile";
using (await c.PostAsJsonAsync(new Uri(_lightlessServicesConfiguration.GetValue<Uri>
(nameof(ServicesConfiguration.MainServerAddress)), endpoint), new GroupProfileAvailabilityRequest(gid))
.ConfigureAwait(false)) { }
var discordChannelForMessages = _lightlessServicesConfiguration.GetValueOrDefault<ulong?>(nameof(ServicesConfiguration.DiscordChannelForMessages), null);
if (discordChannelForMessages != null)
{
var discordChannel = await Context.Guild.GetChannelAsync(discordChannelForMessages.Value).ConfigureAwait(false) as IMessageChannel;
if (discordChannel != null)
{
var embedColor = Color.Blue;
var action = string.Equals(toggle, "Enable", StringComparison.Ordinal) ? "enabled" : "disabled";
EmbedBuilder eb = new();
eb.WithTitle($"Profile {action}");
eb.WithColor(embedColor);
eb.WithDescription($"{gid}'s profile has been {action}");
await discordChannel.SendMessageAsync(embed: eb.Build()).ConfigureAwait(false);
}
}
await RespondAsync("Message sent", ephemeral: true).ConfigureAwait(false);
}
catch (Exception ex)
{
EmbedBuilder eb = new();
eb.WithTitle("An error occured");
eb.WithDescription("Please report this: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine);
await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false);
}
}
public async Task<Embed> HandleUserAdd(string desiredUid, ulong discordUserId)
{
var embed = new EmbedBuilder();
@@ -319,11 +449,9 @@ public class LightlessModule : InteractionModuleBase
return embed.Build();
}
private async Task<EmbedBuilder> HandleUserInfo(EmbedBuilder eb, ulong id, string? secondaryUserUid = null, ulong? optionalUser = null, string? uid = null)
private async Task<EmbedBuilder> HandleUserInfo(EmbedBuilder eb, LightlessDbContext db, ulong id, string? secondaryUserUid = null, ulong? optionalUser = null, string? uid = null)
{
bool showForSecondaryUser = secondaryUserUid != null;
using var scope = _services.CreateScope();
await using var db = scope.ServiceProvider.GetRequiredService<LightlessDbContext>();
var primaryUser = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == id).ConfigureAwait(false);
@@ -382,6 +510,7 @@ public class LightlessModule : InteractionModuleBase
var auth = await db.Auth.Include(u => u.PrimaryUser).SingleOrDefaultAsync(u => u.UserUID == dbUser.UID).ConfigureAwait(false);
var groups = await db.Groups.Where(g => g.OwnerUID == dbUser.UID).ToListAsync().ConfigureAwait(false);
var groupsJoined = await db.GroupPairs.Where(g => g.GroupUserUID == dbUser.UID).ToListAsync().ConfigureAwait(false);
var profile = await db.UserProfileData.Where(u => u.UserUID == dbUser.UID).SingleOrDefaultAsync().ConfigureAwait(false);
var identity = await _connectionMultiplexer.GetDatabase().StringGetAsync("UID:" + dbUser.UID).ConfigureAwait(false);
eb.WithTitle("User Information");
@@ -404,6 +533,14 @@ public class LightlessModule : InteractionModuleBase
eb.AddField("Secondary UIDs", string.Join(Environment.NewLine, secondaryUIDs));
}
}
if(profile != null)
{
eb.AddField("Profile Description", string.IsNullOrEmpty(profile.UserDescription) ? "(No description set)" : profile.UserDescription);
eb.AddField("Profile NSFW", profile.IsNSFW);
eb.AddField("Profile Disabled", profile.ProfileDisabled);
eb.AddField("Profile Flagged for Report", profile.FlaggedForReport);
eb.AddField("Profile Tags", profile.Tags != null && profile.Tags.Length > 0 ? string.Join(", ", profile.Tags) : "(No tags set)");
}
eb.AddField("Last Online (UTC)", dbUser.LastLoggedIn.ToString("U"));
eb.AddField("Currently online ", !string.IsNullOrEmpty(identity));
eb.AddField("Hashed Secret Key", auth.HashedKey);
@@ -426,4 +563,62 @@ public class LightlessModule : InteractionModuleBase
return eb;
}
private async Task<string> GetUserUID(LightlessDbContext db, string? secondaryUserUid = null, ulong? optionalUser = null, string? uid = null)
{
var primaryUser = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == Context.User.Id).ConfigureAwait(false);
ulong userToCheckForDiscordId = Context.User.Id;
if ((optionalUser != null || uid != null))
{
LodeStoneAuth userInDb = null;
if (optionalUser != null)
{
userInDb = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == optionalUser).ConfigureAwait(false);
}
else if (uid != null)
{
userInDb = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.User.UID == uid || u.User.Alias == uid).ConfigureAwait(false);
}
if (userInDb == null)
{
throw new Exception("The Discord user has no valid Lightless account");
}
userToCheckForDiscordId = userInDb.DiscordId;
}
var lodestoneUser = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == userToCheckForDiscordId).ConfigureAwait(false);
var dbUser = lodestoneUser.User;
if (secondaryUserUid != null)
{
dbUser = (await db.Auth.Include(u => u.User).SingleOrDefaultAsync(u => u.PrimaryUserUID == dbUser.UID && u.UserUID == secondaryUserUid))?.User;
if (dbUser == null)
{
throw new Exception($"A secondary UID {secondaryUserUid} was not found attached to your primary UID {primaryUser.User.UID}.");
}
}
return dbUser.UID;
}
private byte[] GetProfileImage(UserProfileData profile)
{
if (profile != null && profile.Base64ProfileImage != null && profile.Base64ProfileImage.Length > 0)
{
return Convert.FromBase64String(profile.Base64ProfileImage);
}
return Array.Empty<byte>();
}
private byte[] GetBannerImage(UserProfileData profile)
{
if (profile != null && profile.Base64BannerImage != null && profile.Base64BannerImage.Length > 0)
{
return Convert.FromBase64String(profile.Base64BannerImage);
}
return Array.Empty<byte>();
}
private async Task<UserProfileData> GetUserProfileData(LightlessDbContext db, string uid)
{
var profile = await db.UserProfileData.Where(u => u.UserUID == uid).SingleOrDefaultAsync().ConfigureAwait(false);
return profile;
}
}

View File

@@ -329,13 +329,12 @@ public partial class LightlessWizardModule : InteractionModuleBase
private int? ParseCharacterIdFromLodestoneUrl(string lodestoneUrl)
{
var regex = new Regex(@"https:\/\/(na|eu|de|fr|jp)\.finalfantasyxiv\.com\/lodestone\/character\/\d+");
var regex = new Regex(@"^https:\/\/(na|eu|de|fr|jp)\.finalfantasyxiv\.com\/lodestone\/character\/(\d+)/?$");
var matches = regex.Match(lodestoneUrl);
var isLodestoneUrl = matches.Success;
if (!isLodestoneUrl || matches.Groups.Count < 1) return null;
var stringId = matches.Groups[2].ToString();
lodestoneUrl = matches.Groups[0].ToString();
var stringId = lodestoneUrl.Split('/', StringSplitOptions.RemoveEmptyEntries).Last();
if (!int.TryParse(stringId, out int lodestoneId))
{
return null;

View File

@@ -45,6 +45,7 @@ public class LightlessDbContext : DbContext
public DbSet<UserProfileData> UserProfileData { get; set; }
public DbSet<User> Users { get; set; }
public DbSet<UserPermissionSet> Permissions { get; set; }
public DbSet<ReportedChatMessage> ReportedChatMessages { get; set; }
public DbSet<GroupPairPreferredPermission> GroupPairPreferredPermissions { get; set; }
public DbSet<UserDefaultPreferredPermission> UserDefaultPreferredPermissions { get; set; }
public DbSet<CharaData> CharaData { get; set; }
@@ -90,6 +91,12 @@ public class LightlessDbContext : DbContext
mb.Entity<GroupProfile>().ToTable("group_profiles");
mb.Entity<GroupProfile>().HasKey(u => u.GroupGID);
mb.Entity<GroupProfile>().HasIndex(c => c.GroupGID);
mb.Entity<Group>()
.HasOne(g => g.Profile)
.WithOne(p => p.Group)
.HasForeignKey<GroupProfile>(p => p.GroupGID)
.IsRequired(false)
.OnDelete(DeleteBehavior.Cascade);
mb.Entity<GroupTempInvite>().ToTable("group_temp_invites");
mb.Entity<GroupTempInvite>().HasKey(u => new { u.GroupGID, u.Invite });
mb.Entity<GroupTempInvite>().HasIndex(c => c.GroupGID);
@@ -153,5 +160,11 @@ public class LightlessDbContext : DbContext
mb.Entity<CharaDataAllowance>().HasIndex(c => c.ParentId);
mb.Entity<CharaDataAllowance>().HasOne(u => u.AllowedGroup).WithMany().HasForeignKey(u => u.AllowedGroupGID).OnDelete(DeleteBehavior.Cascade);
mb.Entity<CharaDataAllowance>().HasOne(u => u.AllowedUser).WithMany().HasForeignKey(u => u.AllowedUserUID).OnDelete(DeleteBehavior.Cascade);
mb.Entity<ReportedChatMessage>().ToTable("reported_chat_messages");
mb.Entity<ReportedChatMessage>().HasIndex(r => r.ReporterUserUid);
mb.Entity<ReportedChatMessage>().HasIndex(r => r.ReportedUserUid);
mb.Entity<ReportedChatMessage>().HasIndex(r => r.MessageId).IsUnique();
mb.Entity<ReportedChatMessage>().HasIndex(r => r.DiscordMessageId);
}
}

View File

@@ -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";

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -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")
@@ -671,6 +683,131 @@ namespace LightlessSyncServer.Migrations
b.ToTable("lodestone_auth", (string)null);
});
modelBuilder.Entity("LightlessSyncShared.Models.ReportedChatMessage", b =>
{
b.Property<int>("ReportId")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("report_id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ReportId"));
b.Property<string>("AdditionalContext")
.HasColumnType("text")
.HasColumnName("additional_context");
b.Property<string>("ChannelKey")
.IsRequired()
.HasColumnType("text")
.HasColumnName("channel_key");
b.Property<byte>("ChannelType")
.HasColumnType("smallint")
.HasColumnName("channel_type");
b.Property<decimal?>("DiscordMessageId")
.HasColumnType("numeric(20,0)")
.HasColumnName("discord_message_id");
b.Property<DateTime?>("DiscordMessagePostedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("discord_message_posted_at_utc");
b.Property<string>("MessageContent")
.IsRequired()
.HasColumnType("text")
.HasColumnName("message_content");
b.Property<string>("MessageId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("message_id");
b.Property<DateTime>("MessageSentAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("message_sent_at_utc");
b.Property<string>("Reason")
.HasColumnType("text")
.HasColumnName("reason");
b.Property<DateTime>("ReportTimeUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("report_time_utc");
b.Property<string>("ReportedUserUid")
.HasColumnType("text")
.HasColumnName("reported_user_uid");
b.Property<string>("ReporterUserUid")
.IsRequired()
.HasColumnType("text")
.HasColumnName("reporter_user_uid");
b.Property<string>("ResolutionNotes")
.HasColumnType("text")
.HasColumnName("resolution_notes");
b.Property<bool>("Resolved")
.HasColumnType("boolean")
.HasColumnName("resolved");
b.Property<DateTime?>("ResolvedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at_utc");
b.Property<string>("ResolvedByUserUid")
.HasColumnType("text")
.HasColumnName("resolved_by_user_uid");
b.Property<string>("SenderDisplayName")
.HasColumnType("text")
.HasColumnName("sender_display_name");
b.Property<string>("SenderHashedCid")
.HasColumnType("text")
.HasColumnName("sender_hashed_cid");
b.Property<string>("SenderToken")
.IsRequired()
.HasColumnType("text")
.HasColumnName("sender_token");
b.Property<bool>("SenderWasLightfinder")
.HasColumnType("boolean")
.HasColumnName("sender_was_lightfinder");
b.Property<string>("SnapshotJson")
.HasColumnType("text")
.HasColumnName("snapshot_json");
b.Property<int>("WorldId")
.HasColumnType("integer")
.HasColumnName("world_id");
b.Property<int>("ZoneId")
.HasColumnType("integer")
.HasColumnName("zone_id");
b.HasKey("ReportId")
.HasName("pk_reported_chat_messages");
b.HasIndex("DiscordMessageId")
.HasDatabaseName("ix_reported_chat_messages_discord_message_id");
b.HasIndex("MessageId")
.IsUnique()
.HasDatabaseName("ix_reported_chat_messages_message_id");
b.HasIndex("ReportedUserUid")
.HasDatabaseName("ix_reported_chat_messages_reported_user_uid");
b.HasIndex("ReporterUserUid")
.HasDatabaseName("ix_reported_chat_messages_reporter_user_uid");
b.ToTable("reported_chat_messages", (string)null);
});
modelBuilder.Entity("LightlessSyncShared.Models.User", b =>
{
b.Property<string>("UID")
@@ -683,6 +820,10 @@ namespace LightlessSyncServer.Migrations
.HasColumnType("character varying(15)")
.HasColumnName("alias");
b.Property<bool>("ChatBanned")
.HasColumnType("boolean")
.HasColumnName("chat_banned");
b.Property<bool>("HasVanity")
.HasColumnType("boolean")
.HasColumnName("has_vanity");
@@ -816,6 +957,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");
@@ -832,6 +977,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");
@@ -1071,6 +1220,7 @@ namespace LightlessSyncServer.Migrations
b.HasOne("LightlessSyncShared.Models.Group", "Group")
.WithOne("Profile")
.HasForeignKey("LightlessSyncShared.Models.GroupProfile", "GroupGID")
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("fk_group_profiles_groups_group_gid");
b.Navigation("Group");

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

@@ -0,0 +1,69 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using LightlessSync.API.Dto.Chat;
namespace LightlessSyncShared.Models;
/// <summary>
/// Stores metadata about chat reports submitted by users.
/// </summary>
public class ReportedChatMessage
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int ReportId { get; set; }
[Required]
public DateTime ReportTimeUtc { get; set; }
[Required]
public string ReporterUserUid { get; set; } = string.Empty;
public string? ReportedUserUid { get; set; }
[Required]
public ChatChannelType ChannelType { get; set; }
public ushort WorldId { get; set; }
public ushort ZoneId { get; set; }
[Required]
public string ChannelKey { get; set; } = string.Empty;
[Required]
public string MessageId { get; set; } = string.Empty;
public DateTime MessageSentAtUtc { get; set; }
[Required]
public string MessageContent { get; set; } = string.Empty;
[Required]
public string SenderToken { get; set; } = string.Empty;
public string? SenderHashedCid { get; set; }
public string? SenderDisplayName { get; set; }
public bool SenderWasLightfinder { get; set; }
public string SnapshotJson { get; set; } = string.Empty;
public string? Reason { get; set; }
public string? AdditionalContext { get; set; }
public ulong? DiscordMessageId { get; set; }
public DateTime? DiscordMessagePostedAtUtc { get; set; }
public bool Resolved { get; set; }
public DateTime? ResolvedAtUtc { get; set; }
public string? ResolutionNotes { get; set; }
public string? ResolvedByUserUid { get; set; }
}

View File

@@ -22,6 +22,8 @@ public class User
[MaxLength(9)]
public string? TextGlowColorHex { get; set; } = string.Empty;
public bool ChatBanned { get; set; } = false;
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

@@ -7,6 +7,7 @@ public class ServicesConfiguration : LightlessConfigurationBase
public string DiscordBotToken { get; set; } = string.Empty;
public ulong? DiscordChannelForMessages { get; set; } = null;
public ulong? DiscordChannelForCommands { get; set; } = null;
public ulong? DiscordChannelForChatReports { get; set; } = null;
public ulong? DiscordRoleAprilFools2024 { get; set; } = null;
public ulong? DiscordChannelForBotLog { get; set; } = null!;
public ulong? DiscordRoleRegistered { get; set; } = null!;
@@ -22,6 +23,7 @@ public class ServicesConfiguration : LightlessConfigurationBase
sb.AppendLine($"{nameof(MainServerAddress)} => {MainServerAddress}");
sb.AppendLine($"{nameof(DiscordChannelForMessages)} => {DiscordChannelForMessages}");
sb.AppendLine($"{nameof(DiscordChannelForCommands)} => {DiscordChannelForCommands}");
sb.AppendLine($"{nameof(DiscordChannelForChatReports)} => {DiscordChannelForChatReports}");
sb.AppendLine($"{nameof(DiscordRoleAprilFools2024)} => {DiscordRoleAprilFools2024}");
sb.AppendLine($"{nameof(DiscordRoleRegistered)} => {DiscordRoleRegistered}");
sb.AppendLine($"{nameof(KickNonRegisteredUsers)} => {KickNonRegisteredUsers}");

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": "*"
}