✨ Message attachments expires at
This commit is contained in:
		| @@ -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<ActionResult<List<Message>>> ListMessages(Guid roomId, [FromQuery] int offset, [FromQuery] int take = 20) | ||||
|     public async Task<ActionResult<List<Message>>> 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<ActionResult<Message>> 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<ActionResult> 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<ActionResult> 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<string, CloudFile>(); | ||||
|             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<Storage.FileService>(); | ||||
|             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<ActionResult> 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")] | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -399,6 +399,64 @@ public class FileService( | ||||
|                 ) | ||||
|             ); | ||||
|     } | ||||
|      | ||||
|      | ||||
|  | ||||
|     public async Task SetExpiresRangeAsync(ICollection<CloudFile> 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<CloudFile> current, ICollection<CloudFile> added, ICollection<CloudFile> removed)> DiffAndMarkFilesAsync( | ||||
|         ICollection<string>? newFileIds, | ||||
|         ICollection<CloudFile>? 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<string, CloudFile>(); | ||||
|         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<CloudFile> current, ICollection<CloudFile> added, ICollection<CloudFile> removed)> DiffAndSetExpiresAsync( | ||||
|         ICollection<string>? newFileIds, | ||||
|         Duration? duration, | ||||
|         ICollection<CloudFile>? 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<string, CloudFile>(); | ||||
|         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<CloudFileUnusedRecyclingJob> logger) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user