diff --git a/Foxnouns.Backend/Database/Models/User.cs b/Foxnouns.Backend/Database/Models/User.cs index 367e293..7eda12d 100644 --- a/Foxnouns.Backend/Database/Models/User.cs +++ b/Foxnouns.Backend/Database/Models/User.cs @@ -30,6 +30,7 @@ public class User : BaseModel public List Members { get; } = []; public List AuthMethods { get; } = []; + public List DataExports { get; } = []; public UserSettings Settings { get; set; } = new(); public required Instant LastActive { get; set; } @@ -53,6 +54,12 @@ public class User : BaseModel [JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] public PreferenceSize Size { get; set; } } + + [NotMapped] + public static readonly Duration DeleteAfter = Duration.FromDays(30); + + [NotMapped] + public static readonly Duration DeleteSuspendedAfter = Duration.FromDays(180); } public enum UserRole diff --git a/Foxnouns.Backend/Jobs/CreateDataExportInvocable.cs b/Foxnouns.Backend/Jobs/CreateDataExportInvocable.cs index 2ce02ae..dea6fcf 100644 --- a/Foxnouns.Backend/Jobs/CreateDataExportInvocable.cs +++ b/Foxnouns.Backend/Jobs/CreateDataExportInvocable.cs @@ -44,6 +44,7 @@ public class CreateDataExportInvocable( .Users.Include(u => u.AuthMethods) .Include(u => u.Flags) .Include(u => u.ProfileFlags) + .AsSplitQuery() .FirstOrDefaultAsync(u => u.Id == Payload.UserId); if (user == null) { diff --git a/Foxnouns.Backend/Services/DataCleanupService.cs b/Foxnouns.Backend/Services/DataCleanupService.cs index a4ca9c8..b89a399 100644 --- a/Foxnouns.Backend/Services/DataCleanupService.cs +++ b/Foxnouns.Backend/Services/DataCleanupService.cs @@ -1,7 +1,10 @@ +using System.Diagnostics; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Extensions; using Microsoft.EntityFrameworkCore; using NodaTime; +using NodaTime.Extensions; namespace Foxnouns.Backend.Services; @@ -16,10 +19,76 @@ public class DataCleanupService( public async Task InvokeAsync(CancellationToken ct = default) { + _logger.Information("Cleaning up expired users"); + await CleanUsersAsync(ct); + _logger.Information("Cleaning up expired data exports"); await CleanExportsAsync(ct); } + private async Task CleanUsersAsync(CancellationToken ct = default) + { + var selfDeleteExpires = clock.GetCurrentInstant() - User.DeleteAfter; + var suspendExpires = clock.GetCurrentInstant() - User.DeleteSuspendedAfter; + var users = await db + .Users.Include(u => u.Members) + .Include(u => u.DataExports) + .Where(u => + u.Deleted + && ( + (u.DeletedBy != null && u.DeletedAt < suspendExpires) + || (u.DeletedBy == null && u.DeletedAt < selfDeleteExpires) + ) + ) + .OrderBy(u => u.Id) + .AsSplitQuery() + .ToListAsync(ct); + if (users.Count == 0) + return; + + _logger.Debug( + "Deleting {Count} users that have been deleted for over 30 days or suspended for over 180 days", + users.Count + ); + + var sw = new Stopwatch(); + + await Task.WhenAll(users.Select(u => CleanUserAsync(u, ct))); + + await db.SaveChangesAsync(ct); + _logger.Information( + "Deleted {Count} users, their members, and their exports in {Time}", + users.Count, + sw.ElapsedDuration() + ); + } + + private Task CleanUserAsync(User user, CancellationToken ct = default) + { + var tasks = new List(); + + if (user.Avatar != null) + tasks.Add(objectStorageService.DeleteUserAvatarAsync(user.Id, user.Avatar, ct)); + + tasks.AddRange( + user.Members.Select(member => + objectStorageService.DeleteMemberAvatarAsync(member.Id, member.Avatar!, ct) + ) + ); + + tasks.AddRange( + user.DataExports.Select(export => + objectStorageService.RemoveObjectAsync( + ExportPath(export.UserId, export.Filename), + ct + ) + ) + ); + + db.Remove(user); + return Task.WhenAll(tasks); + } + private async Task CleanExportsAsync(CancellationToken ct = default) { var minExpiredId = Snowflake.FromInstant(clock.GetCurrentInstant() - DataExport.Expiration); @@ -27,7 +96,7 @@ public class DataCleanupService( if (exports.Count == 0) return; - _logger.Debug("There are {Count} expired exports", exports.Count); + _logger.Debug("Deleting {Count} expired exports", exports.Count); foreach (var export in exports) {