Compare commits

..

1 Commits

Author SHA1 Message Date
defnotken
3b6268e82f adding quick logs 2025-09-04 18:00:43 -05:00
116 changed files with 969 additions and 24423 deletions

3
.gitignore vendored
View File

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

1
.gitmodules vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,26 +0,0 @@
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

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

View File

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

View File

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

View File

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

View File

@@ -1,56 +0,0 @@
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

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,12 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.Chat;
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 SixLabors.ImageSharp;
using System.Collections.Concurrent;
using System.Security.Cryptography;
namespace LightlessSyncServer.Hubs;
@@ -51,8 +44,6 @@ public partial class LightlessHub
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
}
private static readonly ConcurrentDictionary<string, DateTime> GroupChatToggleCooldowns = new(StringComparer.Ordinal);
[Authorize(Policy = "Identified")]
public async Task GroupChangeGroupPermissionState(GroupPermissionDto dto)
{
@@ -61,73 +52,15 @@ public partial class LightlessHub
var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (!hasRights) return;
var permissions = dto.Permissions;
var isOwner = string.Equals(group.OwnerUID, UserUID, StringComparison.Ordinal);
var chatEnabled = group.ChatEnabled;
var chatChanged = false;
group.InvitesEnabled = !dto.Permissions.HasFlag(GroupPermissions.DisableInvites);
group.PreferDisableSounds = dto.Permissions.HasFlag(GroupPermissions.PreferDisableSounds);
group.PreferDisableAnimations = dto.Permissions.HasFlag(GroupPermissions.PreferDisableAnimations);
group.PreferDisableVFX = dto.Permissions.HasFlag(GroupPermissions.PreferDisableVFX);
if (!isOwner)
{
permissions.SetDisableChat(!group.ChatEnabled);
}
else
{
var requestedChatEnabled = !permissions.IsDisableChat();
if (requestedChatEnabled != group.ChatEnabled)
{
var now = DateTime.UtcNow;
if (GroupChatToggleCooldowns.TryGetValue(group.GID, out var lockedUntil) && lockedUntil > now)
{
var remaining = lockedUntil - now;
var minutes = Math.Max(1, (int)Math.Ceiling(remaining.TotalMinutes));
await Clients.Caller.Client_ReceiveServerMessage(
MessageSeverity.Warning,
$"Syncshell chat can be toggled again in {minutes} minute{(minutes == 1 ? string.Empty : "s")}."
).ConfigureAwait(false);
permissions.SetDisableChat(!group.ChatEnabled);
}
else
{
chatEnabled = requestedChatEnabled;
group.ChatEnabled = chatEnabled;
GroupChatToggleCooldowns[group.GID] = now.AddMinutes(5);
chatChanged = true;
}
}
}
group.InvitesEnabled = !permissions.HasFlag(GroupPermissions.DisableInvites);
group.PreferDisableSounds = permissions.HasFlag(GroupPermissions.PreferDisableSounds);
group.PreferDisableAnimations = permissions.HasFlag(GroupPermissions.PreferDisableAnimations);
group.PreferDisableVFX = permissions.HasFlag(GroupPermissions.PreferDisableVFX);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
var groupPairs = DbContext.GroupPairs.Where(p => p.GroupGID == dto.Group.GID).Select(p => p.GroupUserUID).ToList();
await Clients.Users(groupPairs).Client_GroupChangePermissions(new GroupPermissionDto(dto.Group, permissions)).ConfigureAwait(false);
if (isOwner && chatChanged && !chatEnabled)
{
var groupDisplayName = string.IsNullOrWhiteSpace(group.Alias) ? group.GID : group.Alias;
var descriptor = new ChatChannelDescriptor
{
Type = ChatChannelType.Group,
WorldId = 0,
ZoneId = 0,
CustomKey = group.GID
};
foreach (var uid in groupPairs)
{
TryInvokeChatService(() => _chatChannelService.RemovePresence(uid, descriptor), "removing group chat presence", descriptor, uid);
}
await Clients.Users(groupPairs)
.Client_ReceiveServerMessage(
MessageSeverity.Information,
$"Syncshell chat for '{groupDisplayName}' has been disabled.")
.ConfigureAwait(false);
}
await Clients.Users(groupPairs).Client_GroupChangePermissions(new GroupPermissionDto(dto.Group, dto.Permissions)).ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
@@ -202,7 +135,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(cancellationToken: RequestAbortedToken).ConfigureAwait(false);
var sharedData = await DbContext.CharaDataAllowances.Where(u => u.AllowedGroup != null && u.AllowedGroupGID == dto.GID && u.ParentUploaderUID == pair.GroupUserUID).ToListAsync().ConfigureAwait(false);
DbContext.CharaDataAllowances.RemoveRange(sharedData);
foreach (var groupUserPair in groupPairs.Where(p => !string.Equals(p.GroupUserUID, pair.GroupUserUID, StringComparison.Ordinal)))
@@ -214,76 +147,29 @@ public partial class LightlessHub
await DbContext.SaveChangesAsync().ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task GroupClearFinder(GroupDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (!hasRights) return;
var groupPairs = await DbContext.GroupPairs.Include(p => p.GroupUser).Where(p => p.GroupGID == dto.Group.GID).ToListAsync().ConfigureAwait(false);
var finder_only = groupPairs.Where(g => g.FromFinder && !g.IsPinned && !g.IsModerator).ToList();
if (finder_only.Count == 0)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "No Users To Clear"));
return;
}
await Clients.Users(finder_only.Select(g => g.GroupUserUID)).Client_GroupDelete(new GroupDto(group.ToGroupData())).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Cleared Finder users ", finder_only.Count));
DbContext.GroupPairs.RemoveRange(finder_only);
foreach (var pair in finder_only)
{
await Clients.Users(groupPairs.Where(p => p.IsPinned || p.IsModerator).Select(g => g.GroupUserUID)).Client_GroupPairLeft(new GroupPairDto(dto.Group, pair.GroupUser.ToUserData())).ConfigureAwait(false);
var pairIdent = await GetUserIdent(pair.GroupUserUID).ConfigureAwait(false);
if (string.IsNullOrEmpty(pairIdent)) continue;
var allUserPairs = await GetAllPairInfo(pair.GroupUserUID).ConfigureAwait(false);
var sharedData = await DbContext.CharaDataAllowances.Where(u => u.AllowedGroup != null && u.AllowedGroupGID == dto.GID && u.ParentUploaderUID == pair.GroupUserUID).ToListAsync(cancellationToken: RequestAbortedToken).ConfigureAwait(false);
DbContext.CharaDataAllowances.RemoveRange(sharedData);
foreach (var groupUserPair in groupPairs.Where(p => !string.Equals(p.GroupUserUID, pair.GroupUserUID, StringComparison.Ordinal)))
{
await UserGroupLeave(pair, pairIdent, allUserPairs, pair.GroupUserUID).ConfigureAwait(false);
}
}
await DbContext.SaveChangesAsync().ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task<GroupJoinDto> GroupCreate()
{
_logger.LogCallInfo();
var existingGroupsByUser = await DbContext.Groups.CountAsync(u => u.OwnerUID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
var existingJoinedGroups = await DbContext.GroupPairs.CountAsync(u => u.GroupUserUID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
var existingGroupsByUser = await DbContext.Groups.CountAsync(u => u.OwnerUID == UserUID).ConfigureAwait(false);
var existingJoinedGroups = await DbContext.GroupPairs.CountAsync(u => u.GroupUserUID == UserUID).ConfigureAwait(false);
if (existingGroupsByUser >= _maxExistingGroupsByUser || existingJoinedGroups >= _maxJoinedGroupsByUser)
{
throw new System.Exception($"Max groups for user is {_maxExistingGroupsByUser}, max joined groups is {_maxJoinedGroupsByUser}.");
}
var gid = StringUtils.GenerateRandomString(12);
while (await DbContext.Groups.AnyAsync(g => g.GID == "LLS-" + gid, cancellationToken: RequestAbortedToken).ConfigureAwait(false))
while (await DbContext.Groups.AnyAsync(g => g.GID == "MSS-" + gid).ConfigureAwait(false))
{
gid = StringUtils.GenerateRandomString(12);
}
gid = "LLS-" + gid;
gid = "MSS-" + gid;
var passwd = StringUtils.GenerateRandomString(16);
using var sha = SHA256.Create();
var hashedPw = StringUtils.Sha256String(passwd);
var currentTime = DateTime.UtcNow;
UserDefaultPreferredPermission defaultPermissions = await DbContext.UserDefaultPreferredPermissions.SingleAsync(u => u.UserUID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
UserDefaultPreferredPermission defaultPermissions = await DbContext.UserDefaultPreferredPermissions.SingleAsync(u => u.UserUID == UserUID).ConfigureAwait(false);
Group newGroup = new()
{
@@ -293,9 +179,7 @@ public partial class LightlessHub
OwnerUID = UserUID,
PreferDisableAnimations = defaultPermissions.DisableGroupAnimations,
PreferDisableSounds = defaultPermissions.DisableGroupSounds,
PreferDisableVFX = defaultPermissions.DisableGroupVFX,
ChatEnabled = true,
CreatedDate = currentTime,
PreferDisableVFX = defaultPermissions.DisableGroupVFX
};
GroupPair initialPair = new()
@@ -303,8 +187,6 @@ public partial class LightlessHub
GroupGID = newGroup.GID,
GroupUserUID = UserUID,
IsPinned = true,
JoinedGroupOn = currentTime,
FromFinder = false,
};
GroupPairPreferredPermission initialPrefPermissions = new()
@@ -313,20 +195,20 @@ public partial class LightlessHub
GroupGID = newGroup.GID,
DisableSounds = defaultPermissions.DisableGroupSounds,
DisableAnimations = defaultPermissions.DisableGroupAnimations,
DisableVFX = defaultPermissions.DisableGroupAnimations,
DisableVFX = defaultPermissions.DisableGroupAnimations
};
await DbContext.Groups.AddAsync(newGroup, 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);
await DbContext.Groups.AddAsync(newGroup).ConfigureAwait(false);
await DbContext.GroupPairs.AddAsync(initialPair).ConfigureAwait(false);
await DbContext.GroupPairPreferredPermissions.AddAsync(initialPrefPermissions).ConfigureAwait(false);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
var self = await DbContext.Users.SingleAsync(u => u.UID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
var self = await DbContext.Users.SingleAsync(u => u.UID == UserUID).ConfigureAwait(false);
await Clients.User(UserUID).Client_GroupSendFullInfo(new GroupFullInfoDto(newGroup.ToGroupData(), self.ToUserData(),
newGroup.ToEnum(), initialPrefPermissions.ToEnum(), initialPair.ToEnum(), new(StringComparer.Ordinal), 1))
newGroup.ToEnum(), initialPrefPermissions.ToEnum(), initialPair.ToEnum(), new(StringComparer.Ordinal)))
.ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(gid));
return new GroupJoinDto(newGroup.ToGroupData(), passwd, initialPrefPermissions.ToEnum());
@@ -380,10 +262,10 @@ public partial class LightlessHub
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
var groupPairs = await DbContext.GroupPairs.Where(p => p.GroupGID == dto.Group.GID).ToListAsync(cancellationToken: RequestAbortedToken).ConfigureAwait(false);
var groupPairs = await DbContext.GroupPairs.Where(p => p.GroupGID == dto.Group.GID).ToListAsync().ConfigureAwait(false);
DbContext.RemoveRange(groupPairs);
DbContext.Remove(group);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
await Clients.Users(groupPairs.Select(g => g.GroupUserUID)).Client_GroupDelete(new GroupDto(group.ToGroupData())).ConfigureAwait(false);
@@ -396,9 +278,9 @@ public partial class LightlessHub
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (userHasRights, group) = await TryValidateGroupModeratorOrOwner(dto.GID).ConfigureAwait(false);
if (!userHasRights) return [];
if (!userHasRights) return new List<BannedGroupUserDto>();
var banEntries = await DbContext.GroupBans.Include(b => b.BannedUser).Where(g => g.GroupGID == dto.Group.GID).AsNoTracking().ToListAsync(cancellationToken: RequestAbortedToken).ConfigureAwait(false);
var banEntries = await DbContext.GroupBans.Include(b => b.BannedUser).Where(g => g.GroupGID == dto.Group.GID).AsNoTracking().ToListAsync().ConfigureAwait(false);
List<BannedGroupUserDto> bannedGroupUsers = banEntries.Select(b =>
new BannedGroupUserDto(group.ToGroupData(), b.BannedUser.ToUserData(), b.BannedReason, b.BannedOn,
@@ -416,14 +298,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, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
var group = await DbContext.Groups.Include(g => g.Owner).AsNoTracking().SingleOrDefaultAsync(g => g.GID == aliasOrGid || g.Alias == aliasOrGid).ConfigureAwait(false);
var groupGid = group?.GID ?? string.Empty;
var existingPair = await DbContext.GroupPairs.AsNoTracking().SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.GroupUserUID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
var existingPair = await DbContext.GroupPairs.AsNoTracking().SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.GroupUserUID == UserUID).ConfigureAwait(false);
var hashedPw = StringUtils.Sha256String(dto.Password);
var existingUserCount = await DbContext.GroupPairs.AsNoTracking().CountAsync(g => g.GroupGID == groupGid, 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);
var existingUserCount = await DbContext.GroupPairs.AsNoTracking().CountAsync(g => g.GroupGID == groupGid).ConfigureAwait(false);
var joinedGroups = await DbContext.GroupPairs.CountAsync(g => g.GroupUserUID == UserUID).ConfigureAwait(false);
var isBanned = await DbContext.GroupBans.AnyAsync(g => g.GroupGID == groupGid && g.BannedUserUID == UserUID).ConfigureAwait(false);
var oneTimeInvite = await DbContext.GroupTempInvites.SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.Invite == hashedPw).ConfigureAwait(false);
if (group == null
|| (!string.Equals(group.HashedPassword, hashedPw, StringComparison.Ordinal) && oneTimeInvite == null)
@@ -444,13 +326,10 @@ 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, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
var group = await DbContext.Groups.Include(g => g.Owner).AsNoTracking().SingleOrDefaultAsync(g => g.GID == aliasOrGid || g.Alias == aliasOrGid).ConfigureAwait(false);
var groupGid = group?.GID ?? string.Empty;
var existingPair = await DbContext.GroupPairs.AsNoTracking().SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.GroupUserUID == UserUID).ConfigureAwait(false);
var isHashedPassword = dto.Password.Length == 64 && dto.Password.All(Uri.IsHexDigit);
var hashedPw = isHashedPassword
? dto.Password
: StringUtils.Sha256String(dto.Password);
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);
@@ -478,11 +357,9 @@ public partial class LightlessHub
{
GroupGID = group.GID,
GroupUserUID = UserUID,
JoinedGroupOn = DateTime.UtcNow,
FromFinder = isHashedPassword
};
var preferredPermissions = await DbContext.GroupPairPreferredPermissions.SingleOrDefaultAsync(u => u.UserUID == UserUID && u.GroupGID == group.GID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
var preferredPermissions = await DbContext.GroupPairPreferredPermissions.SingleOrDefaultAsync(u => u.UserUID == UserUID && u.GroupGID == group.GID).ConfigureAwait(false);
if (preferredPermissions == null)
{
GroupPairPreferredPermission newPerms = new()
@@ -492,7 +369,7 @@ public partial class LightlessHub
DisableSounds = dto.GroupUserPreferredPermissions.IsDisableSounds(),
DisableVFX = dto.GroupUserPreferredPermissions.IsDisableVFX(),
DisableAnimations = dto.GroupUserPreferredPermissions.IsDisableAnimations(),
IsPaused = false,
IsPaused = false
};
DbContext.Add(newPerms);
@@ -504,25 +381,19 @@ public partial class LightlessHub
preferredPermissions.DisableVFX = dto.GroupUserPreferredPermissions.IsDisableVFX();
preferredPermissions.DisableAnimations = dto.GroupUserPreferredPermissions.IsDisableAnimations();
preferredPermissions.IsPaused = false;
preferredPermissions.ShareLocation = dto.GroupUserPreferredPermissions.IsSharingLocation();
DbContext.Update(preferredPermissions);
}
await DbContext.GroupPairs.AddAsync(newPair, RequestAbortedToken).ConfigureAwait(false);
await DbContext.GroupPairs.AddAsync(newPair).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(aliasOrGid, "Success"));
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
var groupInfos = await DbContext.GroupPairs.Where(u => u.GroupGID == group.GID && (u.IsPinned || u.IsModerator)).ToListAsync(cancellationToken: RequestAbortedToken).ConfigureAwait(false);
var totalUserCount = await DbContext.GroupPairs
.AsNoTracking()
.CountAsync(u => u.GroupGID == group.GID, RequestAbortedToken)
.ConfigureAwait(false);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
var groupInfos = await DbContext.GroupPairs.Where(u => u.GroupGID == group.GID && (u.IsPinned || u.IsModerator)).ToListAsync().ConfigureAwait(false);
await Clients.User(UserUID).Client_GroupSendFullInfo(new GroupFullInfoDto(group.ToGroupData(), group.Owner.ToUserData(),
group.ToEnum(), preferredPermissions.ToEnum(), newPair.ToEnum(),
groupInfos.ToDictionary(u => u.GroupUserUID, u => u.ToEnum(), StringComparer.Ordinal), totalUserCount)).ConfigureAwait(false);
groupInfos.ToDictionary(u => u.GroupUserUID, u => u.ToEnum(), StringComparer.Ordinal))).ConfigureAwait(false);
var self = DbContext.Users.Single(u => u.UID == UserUID);
@@ -550,8 +421,7 @@ public partial class LightlessHub
DisableSounds = preferredPermissions.DisableSounds,
DisableVFX = preferredPermissions.DisableVFX,
IsPaused = preferredPermissions.IsPaused,
Sticky = false,
ShareLocation = preferredPermissions.ShareLocation,
Sticky = false
};
await DbContext.Permissions.AddAsync(ownPermissionsToOther).ConfigureAwait(false);
@@ -563,7 +433,6 @@ public partial class LightlessHub
existingPermissionsOnDb.DisableVFX = preferredPermissions.DisableVFX;
existingPermissionsOnDb.IsPaused = false;
existingPermissionsOnDb.Sticky = false;
existingPermissionsOnDb.ShareLocation = preferredPermissions.ShareLocation;
DbContext.Update(existingPermissionsOnDb);
@@ -579,7 +448,6 @@ public partial class LightlessHub
ownPermissionsToOther.DisableVFX = preferredPermissions.DisableVFX;
ownPermissionsToOther.DisableSounds = preferredPermissions.DisableSounds;
ownPermissionsToOther.IsPaused = false;
ownPermissionsToOther.ShareLocation = preferredPermissions.ShareLocation;
DbContext.Update(ownPermissionsToOther);
}
@@ -601,8 +469,7 @@ public partial class LightlessHub
DisableSounds = otherPreferred.DisableSounds,
DisableVFX = otherPreferred.DisableVFX,
IsPaused = otherPreferred.IsPaused,
Sticky = false,
ShareLocation = otherPreferred.ShareLocation,
Sticky = false
};
await DbContext.AddAsync(otherExistingPermsOnDb).ConfigureAwait(false);
@@ -614,7 +481,6 @@ public partial class LightlessHub
otherExistingPermsOnDb.DisableSounds = otherPreferred.DisableSounds;
otherExistingPermsOnDb.DisableVFX = otherPreferred.DisableVFX;
otherExistingPermsOnDb.IsPaused = otherPreferred.IsPaused;
otherExistingPermsOnDb.ShareLocation = otherPreferred.ShareLocation;
DbContext.Update(otherExistingPermsOnDb);
}
@@ -628,7 +494,6 @@ public partial class LightlessHub
otherPermissionToSelf.DisableSounds = otherPreferred.DisableSounds;
otherPermissionToSelf.DisableVFX = otherPreferred.DisableVFX;
otherPermissionToSelf.IsPaused = otherPreferred.IsPaused;
otherPermissionToSelf.ShareLocation = otherPreferred.ShareLocation;
DbContext.Update(otherPermissionToSelf);
}
@@ -653,92 +518,11 @@ public partial class LightlessHub
}
}
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
return true;
}
[Authorize(Policy = "Identified")]
public async Task<GroupJoinInfoDto> GroupJoinHashed(GroupJoinHashedDto dto)
{
var aliasOrGid = dto.Group.GID.Trim();
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var group = await DbContext.Groups.Include(g => g.Owner)
.AsNoTracking()
.SingleOrDefaultAsync(g => g.GID == aliasOrGid || g.Alias == aliasOrGid)
.ConfigureAwait(false);
var groupGid = group?.GID ?? string.Empty;
var existingPair = await DbContext.GroupPairs
.AsNoTracking()
.SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.GroupUserUID == UserUID)
.ConfigureAwait(false);
var isBanned = await DbContext.GroupBans
.AnyAsync(g => g.GroupGID == groupGid && g.BannedUserUID == UserUID)
.ConfigureAwait(false);
var oneTimeInvite = await DbContext.GroupTempInvites
.SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.Invite == dto.HashedPassword)
.ConfigureAwait(false);
var existingUserCount = await DbContext.GroupPairs
.AsNoTracking()
.CountAsync(g => g.GroupGID == groupGid)
.ConfigureAwait(false);
var joinedGroups = await DbContext.GroupPairs
.CountAsync(g => g.GroupUserUID == UserUID)
.ConfigureAwait(false);
if (group == null)
{
await Clients.User(UserUID).Client_ReceiveServerMessage(MessageSeverity.Warning, "Syncshell not found.");
return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false);
}
if (!string.Equals(group.HashedPassword, dto.HashedPassword, StringComparison.Ordinal) && oneTimeInvite == null)
{
await Clients.User(UserUID).Client_ReceiveServerMessage(MessageSeverity.Warning, "Incorrect or expired password.");
return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false);
}
if (existingPair != null)
{
await Clients.User(UserUID).Client_ReceiveServerMessage(MessageSeverity.Warning, "You are already a member of this syncshell.");
return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false);
}
if (existingUserCount >= _maxGroupUserCount)
{
await Clients.User(UserUID).Client_ReceiveServerMessage(MessageSeverity.Warning, "This syncshell is full.");
return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false);
}
if (!group.InvitesEnabled)
{
await Clients.User(UserUID).Client_ReceiveServerMessage(MessageSeverity.Warning, "Invites to this syncshell are currently disabled.");
return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false);
}
if (joinedGroups >= _maxJoinedGroupsByUser)
{
await Clients.User(UserUID).Client_ReceiveServerMessage(MessageSeverity.Warning, "You have reached the maximum number of syncshells you can join.");
return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false);
}
if (isBanned)
{
await Clients.User(UserUID).Client_ReceiveServerMessage(MessageSeverity.Warning, "You are banned from this syncshell.");
return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false);
}
return new GroupJoinInfoDto(group.ToGroupData(), group.Owner.ToUserData(), group.ToEnum(), true);
}
[Authorize(Policy = "Identified")]
public async Task GroupLeave(GroupDto dto)
{
@@ -750,38 +534,30 @@ public partial class LightlessHub
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto, days, execute));
var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID)
.ConfigureAwait(false);
var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (!hasRights) return -1;
if (!execute)
{
var count = await _pruneService.CountPrunableUsersAsync(dto.Group.GID, days, RequestAbortedToken).ConfigureAwait(false);
return count;
}
var allGroupUsers = await DbContext.GroupPairs
.Include(p => p.GroupUser)
.Include(p => p.Group)
var allGroupUsers = await DbContext.GroupPairs.Include(p => p.GroupUser).Include(p => p.Group)
.Where(g => g.GroupGID == dto.Group.GID)
.ToListAsync(RequestAbortedToken).ConfigureAwait(false);
.ToListAsync().ConfigureAwait(false);
var usersToPrune = allGroupUsers.Where(p => !p.IsPinned && !p.IsModerator
&& p.GroupUserUID != UserUID
&& p.Group.OwnerUID != p.GroupUserUID
&& p.GroupUser.LastLoggedIn.AddDays(days) < DateTime.UtcNow);
var prunedPairs = await _pruneService.ExecutePruneAsync(dto.Group.GID, days, RequestAbortedToken).ConfigureAwait(false);
if (!execute) return usersToPrune.Count();
var remainingUserIds = allGroupUsers
.Where(p => !prunedPairs.Any(x => string.Equals(x.GroupUserUID, p.GroupUserUID, StringComparison.Ordinal)))
.Select(p => p.GroupUserUID)
.Distinct(StringComparer.Ordinal)
.ToList();
DbContext.GroupPairs.RemoveRange(usersToPrune);
foreach (var pair in prunedPairs)
foreach (var pair in usersToPrune)
{
await Clients.Users(remainingUserIds)
.Client_GroupPairLeft(new GroupPairDto(dto.Group, pair.GroupUser.ToUserData()))
.ConfigureAwait(false);
await Clients.Users(allGroupUsers.Where(p => !usersToPrune.Contains(p)).Select(g => g.GroupUserUID))
.Client_GroupPairLeft(new GroupPairDto(dto.Group, pair.GroupUser.ToUserData())).ConfigureAwait(false);
}
return prunedPairs.Count;
await DbContext.SaveChangesAsync().ConfigureAwait(false);
return usersToPrune.Count();
}
[Authorize(Policy = "Identified")]
@@ -803,15 +579,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(cancellationToken: RequestAbortedToken).ConfigureAwait(false);
var sharedData = await DbContext.CharaDataAllowances.Where(u => u.AllowedGroup != null && u.AllowedGroupGID == dto.GID && u.ParentUploaderUID == dto.UID).ToListAsync().ConfigureAwait(false);
DbContext.CharaDataAllowances.RemoveRange(sharedData);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
var userIdent = await GetUserIdent(dto.User.UID).ConfigureAwait(false);
if (userIdent == null)
{
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
return;
}
@@ -824,132 +600,6 @@ public partial class LightlessHub
}
}
[Authorize(Policy = "Identified")]
public async Task<GroupProfileDto> GroupGetProfile(GroupDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var cancellationToken = RequestAbortedToken;
if (dto?.Group == null)
{
_logger.LogCallWarning(LightlessHubLogger.Args("GroupGetProfile: dto.Group is null"));
return new GroupProfileDto(Group: null, Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: false, IsDisabled: false);
}
var data = await DbContext.GroupProfiles
.Include(gp => gp.Group)
.FirstOrDefaultAsync(
g => g.Group.GID == dto.Group.GID || g.Group.Alias == dto.Group.AliasOrGID,
cancellationToken
)
.ConfigureAwait(false);
if (data == null)
{
return new GroupProfileDto(dto.Group, Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: false, IsDisabled: false);
}
if (data.ProfileDisabled)
{
return new GroupProfileDto(Group: dto.Group, Description: "This profile was permanently disabled", Tags: [], PictureBase64: null, BannerBase64: null, IsNsfw: false, IsDisabled: true);
}
try
{
return data.ToDTO();
}
catch (Exception ex)
{
_logger.LogCallWarning(LightlessHubLogger.Args(ex, "GroupGetProfile: failed to map GroupProfileDto for {Group}", dto.Group.GID ?? dto.Group.AliasOrGID));
return new GroupProfileDto(dto.Group, Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: false, IsDisabled: false);
}
}
[Authorize(Policy = "Identified")]
public async Task GroupSetProfile(GroupProfileDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var cancellationToken = RequestAbortedToken;
if (dto.Group == null) return;
var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (!hasRights) return;
var groupProfileDb = await DbContext.GroupProfiles
.Include(g => g.Group)
.FirstOrDefaultAsync(g => g.GroupGID == dto.Group.GID, cancellationToken)
.ConfigureAwait(false);
ImageCheckService.ImageLoadResult profileResult = new();
ImageCheckService.ImageLoadResult bannerResult = new();
//Avatar image validation
if (!string.IsNullOrEmpty(dto.PictureBase64))
{
profileResult = await ImageCheckService.ValidateImageAsync(dto.PictureBase64, banner: false, RequestAbortedToken).ConfigureAwait(false);
if (!profileResult.Success)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, profileResult.ErrorMessage).ConfigureAwait(false);
return;
}
}
//Banner image validation
if (!string.IsNullOrEmpty(dto.BannerBase64))
{
bannerResult = await ImageCheckService.ValidateImageAsync(dto.BannerBase64, banner: true, RequestAbortedToken).ConfigureAwait(false);
if (!bannerResult.Success)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, bannerResult.ErrorMessage).ConfigureAwait(false);
return;
}
}
var sanitizedProfileImage = profileResult?.Base64Image;
var sanitizedBannerImage = bannerResult?.Base64Image;
if (groupProfileDb == null)
{
groupProfileDb = new GroupProfile
{
GroupGID = dto.Group.GID,
Group = group,
ProfileDisabled = dto.IsDisabled ?? false,
IsNSFW = dto.IsNsfw ?? false,
};
groupProfileDb.UpdateProfileFromDto(dto, sanitizedProfileImage, sanitizedBannerImage);
await DbContext.GroupProfiles.AddAsync(groupProfileDb, cancellationToken).ConfigureAwait(false);
}
else
{
groupProfileDb.Group ??= group;
groupProfileDb.UpdateProfileFromDto(dto, sanitizedProfileImage, sanitizedBannerImage);
}
var userIds = await DbContext.GroupPairs
.Where(p => p.GroupGID == groupProfileDb.GroupGID)
.Select(p => p.GroupUserUID)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (userIds.Count > 0)
{
var profileDto = groupProfileDb.ToDTO();
await Clients.Users(userIds).Client_GroupSendProfile(profileDto)
.ConfigureAwait(false);
}
await DbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task GroupSetUserInfo(GroupPairUserInfoDto dto)
{
@@ -979,99 +629,28 @@ public partial class LightlessHub
userPair.IsModerator = false;
}
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
var groupPairs = await DbContext.GroupPairs.AsNoTracking().Where(p => p.GroupGID == dto.Group.GID).Select(p => p.GroupUserUID).ToListAsync(cancellationToken: RequestAbortedToken).ConfigureAwait(false);
var groupPairs = await DbContext.GroupPairs.AsNoTracking().Where(p => p.GroupGID == dto.Group.GID).Select(p => p.GroupUserUID).ToListAsync().ConfigureAwait(false);
await Clients.Users(groupPairs).Client_GroupPairChangeUserInfo(new GroupPairUserInfoDto(dto.Group, dto.User, userPair.ToEnum())).ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task<GroupPruneSettingsDto> GroupGetPruneSettings(GroupDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.GID)
.ConfigureAwait(false);
if (!hasRights || group == null)
return null;
return new GroupPruneSettingsDto(
Group: group.ToGroupData(),
AutoPruneEnabled: group.AutoPruneEnabled,
AutoPruneDays: group.AutoPruneDays
);
}
[Authorize(Policy = "Identified")]
public async Task GroupSetPruneSettings(GroupPruneSettingsDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID)
.ConfigureAwait(false);
if (!hasRights || group == null)
return;
// if days == 0, auto prune is OFF
var days = dto.AutoPruneDays;
var enabled = dto.AutoPruneEnabled && days > 0;
group.AutoPruneEnabled = enabled;
group.AutoPruneDays = enabled ? days : 0;
await DbContext.SaveChangesAsync(RequestAbortedToken)
.ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task<List<GroupFullInfoDto>> GroupsGetAll()
{
_logger.LogCallInfo();
var ct = RequestAbortedToken;
var groups = await DbContext.GroupPairs.Include(g => g.Group).Include(g => g.Group.Owner).Where(g => g.GroupUserUID == UserUID).AsNoTracking().ToListAsync().ConfigureAwait(false);
var preferredPermissions = (await DbContext.GroupPairPreferredPermissions.Where(u => u.UserUID == UserUID).ToListAsync().ConfigureAwait(false))
.Where(u => groups.Exists(k => string.Equals(k.GroupGID, u.GroupGID, StringComparison.Ordinal)))
.ToDictionary(u => groups.First(f => string.Equals(f.GroupGID, u.GroupGID, StringComparison.Ordinal)), u => u);
var groupInfos = await DbContext.GroupPairs.Where(u => groups.Select(g => g.GroupGID).Contains(u.GroupGID) && (u.IsPinned || u.IsModerator))
.ToListAsync().ConfigureAwait(false);
var result = await (
from gp in DbContext.GroupPairs
.Include(gp => gp.Group)
.ThenInclude(g => g.Owner)
join pp in DbContext.GroupPairPreferredPermissions
on new { gp.GroupGID, UserUID } equals new { pp.GroupGID, pp.UserUID }
where gp.GroupUserUID == UserUID
select new
{
GroupPair = gp,
PreferredPermission = pp,
GroupInfos = DbContext.GroupPairs
.Where(x => x.GroupGID == gp.GroupGID && (x.IsPinned || x.IsModerator))
.Select(x => new { x.GroupUserUID, EnumValue = x.ToEnum() })
.ToList(),
UserCount = DbContext.GroupPairs
.Count(x => x.GroupGID == gp.GroupGID),
})
.AsNoTracking()
.ToListAsync()
.ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(result));
List<GroupFullInfoDto> List = [.. result.Select(r =>
{
var groupInfoDict = r.GroupInfos
.ToDictionary(x => x.GroupUserUID, x => x.EnumValue, StringComparer.Ordinal);
_logger.LogCallInfo(LightlessHubLogger.Args(r));
return new GroupFullInfoDto(
r.GroupPair.Group.ToGroupData(),
r.GroupPair.Group.Owner.ToUserData(),
r.GroupPair.Group.ToEnum(),
r.PreferredPermission.ToEnum(),
r.GroupPair.ToEnum(),
groupInfoDict,
r.UserCount
);
}),];
return List;
return preferredPermissions.Select(g => new GroupFullInfoDto(g.Key.Group.ToGroupData(), g.Key.Group.Owner.ToUserData(),
g.Key.Group.ToEnum(), g.Value.ToEnum(), g.Key.ToEnum(),
groupInfos.Where(i => string.Equals(i.GroupGID, g.Key.GroupGID, StringComparison.Ordinal))
.ToDictionary(i => i.GroupUserUID, i => i.ToEnum(), StringComparer.Ordinal))).ToList();
}
[Authorize(Policy = "Identified")]
@@ -1082,95 +661,12 @@ 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, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
var banEntry = await DbContext.GroupBans.SingleOrDefaultAsync(g => g.GroupGID == dto.Group.GID && g.BannedUserUID == dto.User.UID).ConfigureAwait(false);
if (banEntry == null) return;
DbContext.Remove(banEntry);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
}
[Authorize(Policy = "Identified")]
public async Task<bool> SetGroupBroadcastStatus(GroupBroadcastRequestDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
if (string.IsNullOrEmpty(dto.HashedCID))
{
_logger.LogCallWarning(LightlessHubLogger.Args("missing CID in syncshell broadcast request", "User", UserUID, "GID", dto.GID));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Internal error: missing CID.");
return false;
}
if (!_broadcastConfiguration.EnableBroadcasting || !_broadcastConfiguration.EnableSyncshellBroadcastPayloads)
{
_logger.LogCallWarning(LightlessHubLogger.Args("syncshell broadcast disabled", "User", UserUID, "GID", dto.GID));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Syncshell broadcasting is currently disabled.").ConfigureAwait(false);
return false;
}
var (isOwner, _) = await TryValidateOwner(dto.GID).ConfigureAwait(false);
if (!isOwner)
{
_logger.LogCallWarning(LightlessHubLogger.Args("Unauthorized syncshell broadcast change", "User", UserUID, "GID", dto.GID));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "You must be the owner of the syncshell to broadcast it.");
return false;
}
return true;
}
[Authorize(Policy = "Identified")]
public async Task<List<GroupJoinDto>> GetBroadcastedGroups(List<BroadcastStatusInfoDto> broadcastEntries)
{
_logger.LogCallInfo(LightlessHubLogger.Args("Requested Syncshells", broadcastEntries.Select(b => b.GID)));
if (!_broadcastConfiguration.EnableBroadcasting || !_broadcastConfiguration.EnableSyncshellBroadcastPayloads)
return new List<GroupJoinDto>();
var results = new List<GroupJoinDto>();
var gidsToValidate = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in broadcastEntries)
{
if (string.IsNullOrWhiteSpace(entry.HashedCID) || string.IsNullOrWhiteSpace(entry.GID))
continue;
var redisKey = _broadcastConfiguration.BuildRedisKey(entry.HashedCID);
var redisEntry = await _redis.GetAsync<BroadcastRedisEntry>(redisKey).ConfigureAwait(false);
if (redisEntry is null)
continue;
if (!string.IsNullOrEmpty(redisEntry.HashedCID) && !string.Equals(redisEntry.HashedCID, entry.HashedCID, StringComparison.Ordinal))
{
_logger.LogCallWarning(LightlessHubLogger.Args("mismatched broadcast cid for group lookup", "Requested", entry.HashedCID, "EntryCID", redisEntry.HashedCID));
continue;
}
if (redisEntry.GID != null && string.Equals(redisEntry.GID, entry.GID, StringComparison.OrdinalIgnoreCase))
gidsToValidate.Add(entry.GID);
}
if (gidsToValidate.Count == 0)
return results;
var groups = await DbContext.Groups
.AsNoTracking()
.Where(g => gidsToValidate.Contains(g.GID) && g.InvitesEnabled)
.ToListAsync()
.ConfigureAwait(false);
foreach (var group in groups)
{
results.Add(new GroupJoinDto(
Group: new GroupData(group.GID, group.Alias),
Password: group.HashedPassword,
GroupUserPreferredPermissions: new GroupUserPreferredPermissions()
));
}
return results;
}
}

View File

@@ -66,7 +66,6 @@ public partial class LightlessHub
prevPermissions.DisableSounds = newPerm.IsDisableSounds();
prevPermissions.DisableVFX = newPerm.IsDisableVFX();
prevPermissions.Sticky = newPerm.IsSticky() || setSticky;
prevPermissions.ShareLocation = newPerm.IsSharingLocation();
DbContext.Update(prevPermissions);
// send updated data to pair
@@ -113,7 +112,6 @@ public partial class LightlessHub
groupPreferredPermissions.DisableAnimations = group.Value.IsDisableAnimations();
groupPreferredPermissions.IsPaused = group.Value.IsPaused();
groupPreferredPermissions.DisableVFX = group.Value.IsDisableVFX();
groupPreferredPermissions.ShareLocation = group.Value.IsSharingLocation();
var nonStickyPairs = allUsers.Where(u => !u.Value.OwnPermissions.Sticky).ToList();
var affectedGroupPairs = nonStickyPairs.Where(u => u.Value.GIDs.Contains(group.Key, StringComparer.Ordinal)).ToList();
@@ -128,7 +126,6 @@ public partial class LightlessHub
perm.DisableAnimations = groupPreferredPermissions.DisableAnimations;
perm.IsPaused = groupPreferredPermissions.IsPaused;
perm.DisableVFX = groupPreferredPermissions.DisableVFX;
perm.ShareLocation = groupPreferredPermissions.ShareLocation;
}
UserPermissions permissions = UserPermissions.NoneSet;
@@ -136,7 +133,6 @@ public partial class LightlessHub
permissions.SetDisableAnimations(groupPreferredPermissions.DisableAnimations);
permissions.SetDisableSounds(groupPreferredPermissions.DisableSounds);
permissions.SetDisableVFX(groupPreferredPermissions.DisableVFX);
permissions.SetShareLocation(groupPreferredPermissions.ShareLocation);
await Clients.Users(affectedGroupPairs
.Select(k => k.Key))

View File

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

View File

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

View File

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

View File

@@ -1,12 +0,0 @@
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

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

View File

@@ -1,65 +0,0 @@
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

@@ -1,8 +0,0 @@
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

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

View File

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

View File

@@ -1,117 +0,0 @@
// <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,6 +41,7 @@ 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

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

View File

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

View File

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

View File

@@ -1,60 +0,0 @@
using LightlessSyncServer.Services.Interfaces;
using LightlessSyncShared.Data;
using LightlessSyncShared.Models;
using Microsoft.EntityFrameworkCore;
namespace LightlessSyncServer.Services
{
public class PruneService(LightlessDbContext dbContext) : IPruneService
{
private readonly LightlessDbContext _dbContext = dbContext;
public async Task<int> CountPrunableUsersAsync(string groupGid, int days, CancellationToken ct)
{
var allGroupUsers = await _dbContext.GroupPairs
.Include(p => p.GroupUser)
.Include(p => p.Group)
.Where(g => g.GroupGID == groupGid)
.ToListAsync(ct).ConfigureAwait(false);
var inactivitySpan = GetInactivitySpan(days);
var now = DateTime.UtcNow;
var usersToPrune = allGroupUsers.Where(p =>
!p.IsPinned &&
!p.IsModerator &&
!string.Equals(p.Group.OwnerUID, p.GroupUserUID, StringComparison.Ordinal) &&
p.GroupUser.LastLoggedIn < now - inactivitySpan);
return usersToPrune.Count();
}
public async Task<IReadOnlyList<GroupPair>> ExecutePruneAsync(string groupGid, int days, CancellationToken ct)
{
var allGroupUsers = await _dbContext.GroupPairs
.Include(p => p.GroupUser)
.Include(p => p.Group)
.Where(g => g.GroupGID == groupGid)
.ToListAsync(ct).ConfigureAwait(false);
var inactivitySpan = GetInactivitySpan(days);
var now = DateTime.UtcNow;
var usersToPrune = allGroupUsers.Where(p =>
!p.IsPinned &&
!p.IsModerator &&
!string.Equals(p.Group.OwnerUID, p.GroupUserUID, StringComparison.Ordinal) &&
p.GroupUser.LastLoggedIn < now - inactivitySpan)
.ToList();
_dbContext.GroupPairs.RemoveRange(usersToPrune);
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
return usersToPrune;
}
private static TimeSpan GetInactivitySpan(int days) => days == 0
? TimeSpan.FromMinutes(15)
: TimeSpan.FromDays(days);
}
}

View File

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

View File

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

View File

@@ -1,8 +1,6 @@
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;
@@ -10,97 +8,18 @@ namespace LightlessSyncServer.Utils;
public static class Extensions
{
public static void UpdateProfileFromDto(this GroupProfile profile, GroupProfileDto dto, string? base64PictureString = null, string? base64BannerString = null)
{
ArgumentNullException.ThrowIfNull(profile);
ArgumentNullException.ThrowIfNull(dto);
if (profile == null || dto == null) return;
if (base64PictureString != null) profile.Base64GroupProfileImage = base64PictureString;
if (base64BannerString != null) profile.Base64GroupBannerImage = base64BannerString;
if (dto.Tags != null) profile.Tags = dto.Tags;
if (dto.Description != null) profile.Description = dto.Description;
if (dto.IsNsfw.HasValue) profile.IsNSFW = dto.IsNsfw.Value;
if (dto.IsDisabled.HasValue) profile.ProfileDisabled = dto.IsDisabled.Value;
}
public static void UpdateProfileFromDto(this UserProfileData profile, UserProfileDto dto, string? base64PictureString = null, string? base64BannerString = null)
{
ArgumentNullException.ThrowIfNull(profile);
ArgumentNullException.ThrowIfNull(dto);
if (profile == null || dto == null) return;
if (base64PictureString != null) profile.Base64ProfileImage = base64PictureString;
if (base64BannerString != null) profile.Base64BannerImage = base64BannerString;
if (dto.Tags != null) profile.Tags = dto.Tags;
if (dto.Description != null) profile.UserDescription = dto.Description;
if (dto.IsNSFW.HasValue) profile.IsNSFW = dto.IsNSFW.Value;
}
public static GroupProfileDto ToDTO(this GroupProfile groupProfile)
{
if (groupProfile == null)
{
return new GroupProfileDto(Group: null, Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: false, IsDisabled: false);
}
var groupData = groupProfile.Group?.ToGroupData()
?? (!string.IsNullOrWhiteSpace(groupProfile.GroupGID) ? new GroupData(groupProfile.GroupGID) : null);
return new GroupProfileDto(
groupData,
groupProfile.Description,
groupProfile.Tags,
groupProfile.Base64GroupProfileImage,
groupProfile.Base64GroupBannerImage,
groupProfile.IsNSFW,
groupProfile.ProfileDisabled
);
}
public static UserProfileDto ToDTO(this UserProfileData userProfileData)
{
if (userProfileData == null)
{
return new UserProfileDto(User: null, Disabled: false, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: null, Tags: []);
}
var userData = userProfileData.User?.ToUserData();
return new UserProfileDto(
userData,
userProfileData.ProfileDisabled,
userProfileData.IsNSFW,
userProfileData.Base64ProfileImage,
userProfileData.Base64BannerImage,
userProfileData.UserDescription,
userProfileData.Tags
);
}
public static GroupData ToGroupData(this Group group)
{
if (group == null)
return null;
return new GroupData(group.GID, group.Alias, group.CreatedDate);
return new GroupData(group.GID, group.Alias);
}
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);
}
@@ -118,7 +37,6 @@ public static class Extensions
permissions.SetPreferDisableSounds(group.PreferDisableSounds);
permissions.SetPreferDisableVFX(group.PreferDisableVFX);
permissions.SetDisableInvites(!group.InvitesEnabled);
permissions.SetDisableChat(!group.ChatEnabled);
return permissions;
}
@@ -129,7 +47,6 @@ public static class Extensions
permissions.SetDisableSounds(groupPair.DisableSounds);
permissions.SetPaused(groupPair.IsPaused);
permissions.SetDisableVFX(groupPair.DisableVFX);
permissions.SetShareLocation(groupPair.ShareLocation);
return permissions;
}
@@ -150,7 +67,6 @@ public static class Extensions
perm.SetDisableAnimations(permissions.DisableAnimations);
perm.SetDisableSounds(permissions.DisableSounds);
perm.SetDisableVFX(permissions.DisableVFX);
perm.SetShareLocation(permissions.ShareLocation);
if (setSticky)
perm.SetSticky(permissions.Sticky);
return perm;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,40 +0,0 @@
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

@@ -1,51 +0,0 @@
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

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

View File

@@ -1,90 +0,0 @@
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

@@ -1,41 +0,0 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,5 +10,4 @@ public class GroupPairPreferredPermission
public bool DisableAnimations { get; set; }
public bool DisableSounds { get; set; }
public bool DisableVFX { get; set; }
public bool ShareLocation { get; set; }
}

View File

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

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