Merge pull request 'Auto pruning of Syncshell, Added group count in DTO of group. Changed database to add prune days. Metrics added for auto prune enabled syncshells.' (#36) from auto-prune-syncshell into master

Reviewed-on: #36
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
This commit was merged in pull request #36.
This commit is contained in:
2025-12-09 05:37:42 +01:00
27 changed files with 1710 additions and 136 deletions

View File

@@ -264,7 +264,7 @@ public partial class LightlessHub
var self = await DbContext.Users.SingleAsync(u => u.UID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false); var self = await DbContext.Users.SingleAsync(u => u.UID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
await Clients.User(UserUID).Client_GroupSendFullInfo(new GroupFullInfoDto(newGroup.ToGroupData(), self.ToUserData(), await Clients.User(UserUID).Client_GroupSendFullInfo(new GroupFullInfoDto(newGroup.ToGroupData(), self.ToUserData(),
newGroup.ToEnum(), initialPrefPermissions.ToEnum(), initialPair.ToEnum(), new(StringComparer.Ordinal))) newGroup.ToEnum(), initialPrefPermissions.ToEnum(), initialPair.ToEnum(), new(StringComparer.Ordinal), 1))
.ConfigureAwait(false); .ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(gid)); _logger.LogCallInfo(LightlessHubLogger.Args(gid));
@@ -454,9 +454,14 @@ public partial class LightlessHub
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false); 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 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 Clients.User(UserUID).Client_GroupSendFullInfo(new GroupFullInfoDto(group.ToGroupData(), group.Owner.ToUserData(), await Clients.User(UserUID).Client_GroupSendFullInfo(new GroupFullInfoDto(group.ToGroupData(), group.Owner.ToUserData(),
group.ToEnum(), preferredPermissions.ToEnum(), newPair.ToEnum(), group.ToEnum(), preferredPermissions.ToEnum(), newPair.ToEnum(),
groupInfos.ToDictionary(u => u.GroupUserUID, u => u.ToEnum(), StringComparer.Ordinal))).ConfigureAwait(false); groupInfos.ToDictionary(u => u.GroupUserUID, u => u.ToEnum(), StringComparer.Ordinal), totalUserCount)).ConfigureAwait(false);
var self = DbContext.Users.Single(u => u.UID == UserUID); var self = DbContext.Users.Single(u => u.UID == UserUID);
@@ -678,30 +683,38 @@ public partial class LightlessHub
{ {
_logger.LogCallInfo(LightlessHubLogger.Args(dto, days, execute)); _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 (!hasRights) return -1;
var allGroupUsers = await DbContext.GroupPairs.Include(p => p.GroupUser).Include(p => p.Group) if (!execute)
.Where(g => g.GroupGID == dto.Group.GID)
.ToListAsync().ConfigureAwait(false);
var usersToPrune = allGroupUsers.Where(p => !p.IsPinned && !p.IsModerator
&& !string.Equals(p.GroupUserUID, UserUID, StringComparison.Ordinal)
&& !string.Equals(p.Group.OwnerUID, p.GroupUserUID, StringComparison.Ordinal)
&& p.GroupUser.LastLoggedIn.AddDays(days) < DateTime.UtcNow);
if (!execute) return usersToPrune.Count();
DbContext.GroupPairs.RemoveRange(usersToPrune);
foreach (var pair in usersToPrune)
{ {
await Clients.Users(allGroupUsers.Where(p => !usersToPrune.Contains(p)).Select(g => g.GroupUserUID)) var count = await _pruneService.CountPrunableUsersAsync(dto.Group.GID, days, RequestAbortedToken).ConfigureAwait(false);
.Client_GroupPairLeft(new GroupPairDto(dto.Group, pair.GroupUser.ToUserData())).ConfigureAwait(false); return count;
} }
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false); var allGroupUsers = await DbContext.GroupPairs
.Include(p => p.GroupUser)
.Include(p => p.Group)
.Where(g => g.GroupGID == dto.Group.GID)
.ToListAsync(RequestAbortedToken).ConfigureAwait(false);
return usersToPrune.Count(); var prunedPairs = await _pruneService.ExecutePruneAsync(dto.Group.GID, days, RequestAbortedToken).ConfigureAwait(false);
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 Clients.Users(remainingUserIds)
.Client_GroupPairLeft(new GroupPairDto(dto.Group, pair.GroupUser.ToUserData()))
.ConfigureAwait(false);
}
return prunedPairs.Count;
} }
[Authorize(Policy = "Identified")] [Authorize(Policy = "Identified")]
@@ -789,90 +802,91 @@ public partial class LightlessHub
[Authorize(Policy = "Identified")] [Authorize(Policy = "Identified")]
public async Task GroupSetProfile(GroupProfileDto dto) public async Task GroupSetProfile(GroupProfileDto dto)
{ {
_logger.LogCallInfo(LightlessHubLogger.Args(dto)); _logger.LogCallInfo(LightlessHubLogger.Args(dto));
var cancellationToken = RequestAbortedToken; var cancellationToken = RequestAbortedToken;
if (dto.Group == null) return; if (dto.Group == null) return;
var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false); var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (!hasRights) return; if (!hasRights) return;
var groupProfileDb = await DbContext.GroupProfiles var groupProfileDb = await DbContext.GroupProfiles
.Include(g => g.Group) .Include(g => g.Group)
.FirstOrDefaultAsync(g => g.GroupGID == dto.Group.GID, cancellationToken) .FirstOrDefaultAsync(g => g.GroupGID == dto.Group.GID, cancellationToken)
.ConfigureAwait(false);
ImageCheckService.ImageLoadResult profileResult = new();
ImageCheckService.ImageLoadResult bannerResult = new();
//Avatar image validation
if (!string.IsNullOrEmpty(dto.PictureBase64))
{
profileResult = await ImageCheckService.ValidateImageAsync(dto.PictureBase64, banner: false, RequestAbortedToken).ConfigureAwait(false);
if (!profileResult.Success)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, profileResult.ErrorMessage).ConfigureAwait(false);
return;
}
}
//Banner image validation
if (!string.IsNullOrEmpty(dto.BannerBase64))
{
bannerResult = await ImageCheckService.ValidateImageAsync(dto.BannerBase64, banner: true, RequestAbortedToken).ConfigureAwait(false);
if (!bannerResult.Success)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, bannerResult.ErrorMessage).ConfigureAwait(false);
return;
}
}
var sanitizedProfileImage = profileResult?.Base64Image;
var sanitizedBannerImage = bannerResult?.Base64Image;
if (groupProfileDb == null)
{
groupProfileDb = new GroupProfile
{
GroupGID = dto.Group.GID,
Group = group,
ProfileDisabled = false,
IsNSFW = dto.IsNsfw ?? false,
};
groupProfileDb.UpdateProfileFromDto(dto, sanitizedProfileImage, sanitizedBannerImage);
await DbContext.GroupProfiles.AddAsync(groupProfileDb, cancellationToken).ConfigureAwait(false);
}
else
{
groupProfileDb.Group ??= group;
if (groupProfileDb?.ProfileDisabled ?? false)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your profile was permanently disabled and cannot be edited").ConfigureAwait(false);
return;
}
groupProfileDb.UpdateProfileFromDto(dto, sanitizedProfileImage, sanitizedBannerImage);
}
var userIds = await DbContext.GroupPairs
.Where(p => p.GroupGID == groupProfileDb.GroupGID)
.Select(p => p.GroupUserUID)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (userIds.Count > 0)
{
var profileDto = groupProfileDb.ToDTO();
await Clients.Users(userIds).Client_GroupSendProfile(profileDto)
.ConfigureAwait(false); .ConfigureAwait(false);
}
await DbContext.SaveChangesAsync(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;
if (groupProfileDb?.ProfileDisabled ?? false)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your profile was permanently disabled and cannot be edited").ConfigureAwait(false);
return;
}
groupProfileDb.UpdateProfileFromDto(dto, sanitizedProfileImage, sanitizedBannerImage);
}
var userIds = await DbContext.GroupPairs
.Where(p => p.GroupGID == groupProfileDb.GroupGID)
.Select(p => p.GroupUserUID)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (userIds.Count > 0)
{
var profileDto = groupProfileDb.ToDTO();
await Clients.Users(userIds).Client_GroupSendProfile(profileDto)
.ConfigureAwait(false);
}
await DbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
} }
[Authorize(Policy = "Identified")] [Authorize(Policy = "Identified")]
@@ -910,6 +924,44 @@ public partial class LightlessHub
await Clients.Users(groupPairs).Client_GroupPairChangeUserInfo(new GroupPairUserInfoDto(dto.Group, dto.User, userPair.ToEnum())).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")] [Authorize(Policy = "Identified")]
public async Task<List<GroupFullInfoDto>> GroupsGetAll() public async Task<List<GroupFullInfoDto>> GroupsGetAll()
{ {
@@ -932,6 +984,8 @@ public partial class LightlessHub
.Where(x => x.GroupGID == gp.GroupGID && (x.IsPinned || x.IsModerator)) .Where(x => x.GroupGID == gp.GroupGID && (x.IsPinned || x.IsModerator))
.Select(x => new { x.GroupUserUID, EnumValue = x.ToEnum() }) .Select(x => new { x.GroupUserUID, EnumValue = x.ToEnum() })
.ToList(), .ToList(),
UserCount = DbContext.GroupPairs
.Count(x => x.GroupGID == gp.GroupGID),
}) })
.AsNoTracking() .AsNoTracking()
.ToListAsync() .ToListAsync()
@@ -940,21 +994,21 @@ public partial class LightlessHub
_logger.LogCallInfo(LightlessHubLogger.Args(result)); _logger.LogCallInfo(LightlessHubLogger.Args(result));
List<GroupFullInfoDto> List = [.. result.Select(r => List<GroupFullInfoDto> List = [.. result.Select(r =>
{ {
var groupInfoDict = r.GroupInfos var groupInfoDict = r.GroupInfos
.ToDictionary(x => x.GroupUserUID, x => x.EnumValue, StringComparer.Ordinal); .ToDictionary(x => x.GroupUserUID, x => x.EnumValue, StringComparer.Ordinal);
_logger.LogCallInfo(LightlessHubLogger.Args(r));
_logger.LogCallInfo(LightlessHubLogger.Args(r)); return new GroupFullInfoDto(
r.GroupPair.Group.ToGroupData(),
return new GroupFullInfoDto( r.GroupPair.Group.Owner.ToUserData(),
r.GroupPair.Group.ToGroupData(), r.GroupPair.Group.ToEnum(),
r.GroupPair.Group.Owner.ToUserData(), r.PreferredPermission.ToEnum(),
r.GroupPair.Group.ToEnum(), r.GroupPair.ToEnum(),
r.PreferredPermission.ToEnum(), groupInfoDict,
r.GroupPair.ToEnum(), r.UserCount
groupInfoDict );
);
}),]; }),];
return List; return List;
} }

View File

@@ -1,7 +1,6 @@
using LightlessSync.API.Data; using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto; using LightlessSync.API.Dto;
using LightlessSync.API.Dto.Chat;
using LightlessSync.API.SignalR; using LightlessSync.API.SignalR;
using LightlessSyncServer.Services; using LightlessSyncServer.Services;
using LightlessSyncServer.Configuration; using LightlessSyncServer.Configuration;
@@ -17,8 +16,7 @@ using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using StackExchange.Redis.Extensions.Core.Abstractions; using StackExchange.Redis.Extensions.Core.Abstractions;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using LightlessSyncServer.Services.Interfaces;
using System.Linq;
namespace LightlessSyncServer.Hubs; namespace LightlessSyncServer.Hubs;
@@ -29,6 +27,7 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
private readonly LightlessMetrics _lightlessMetrics; private readonly LightlessMetrics _lightlessMetrics;
private readonly SystemInfoService _systemInfoService; private readonly SystemInfoService _systemInfoService;
private readonly PairService _pairService; private readonly PairService _pairService;
private readonly IPruneService _pruneService;
private readonly IHttpContextAccessor _contextAccessor; private readonly IHttpContextAccessor _contextAccessor;
private readonly LightlessHubLogger _logger; private readonly LightlessHubLogger _logger;
private readonly string _shardName; private readonly string _shardName;
@@ -55,7 +54,7 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
IConfigurationService<ServerConfiguration> configuration, IHttpContextAccessor contextAccessor, IConfigurationService<ServerConfiguration> configuration, IHttpContextAccessor contextAccessor,
IRedisDatabase redisDb, OnlineSyncedPairCacheService onlineSyncedPairCacheService, LightlessCensus lightlessCensus, IRedisDatabase redisDb, OnlineSyncedPairCacheService onlineSyncedPairCacheService, LightlessCensus lightlessCensus,
GPoseLobbyDistributionService gPoseLobbyDistributionService, IBroadcastConfiguration broadcastConfiguration, PairService pairService, GPoseLobbyDistributionService gPoseLobbyDistributionService, IBroadcastConfiguration broadcastConfiguration, PairService pairService,
ChatChannelService chatChannelService) ChatChannelService chatChannelService, IPruneService pruneService)
{ {
_lightlessMetrics = lightlessMetrics; _lightlessMetrics = lightlessMetrics;
_systemInfoService = systemInfoService; _systemInfoService = systemInfoService;
@@ -77,6 +76,7 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
_broadcastConfiguration = broadcastConfiguration; _broadcastConfiguration = broadcastConfiguration;
_pairService = pairService; _pairService = pairService;
_chatChannelService = chatChannelService; _chatChannelService = chatChannelService;
_pruneService = pruneService;
} }
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)

View File

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

View File

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

View File

@@ -72,9 +72,11 @@ public sealed class SystemInfoService : BackgroundService
await _hubContext.Clients.All.Client_UpdateSystemInfo(SystemInfoDto).ConfigureAwait(false); await _hubContext.Clients.All.Client_UpdateSystemInfo(SystemInfoDto).ConfigureAwait(false);
using var db = await _dbContextFactory.CreateDbContextAsync(ct).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.GaugeAuthorizedConnections, onlineUsers);
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeLightFinderConnections, countLightFinderUsers); _lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeLightFinderConnections, countLightFinderUsers);
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeGroupAutoPrunesEnabled, groupsWithAutoPrune);
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugePairs, db.ClientPairs.AsNoTracking().Count()); _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().Count(p => p.IsPaused));
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeGroups, db.Groups.AsNoTracking().Count()); _lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeGroups, db.Groups.AsNoTracking().Count());

View File

@@ -1,9 +1,13 @@
using AspNetCoreRateLimit; using AspNetCoreRateLimit;
using LightlessSync.API.Data;
using LightlessSync.API.Dto.Group;
using LightlessSync.API.SignalR; using LightlessSync.API.SignalR;
using LightlessSyncServer.Controllers;
using LightlessSyncServer.Configuration; using LightlessSyncServer.Configuration;
using LightlessSyncServer.Controllers;
using LightlessSyncServer.Hubs; using LightlessSyncServer.Hubs;
using LightlessSyncServer.Services; using LightlessSyncServer.Services;
using LightlessSyncServer.Services.Interfaces;
using LightlessSyncServer.Worker;
using LightlessSyncShared.Data; using LightlessSyncShared.Data;
using LightlessSyncShared.Metrics; using LightlessSyncShared.Metrics;
using LightlessSyncShared.RequirementHandlers; using LightlessSyncShared.RequirementHandlers;
@@ -18,6 +22,7 @@ using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Prometheus; using Prometheus;
using StackExchange.Redis; using StackExchange.Redis;
@@ -109,9 +114,12 @@ public class Startup
services.AddHostedService(provider => provider.GetService<CharaDataCleanupService>()); services.AddHostedService(provider => provider.GetService<CharaDataCleanupService>());
services.AddHostedService<ClientPairPermissionsCleanupService>(); services.AddHostedService<ClientPairPermissionsCleanupService>();
services.AddScoped<PairService>(); services.AddScoped<PairService>();
services.AddScoped<IPruneService, PruneService>();
} }
services.AddSingleton<GPoseLobbyDistributionService>(); services.AddSingleton<GPoseLobbyDistributionService>();
services.AddHostedService<AutoPruneWorker>();
services.AddHostedService(provider => provider.GetService<GPoseLobbyDistributionService>()); services.AddHostedService(provider => provider.GetService<GPoseLobbyDistributionService>());
} }
@@ -120,6 +128,8 @@ public class Startup
services.AddSingleton<IUserIdProvider, IdBasedUserIdProvider>(); services.AddSingleton<IUserIdProvider, IdBasedUserIdProvider>();
services.AddSingleton<ConcurrencyFilter>(); services.AddSingleton<ConcurrencyFilter>();
var msgpackOptions = MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4Block).WithResolver(ContractlessStandardResolver.Instance);
var signalRServiceBuilder = services.AddSignalR(hubOptions => var signalRServiceBuilder = services.AddSignalR(hubOptions =>
{ {
hubOptions.MaximumReceiveMessageSize = long.MaxValue; hubOptions.MaximumReceiveMessageSize = long.MaxValue;
@@ -131,21 +141,10 @@ public class Startup
hubOptions.AddFilter<ConcurrencyFilter>(); hubOptions.AddFilter<ConcurrencyFilter>();
}).AddMessagePackProtocol(opt => }).AddMessagePackProtocol(opt =>
{ {
var resolver = CompositeResolver.Create(StandardResolverAllowPrivate.Instance, opt.SerializerOptions = msgpackOptions;
BuiltinResolver.Instance,
AttributeFormatterResolver.Instance,
// replace enum resolver
DynamicEnumAsStringResolver.Instance,
DynamicGenericResolver.Instance,
DynamicUnionResolver.Instance,
DynamicObjectResolver.Instance,
PrimitiveObjectResolver.Instance,
// final fallback(last priority)
StandardResolver.Instance);
opt.SerializerOptions = MessagePackSerializerOptions.Standard var dummy = new GroupPruneSettingsDto(new GroupData("TEST-GID", null), true, 14);
.WithCompression(MessagePackCompression.Lz4Block) MessagePackSerializer.Serialize(dummy, msgpackOptions);
.WithResolver(resolver);
}); });
@@ -166,7 +165,7 @@ public class Startup
KeyPrefix = "", KeyPrefix = "",
Hosts = new RedisHost[] Hosts = new RedisHost[]
{ {
new RedisHost(){ Host = address, Port = port }, new(){ Host = address, Port = port },
}, },
AllowAdmin = true, AllowAdmin = true,
ConnectTimeout = options.ConnectTimeout, ConnectTimeout = options.ConnectTimeout,
@@ -292,11 +291,12 @@ public class Startup
MetricsAPI.CounterUserPairCacheMiss, MetricsAPI.CounterUserPairCacheMiss,
MetricsAPI.CounterUserPairCacheNewEntries, MetricsAPI.CounterUserPairCacheNewEntries,
MetricsAPI.CounterUserPairCacheUpdatedEntries, MetricsAPI.CounterUserPairCacheUpdatedEntries,
}, new List<string> },
{ [
MetricsAPI.GaugeAuthorizedConnections, MetricsAPI.GaugeAuthorizedConnections,
MetricsAPI.GaugeLightFinderConnections, MetricsAPI.GaugeLightFinderConnections,
MetricsAPI.GaugeLightFinderGroups, MetricsAPI.GaugeLightFinderGroups,
MetricsAPI.GaugeGroupAutoPrunesEnabled,
MetricsAPI.GaugeConnections, MetricsAPI.GaugeConnections,
MetricsAPI.GaugePairs, MetricsAPI.GaugePairs,
MetricsAPI.GaugePairsPaused, MetricsAPI.GaugePairsPaused,
@@ -312,7 +312,7 @@ public class Startup
MetricsAPI.GaugeGposeLobbyUsers, MetricsAPI.GaugeGposeLobbyUsers,
MetricsAPI.GaugeHubConcurrency, MetricsAPI.GaugeHubConcurrency,
MetricsAPI.GaugeHubQueuedConcurrency, MetricsAPI.GaugeHubQueuedConcurrency,
})); ]));
} }
private static void ConfigureServicesBasedOnShardType(IServiceCollection services, IConfigurationSection lightlessConfig, bool isMainServer) private static void ConfigureServicesBasedOnShardType(IServiceCollection services, IConfigurationSection lightlessConfig, bool isMainServer)

View File

@@ -22,6 +22,7 @@ public static class Extensions
if (dto.Tags != null) profile.Tags = dto.Tags; if (dto.Tags != null) profile.Tags = dto.Tags;
if (dto.Description != null) profile.Description = dto.Description; if (dto.Description != null) profile.Description = dto.Description;
if (dto.IsNsfw.HasValue) profile.IsNSFW = dto.IsNsfw.Value; 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) public static void UpdateProfileFromDto(this UserProfileData profile, UserProfileDto dto, string? base64PictureString = null, string? base64BannerString = null)

View File

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

View File

@@ -7,6 +7,7 @@
"Default": "Information", "Default": "Information",
"Microsoft": "Warning", "Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information", "Microsoft.Hosting.Lifetime": "Information",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
"LightlessSyncServer.Authentication": "Warning", "LightlessSyncServer.Authentication": "Warning",
"System.IO.IOException": "Warning" "System.IO.IOException": "Warning"
}, },

View File

@@ -94,7 +94,7 @@ public partial class LightlessWizardModule
bool canAddVanityId = !db.Users.Any(u => u.UID == modal.DesiredVanityUID || u.Alias == modal.DesiredVanityUID); bool canAddVanityId = !db.Users.Any(u => u.UID == modal.DesiredVanityUID || u.Alias == modal.DesiredVanityUID);
var forbiddenWords = new[] { "null", "nil" }; var forbiddenWords = new[] { "null", "nil" };
Regex rgx = new(@"^[_\-a-zA-Z0-9]{3,15}$", RegexOptions.ECMAScript); Regex rgx = new(@"^[_\-a-zA-Z0-9\?]{3,15}$", RegexOptions.ECMAScript);
if (!rgx.Match(desiredVanityUid).Success) if (!rgx.Match(desiredVanityUid).Success)
{ {
eb.WithColor(Color.Red); eb.WithColor(Color.Red);

View File

@@ -11,6 +11,7 @@ public class MetricsAPI
public const string CounterUsersRegisteredDeleted = "lightless_users_registered_deleted"; public const string CounterUsersRegisteredDeleted = "lightless_users_registered_deleted";
public const string GaugeLightFinderConnections = "lightless_lightfinder_connections"; public const string GaugeLightFinderConnections = "lightless_lightfinder_connections";
public const string GaugeLightFinderGroups = "lightless_lightfinder_groups"; public const string GaugeLightFinderGroups = "lightless_lightfinder_groups";
public const string GaugeGroupAutoPrunesEnabled = "lightless_group_autoprunes_enabled";
public const string GaugePairs = "lightless_pairs"; public const string GaugePairs = "lightless_pairs";
public const string GaugePairsPaused = "lightless_pairs_paused"; public const string GaugePairsPaused = "lightless_pairs_paused";
public const string GaugeFilesTotal = "lightless_files"; public const string GaugeFilesTotal = "lightless_files";

View File

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

View File

@@ -430,6 +430,14 @@ namespace LightlessSyncServer.Migrations
.HasColumnType("character varying(50)") .HasColumnType("character varying(50)")
.HasColumnName("alias"); .HasColumnName("alias");
b.Property<int>("AutoPruneDays")
.HasColumnType("integer")
.HasColumnName("auto_prune_days");
b.Property<bool>("AutoPruneEnabled")
.HasColumnType("boolean")
.HasColumnName("auto_prune_enabled");
b.Property<DateTime>("CreatedDate") b.Property<DateTime>("CreatedDate")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")

View File

@@ -12,6 +12,8 @@ public class Group
[MaxLength(50)] [MaxLength(50)]
public string Alias { get; set; } public string Alias { get; set; }
public GroupProfile? Profile { 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 bool InvitesEnabled { get; set; }
public string HashedPassword { get; set; } public string HashedPassword { get; set; }
public bool PreferDisableSounds { get; set; } public bool PreferDisableSounds { get; set; }