Compare commits

..

19 Commits

Author SHA1 Message Date
cake
6294b1d496 Reduced report chat messages to 1 min of polling 2025-12-16 07:11:35 +01:00
cake
143037165a Reduced amount of chats, commented out the pending report. 2025-12-16 07:06:17 +01:00
c731b265ff 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>
2025-12-09 05:37:42 +01:00
cake
5feb91ed42 updated api 2025-12-09 05:32:32 +01:00
cake
918c5e7d5d Added disabled in the group profile dto. 2025-12-07 21:53:01 +01:00
cake
7748fa6eac Back to an hourly action 2025-12-07 02:21:48 +01:00
cake
16c55d634d Set minutes to 1 for testing. 2025-12-07 02:18:34 +01:00
cake
7c66d4c9cf Update lightless api 2025-12-06 23:39:31 +01:00
cake
e21f9c36a4 Revert DTO, Changed messagepackprotocol again. 2025-12-06 23:39:24 +01:00
cake
c9d3cb0d50 Updated submodule 2025-12-06 22:58:36 +01:00
cake
2dc7681904 Changed resolver a bit for messagepack 2025-12-06 21:47:57 +01:00
cake
fb6ff4fb0e Update 2025-12-06 20:16:09 +01:00
cake
903429e148 Merge branch 'auto-prune-syncshell' of https://git.lightless-sync.org/Lightless-Sync/LightlessServer into auto-prune-syncshell 2025-12-06 18:29:59 +01:00
cake
d7e5117e6b Update 2025-12-06 18:02:45 +01:00
cake
727f27c2d1 Fixed some problems. 2025-12-06 18:02:39 +01:00
3218e800d6 Merge branch 'master' into auto-prune-syncshell 2025-12-06 03:16:40 +01:00
cake
7e7a5808f4 Update submodule. 2025-12-05 22:01:58 +01:00
cake
091bfbbc29 Auto-pruning of syncshell, added metrics for pruning, return of count of users in fullgroupdto. 2025-12-05 22:01:48 +01:00
00bcbbf8f4 Western Canadians will now filter to NA-East where servers are located in Canada.
Co-authored-by: defnotken <itsdefnotken@gmail.com>
Reviewed-on: #35
2025-11-20 19:33:53 +01:00
34 changed files with 1739 additions and 180 deletions

View File

@@ -44,12 +44,14 @@ public class GeoIPService : IHostedService
if (_dbReader!.TryCity(ip, out var response)) if (_dbReader!.TryCity(ip, out var response))
{ {
string? continent = response?.Continent.Code; string? continent = response?.Continent.Code;
string? countryIso = response?.Country.IsoCode;
if (!string.IsNullOrEmpty(continent) && if (!string.IsNullOrEmpty(continent) &&
string.Equals(continent, "NA", StringComparison.Ordinal) string.Equals(continent, "NA", StringComparison.Ordinal)
&& response?.Location.Longitude != null) && response?.Location.Longitude != null)
{ {
if (response.Location.Longitude < -102) if (response.Location.Longitude < -102 &&
!string.Equals(countryIso, "CA", StringComparison.OrdinalIgnoreCase))
{ {
continent = "NA-W"; continent = "NA-W";
} }
@@ -57,6 +59,8 @@ public class GeoIPService : IHostedService
{ {
continent = "NA-E"; continent = "NA-E";
} }
_logger.LogDebug("Connecting {countryIso} to {continent}", countryIso, continent);
} }
return continent ?? "*"; return continent ?? "*";

View File

@@ -1,13 +1,9 @@
using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json; using System.Text.Json;
using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto.Chat; using LightlessSync.API.Dto.Chat;
using LightlessSync.API.Dto.User; using LightlessSync.API.Dto.User;
using LightlessSyncServer.Models; using LightlessSyncServer.Models;
using LightlessSyncServer.Services;
using LightlessSyncServer.Utils; using LightlessSyncServer.Utils;
using LightlessSyncShared.Models; using LightlessSyncShared.Models;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -18,7 +14,7 @@ namespace LightlessSyncServer.Hubs;
public partial class LightlessHub public partial class LightlessHub
{ {
private const int MaxChatMessageLength = 400; private const int MaxChatMessageLength = 200;
private const int ChatRateLimitMessages = 7; private const int ChatRateLimitMessages = 7;
private static readonly TimeSpan ChatRateLimitWindow = TimeSpan.FromMinutes(1); private static readonly TimeSpan ChatRateLimitWindow = TimeSpan.FromMinutes(1);
private static readonly ConcurrentDictionary<string, ChatRateLimitState> ChatRateLimiters = new(StringComparer.Ordinal); private static readonly ConcurrentDictionary<string, ChatRateLimitState> ChatRateLimiters = new(StringComparer.Ordinal);
@@ -67,7 +63,7 @@ public partial class LightlessHub
descriptor, descriptor,
displayName, displayName,
g.GID, g.GID,
g.OwnerUID == userUid); string.Equals(g.OwnerUID, userUid, StringComparison.Ordinal));
}) })
.OrderBy(info => info.DisplayName, StringComparer.OrdinalIgnoreCase) .OrderBy(info => info.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
@@ -406,7 +402,7 @@ public partial class LightlessHub
return; return;
} }
if (!string.IsNullOrEmpty(messageEntry.SenderUserUid)) /* if (!string.IsNullOrEmpty(messageEntry.SenderUserUid))
{ {
var targetAlreadyPending = await DbContext.ReportedChatMessages var targetAlreadyPending = await DbContext.ReportedChatMessages
.AsNoTracking() .AsNoTracking()
@@ -418,7 +414,7 @@ public partial class LightlessHub
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "This user already has a report pending review.").ConfigureAwait(false); await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "This user already has a report pending review.").ConfigureAwait(false);
return; return;
} }
} } */
var snapshotEntries = _chatChannelService.GetRecentMessages(messageEntry.Channel, 25); var snapshotEntries = _chatChannelService.GetRecentMessages(messageEntry.Channel, 25);
var snapshotItems = snapshotEntries var snapshotItems = snapshotEntries

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")]
@@ -839,8 +852,9 @@ public partial class LightlessHub
{ {
GroupGID = dto.Group.GID, GroupGID = dto.Group.GID,
Group = group, Group = group,
ProfileDisabled = false, ProfileDisabled = dto.IsDisabled ?? false,
IsNSFW = dto.IsNsfw ?? false, IsNSFW = dto.IsNsfw ?? false,
}; };
groupProfileDb.UpdateProfileFromDto(dto, sanitizedProfileImage, sanitizedBannerImage); groupProfileDb.UpdateProfileFromDto(dto, sanitizedProfileImage, sanitizedBannerImage);
@@ -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()
@@ -944,7 +998,6 @@ public partial class LightlessHub
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( return new GroupFullInfoDto(
@@ -953,7 +1006,8 @@ public partial class LightlessHub
r.GroupPair.Group.ToEnum(), r.GroupPair.Group.ToEnum(),
r.PreferredPermission.ToEnum(), r.PreferredPermission.ToEnum(),
r.GroupPair.ToEnum(), r.GroupPair.ToEnum(),
groupInfoDict groupInfoDict,
r.UserCount
); );
}),]; }),];
return List; return List;
@@ -995,11 +1049,11 @@ public partial class LightlessHub
return false; return false;
} }
var (isOwner, _) = await TryValidateGroupModeratorOrOwner(dto.GID).ConfigureAwait(false); var (isOwner, _) = await TryValidateOwner(dto.GID).ConfigureAwait(false);
if (!isOwner) if (!isOwner)
{ {
_logger.LogCallWarning(LightlessHubLogger.Args("Unauthorized syncshell broadcast change", "User", UserUID, "GID", dto.GID)); _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 or moderator of the syncshell to broadcast it."); await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "You must be the owner of the syncshell to broadcast it.");
return false; return false;
} }

View File

@@ -13,6 +13,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using StackExchange.Redis; using StackExchange.Redis;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;

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

@@ -21,7 +21,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" /> <PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />
<PackageReference Include="Blake3" Version="2.0.0" />
<PackageReference Include="IDisposableAnalyzers" Version="4.0.8"> <PackageReference Include="IDisposableAnalyzers" Version="4.0.8">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

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

@@ -162,7 +162,7 @@ internal class DiscordBot : IHostedService
try try
{ {
await Task.Delay(TimeSpan.FromMinutes(10), token).ConfigureAwait(false); await Task.Delay(TimeSpan.FromMinutes(1), token).ConfigureAwait(false);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {

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

View File

@@ -1,5 +1,4 @@
using Blake3; using K4os.Compression.LZ4.Legacy;
using K4os.Compression.LZ4.Legacy;
using LightlessSync.API.Dto.Files; using LightlessSync.API.Dto.Files;
using LightlessSync.API.Routes; using LightlessSync.API.Routes;
using LightlessSync.API.SignalR; using LightlessSync.API.SignalR;
@@ -209,14 +208,11 @@ public class ServerFilesController : ControllerBase
[RequestSizeLimit(200 * 1024 * 1024)] [RequestSizeLimit(200 * 1024 * 1024)]
public async Task<IActionResult> UploadFile(string hash, CancellationToken requestAborted) public async Task<IActionResult> UploadFile(string hash, CancellationToken requestAborted)
{ {
await using var dbContext = await _lightlessDbContext.CreateDbContextAsync(); using var dbContext = await _lightlessDbContext.CreateDbContextAsync();
_logger.LogInformation("{user}|{file}: Uploading", LightlessUser, hash); _logger.LogInformation("{user}|{file}: Uploading", LightlessUser, hash);
if (hash.Length == 40)
{
hash = hash.ToUpperInvariant();
}
hash = hash.ToUpperInvariant();
var existingFile = await dbContext.Files.SingleOrDefaultAsync(f => f.Hash == hash); var existingFile = await dbContext.Files.SingleOrDefaultAsync(f => f.Hash == hash);
if (existingFile != null) return Ok(); if (existingFile != null) return Ok();
@@ -267,14 +263,10 @@ public class ServerFilesController : ControllerBase
[RequestSizeLimit(200 * 1024 * 1024)] [RequestSizeLimit(200 * 1024 * 1024)]
public async Task<IActionResult> UploadFileMunged(string hash, CancellationToken requestAborted) public async Task<IActionResult> UploadFileMunged(string hash, CancellationToken requestAborted)
{ {
await using var dbContext = await _lightlessDbContext.CreateDbContextAsync(); using var dbContext = await _lightlessDbContext.CreateDbContextAsync();
_logger.LogInformation("{user}|{file}: Uploading munged", LightlessUser, hash); _logger.LogInformation("{user}|{file}: Uploading munged", LightlessUser, hash);
if (hash.Length == 40)
{
hash = hash.ToUpperInvariant(); hash = hash.ToUpperInvariant();
}
var existingFile = await dbContext.Files.SingleOrDefaultAsync(f => f.Hash == hash); var existingFile = await dbContext.Files.SingleOrDefaultAsync(f => f.Hash == hash);
if (existingFile != null) return Ok(); if (existingFile != null) return Ok();
@@ -327,26 +319,20 @@ public class ServerFilesController : ControllerBase
private async Task StoreData(string hash, LightlessDbContext dbContext, MemoryStream compressedFileStream) private async Task StoreData(string hash, LightlessDbContext dbContext, MemoryStream compressedFileStream)
{ {
var decompressedData = LZ4Wrapper.Unwrap(compressedFileStream.ToArray()); var decompressedData = LZ4Wrapper.Unwrap(compressedFileStream.ToArray());
// reset streams
compressedFileStream.Seek(0, SeekOrigin.Begin); compressedFileStream.Seek(0, SeekOrigin.Begin);
bool valid; // compute hash to verify
var hashString = BitConverter.ToString(SHA1.HashData(decompressedData))
.Replace("-", "", StringComparison.Ordinal).ToUpperInvariant();
if (!string.Equals(hashString, hash, StringComparison.Ordinal))
throw new InvalidOperationException($"{LightlessUser}|{hash}: Hash does not match file, computed: {hashString}, expected: {hash}");
if (hash.Length == 40) // save file
{ var path = FilePathUtil.GetFilePath(_basePath, hash);
var sha1Hex = Convert.ToHexString(SHA1.HashData(decompressedData)); using var fileStream = new FileStream(path, FileMode.Create);
valid = string.Equals(sha1Hex, hash, StringComparison.OrdinalIgnoreCase); await compressedFileStream.CopyToAsync(fileStream).ConfigureAwait(false);
} _logger.LogDebug("{user}|{file}: Uploaded file saved to {path}", LightlessUser, hash, path);
else
{
var blakeHash = Hasher.Hash(decompressedData);
var blakeHex = Convert.ToHexString(blakeHash.AsSpan());
valid = string.Equals(blakeHex, hash, StringComparison.OrdinalIgnoreCase);
}
if (!valid)
throw new InvalidOperationException(
$"{LightlessUser}|{hash}: Hash does not match file, computed mismatch."
);
// update on db // update on db
await dbContext.Files.AddAsync(new FileCache() await dbContext.Files.AddAsync(new FileCache()

View File

@@ -18,7 +18,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Blake3" Version="2.0.0" />
<PackageReference Include="IDisposableAnalyzers" Version="4.0.8"> <PackageReference Include="IDisposableAnalyzers" Version="4.0.8">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>