From 8da8c4bedd1a4057bd80e9e4db692c265e10caed Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 24 May 2025 03:21:39 +0800 Subject: [PATCH] :sparkles: Message attachments expires at --- DysonNetwork.Sphere/Chat/ChatController.cs | 108 +++++++++------------ DysonNetwork.Sphere/Chat/ChatService.cs | 6 +- DysonNetwork.Sphere/Post/PostService.cs | 17 +--- DysonNetwork.Sphere/Storage/FileService.cs | 58 +++++++++++ 4 files changed, 111 insertions(+), 78 deletions(-) diff --git a/DysonNetwork.Sphere/Chat/ChatController.cs b/DysonNetwork.Sphere/Chat/ChatController.cs index 79da9ed..13456d2 100644 --- a/DysonNetwork.Sphere/Chat/ChatController.cs +++ b/DysonNetwork.Sphere/Chat/ChatController.cs @@ -5,19 +5,20 @@ using DysonNetwork.Sphere.Storage; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using NodaTime; using SystemClock = NodaTime.SystemClock; namespace DysonNetwork.Sphere.Chat; [ApiController] [Route("/chat")] -public partial class ChatController(AppDatabase db, ChatService cs) : ControllerBase +public partial class ChatController(AppDatabase db, ChatService cs, FileService fs) : ControllerBase { public class MarkMessageReadRequest { public Guid ChatRoomId { get; set; } } - + public class TypingMessageRequest { public Guid ChatRoomId { get; set; } @@ -37,7 +38,7 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller var unreadMessages = await cs.CountUnreadMessageForUser(currentUser.Id); var lastMessages = await cs.ListLastMessageForUser(currentUser.Id); - + var result = unreadMessages.Keys .Union(lastMessages.Keys) .ToDictionary( @@ -48,10 +49,10 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller LastMessage = lastMessages.GetValueOrDefault(roomId) } ); - + return Ok(result); } - + public class SendMessageRequest { [MaxLength(4096)] public string? Content { get; set; } @@ -63,7 +64,8 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller } [HttpGet("{roomId:guid}/messages")] - public async Task>> ListMessages(Guid roomId, [FromQuery] int offset, [FromQuery] int take = 20) + public async Task>> ListMessages(Guid roomId, [FromQuery] int offset, + [FromQuery] int take = 20) { var currentUser = HttpContext.Items["CurrentUser"] as Account.Account; @@ -99,26 +101,26 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller return Ok(messages); } - + [HttpGet("{roomId:guid}/messages/{messageId:guid}")] public async Task> GetMessage(Guid roomId, Guid messageId) { var currentUser = HttpContext.Items["CurrentUser"] as Account.Account; - + var room = await db.ChatRooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); - + if (!room.IsPublic) { if (currentUser is null) return Unauthorized(); - + var member = await db.ChatMembers .Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId) .FirstOrDefaultAsync(); if (member == null || member.Role < ChatMemberRole.Member) return StatusCode(403, "You are not a member of this chat room."); } - + var message = await db.ChatMessages .Where(m => m.Id == messageId && m.ChatRoomId == roomId) .Include(m => m.Sender) @@ -126,13 +128,12 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller .Include(m => m.Sender.Account.Profile) .Include(m => m.Attachments) .FirstOrDefaultAsync(); - + if (message is null) return NotFound(); - + return Ok(message); } - - + [GeneratedRegex("@([A-Za-z0-9_-]+)")] private static partial Regex MentionRegex(); @@ -143,7 +144,8 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller public async Task SendMessage([FromBody] SendMessageRequest request, Guid roomId) { if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); - if (string.IsNullOrWhiteSpace(request.Content) && (request.AttachmentsId == null || request.AttachmentsId.Count == 0)) + if (string.IsNullOrWhiteSpace(request.Content) && + (request.AttachmentsId == null || request.AttachmentsId.Count == 0)) return BadRequest("You cannot send an empty message."); var member = await db.ChatMembers @@ -153,7 +155,8 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller .Include(m => m.Account) .Include(m => m.Account.Profile) .FirstOrDefaultAsync(); - if (member == null || member.Role < ChatMemberRole.Member) return StatusCode(403, "You need to be a normal member to send messages here."); + if (member == null || member.Role < ChatMemberRole.Member) + return StatusCode(403, "You need to be a normal member to send messages here."); var message = new Message { @@ -174,24 +177,24 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller .OrderBy(f => request.AttachmentsId.IndexOf(f.Id)) .ToList(); } - + if (request.RepliedMessageId.HasValue) { var repliedMessage = await db.ChatMessages .FirstOrDefaultAsync(m => m.Id == request.RepliedMessageId.Value && m.ChatRoomId == roomId); if (repliedMessage == null) return BadRequest("The message you're replying to does not exist."); - + message.RepliedMessageId = repliedMessage.Id; } - + if (request.ForwardedMessageId.HasValue) { var forwardedMessage = await db.ChatMessages .FirstOrDefaultAsync(m => m.Id == request.ForwardedMessageId.Value); if (forwardedMessage == null) return BadRequest("The message you're forwarding does not exist."); - + message.ForwardedMessageId = forwardedMessage.Id; } @@ -204,9 +207,9 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller if (mentioned.Count > 0) { var mentionedMembers = await db.ChatMembers - .Where(m => mentioned.Contains(m.Account.Name)) - .Select(m => m.Id) - .ToListAsync(); + .Where(m => mentioned.Contains(m.Account.Name)) + .Select(m => m.Id) + .ToListAsync(); message.MembersMentioned = mentionedMembers; } } @@ -215,25 +218,24 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller return Ok(result); } - - + [HttpPatch("{roomId:guid}/messages/{messageId:guid}")] [Authorize] public async Task UpdateMessage([FromBody] SendMessageRequest request, Guid roomId, Guid messageId) { if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); - + var message = await db.ChatMessages .Include(m => m.Sender) .Include(m => m.Sender.Account) - .Include(m => m.Sender.Account.Profile) + .Include(m => m.Sender.Account.Profile).Include(message => message.Attachments) .FirstOrDefaultAsync(m => m.Id == messageId && m.ChatRoomId == roomId); if (message == null) return NotFound(); - + if (message.Sender.AccountId != currentUser.Id) return StatusCode(403, "You can only edit your own messages."); - + if (request.Content is not null) message.Content = request.Content; if (request.Meta is not null) @@ -245,72 +247,56 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller .FirstOrDefaultAsync(m => m.Id == request.RepliedMessageId.Value && m.ChatRoomId == roomId); if (repliedMessage == null) return BadRequest("The message you're replying to does not exist."); - + message.RepliedMessageId = repliedMessage.Id; } - + if (request.ForwardedMessageId.HasValue) { var forwardedMessage = await db.ChatMessages .FirstOrDefaultAsync(m => m.Id == request.ForwardedMessageId.Value); if (forwardedMessage == null) return BadRequest("The message you're forwarding does not exist."); - + message.ForwardedMessageId = forwardedMessage.Id; } - + if (request.AttachmentsId is not null) { - var records = await db.Files.Where(f => request.AttachmentsId.Contains(f.Id)).ToListAsync(); - - var previous = message.Attachments?.ToDictionary(f => f.Id) ?? new Dictionary(); - var current = records.ToDictionary(f => f.Id); - - // Detect added files - var added = current.Keys.Except(previous.Keys).Select(id => current[id]).ToList(); - // Detect removed files - var removed = previous.Keys.Except(current.Keys).Select(id => previous[id]).ToList(); - - // Update attachments - message.Attachments = request.AttachmentsId.Select(id => current[id]).ToList(); - - // Call mark usage - var fs = HttpContext.RequestServices.GetRequiredService(); - await fs.MarkUsageRangeAsync(added, 1); - await fs.MarkUsageRangeAsync(removed, -1); + message.Attachments = (await fs.DiffAndMarkFilesAsync(request.AttachmentsId, message.Attachments)).current; + await fs.DiffAndSetExpiresAsync(request.AttachmentsId, Duration.FromDays(30), message.Attachments); } - + message.EditedAt = SystemClock.Instance.GetCurrentInstant(); db.Update(message); await db.SaveChangesAsync(); - + return Ok(message); } - + [HttpDelete("{roomId:guid}/messages/{messageId:guid}")] [Authorize] public async Task DeleteMessage(Guid roomId, Guid messageId) { if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); - + var message = await db.ChatMessages .Include(m => m.Sender) .FirstOrDefaultAsync(m => m.Id == messageId && m.ChatRoomId == roomId); if (message == null) return NotFound(); - + if (message.Sender.AccountId != currentUser.Id) return StatusCode(403, "You can only delete your own messages."); - + db.ChatMessages.Remove(message); await db.SaveChangesAsync(); - + return Ok(); } - + public class SyncRequest { - [Required] - public long LastSyncTimestamp { get; set; } + [Required] public long LastSyncTimestamp { get; set; } } [HttpPost("{roomId:guid}/sync")] diff --git a/DysonNetwork.Sphere/Chat/ChatService.cs b/DysonNetwork.Sphere/Chat/ChatService.cs index ccf64fa..3cc6390 100644 --- a/DysonNetwork.Sphere/Chat/ChatService.cs +++ b/DysonNetwork.Sphere/Chat/ChatService.cs @@ -18,7 +18,11 @@ public class ChatService(AppDatabase db, FileService fs, IServiceScopeFactory sc await db.SaveChangesAsync(); var files = message.Attachments.Distinct().ToList(); - if (files.Count != 0) await fs.MarkUsageRangeAsync(files, 1); + if (files.Count != 0) + { + await fs.MarkUsageRangeAsync(files, 1); + await fs.SetExpiresRangeAsync(files, Duration.FromDays(30)); + } // Then start the delivery process // Using ConfigureAwait(false) is correct here since we don't need context to flow diff --git a/DysonNetwork.Sphere/Post/PostService.cs b/DysonNetwork.Sphere/Post/PostService.cs index 1845b34..a35198d 100644 --- a/DysonNetwork.Sphere/Post/PostService.cs +++ b/DysonNetwork.Sphere/Post/PostService.cs @@ -111,22 +111,7 @@ public class PostService(AppDatabase db, FileService fs, ActivityService act) if (attachments is not null) { - var records = await db.Files.Where(e => attachments.Contains(e.Id)).ToListAsync(); - - var previous = post.Attachments.ToDictionary(f => f.Id); - var current = records.ToDictionary(f => f.Id); - - // Detect added files - var added = current.Keys.Except(previous.Keys).Select(id => current[id]).ToList(); - // Detect removed files - var removed = previous.Keys.Except(current.Keys).Select(id => previous[id]).ToList(); - - // Update attachments - post.Attachments = attachments.Select(id => current[id]).ToList(); - - // Call mark usage - await fs.MarkUsageRangeAsync(added, 1); - await fs.MarkUsageRangeAsync(removed, -1); + post.Attachments = (await fs.DiffAndMarkFilesAsync(attachments, post.Attachments)).current; } if (tags is not null) diff --git a/DysonNetwork.Sphere/Storage/FileService.cs b/DysonNetwork.Sphere/Storage/FileService.cs index 34545e2..7a3a0fe 100644 --- a/DysonNetwork.Sphere/Storage/FileService.cs +++ b/DysonNetwork.Sphere/Storage/FileService.cs @@ -399,6 +399,64 @@ public class FileService( ) ); } + + + + public async Task SetExpiresRangeAsync(ICollection files, Duration? duration) + { + var ids = files.Select(f => f.Id).ToArray(); + await db.Files.Where(o => ids.Contains(o.Id)) + .ExecuteUpdateAsync(setter => setter.SetProperty( + b => b.ExpiredAt, + duration.HasValue + ? b => SystemClock.Instance.GetCurrentInstant() + duration.Value + : _ => null + ) + ); + } + + public async Task<(ICollection current, ICollection added, ICollection removed)> DiffAndMarkFilesAsync( + ICollection? newFileIds, + ICollection? previousFiles = null + ) + { + if (newFileIds == null) return ([], [], previousFiles ?? []); + + var records = await db.Files.Where(f => newFileIds.Contains(f.Id)).ToListAsync(); + var previous = previousFiles?.ToDictionary(f => f.Id) ?? new Dictionary(); + var current = records.ToDictionary(f => f.Id); + + var added = current.Keys.Except(previous.Keys).Select(id => current[id]).ToList(); + var removed = previous.Keys.Except(current.Keys).Select(id => previous[id]).ToList(); + + if (added.Count > 0) await MarkUsageRangeAsync(added, 1); + if (removed.Count > 0) await MarkUsageRangeAsync(removed, -1); + + return (newFileIds.Select(id => current[id]).ToList(), added, removed); + } + + public async Task<(ICollection current, ICollection added, ICollection removed)> DiffAndSetExpiresAsync( + ICollection? newFileIds, + Duration? duration, + ICollection? previousFiles = null + ) + { + if (newFileIds == null) return ([], [], previousFiles ?? []); + + var records = await db.Files.Where(f => newFileIds.Contains(f.Id)).ToListAsync(); + var previous = previousFiles?.ToDictionary(f => f.Id) ?? new Dictionary(); + var current = records.ToDictionary(f => f.Id); + + var added = current.Keys.Except(previous.Keys).Select(id => current[id]).ToList(); + var removed = previous.Keys.Except(current.Keys).Select(id => previous[id]).ToList(); + + if (added.Count > 0) await SetExpiresRangeAsync(added, duration); + if (removed.Count > 0) await SetExpiresRangeAsync(removed, null); + + return (newFileIds.Select(id => current[id]).ToList(), added, removed); + } + + } public class CloudFileUnusedRecyclingJob(AppDatabase db, FileService fs, ILogger logger)