Compare commits
1 Commits
c8e28cdd64
...
debug-logg
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b6268e82f |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -350,5 +350,4 @@ MigrationBackup/
|
||||
.ionide/
|
||||
|
||||
# docker run data
|
||||
Docker/run/data/
|
||||
*.idea
|
||||
Docker/run/data/
|
||||
1
.gitmodules
vendored
1
.gitmodules
vendored
@@ -1,4 +1,3 @@
|
||||
[submodule "LightlessAPI"]
|
||||
path = LightlessAPI
|
||||
url = https://git.lightless-sync.org/Lightless-Sync/LightlessAPI
|
||||
branch = main
|
||||
Submodule LightlessAPI updated: fdd492a8f4...3a69c94f7f
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using LightlessSync.API.Routes;
|
||||
using LightlessSyncAuthService.Services;
|
||||
using LightlessSyncAuthService.Utils;
|
||||
using LightlessSyncShared;
|
||||
using LightlessSyncShared.Data;
|
||||
using LightlessSyncShared.Services;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using LightlessSync.API.Routes;
|
||||
using LightlessSyncAuthService.Services;
|
||||
using LightlessSyncAuthService.Utils;
|
||||
using LightlessSyncShared;
|
||||
using LightlessSyncShared.Data;
|
||||
using LightlessSyncShared.Services;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "*";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
437
LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.User.cs
Normal file
437
LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.User.cs
Normal 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);
|
||||
}
|
||||
@@ -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 _);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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")),
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
namespace LightlessSyncServer.Models;
|
||||
|
||||
internal readonly record struct TerritoryDefinition(
|
||||
ushort TerritoryId,
|
||||
string Name);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
||||
namespace LightlessSyncServer.Models;
|
||||
|
||||
internal readonly record struct WorldDefinition(
|
||||
ushort WorldId,
|
||||
string Name,
|
||||
string Region,
|
||||
string DataCenter);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using FluentAssertions;
|
||||
using LightlessSyncServices.Discord;
|
||||
using LightlessSyncServer.Discord;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
293
LightlessSyncServer/LightlessSyncServices/Discord/MareModule.cs
Normal file
293
LightlessSyncServer/LightlessSyncServices/Discord/MareModule.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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: "");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user