Message attachments expires at

This commit is contained in:
LittleSheep 2025-05-24 03:21:39 +08:00
parent 2eff4364c9
commit 8da8c4bedd
4 changed files with 111 additions and 78 deletions

View File

@ -5,19 +5,20 @@ using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime;
using SystemClock = NodaTime.SystemClock; using SystemClock = NodaTime.SystemClock;
namespace DysonNetwork.Sphere.Chat; namespace DysonNetwork.Sphere.Chat;
[ApiController] [ApiController]
[Route("/chat")] [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 class MarkMessageReadRequest
{ {
public Guid ChatRoomId { get; set; } public Guid ChatRoomId { get; set; }
} }
public class TypingMessageRequest public class TypingMessageRequest
{ {
public Guid ChatRoomId { get; set; } 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 unreadMessages = await cs.CountUnreadMessageForUser(currentUser.Id);
var lastMessages = await cs.ListLastMessageForUser(currentUser.Id); var lastMessages = await cs.ListLastMessageForUser(currentUser.Id);
var result = unreadMessages.Keys var result = unreadMessages.Keys
.Union(lastMessages.Keys) .Union(lastMessages.Keys)
.ToDictionary( .ToDictionary(
@ -48,10 +49,10 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller
LastMessage = lastMessages.GetValueOrDefault(roomId) LastMessage = lastMessages.GetValueOrDefault(roomId)
} }
); );
return Ok(result); return Ok(result);
} }
public class SendMessageRequest public class SendMessageRequest
{ {
[MaxLength(4096)] public string? Content { get; set; } [MaxLength(4096)] public string? Content { get; set; }
@ -63,7 +64,8 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller
} }
[HttpGet("{roomId:guid}/messages")] [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; var currentUser = HttpContext.Items["CurrentUser"] as Account.Account;
@ -99,26 +101,26 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller
return Ok(messages); return Ok(messages);
} }
[HttpGet("{roomId:guid}/messages/{messageId:guid}")] [HttpGet("{roomId:guid}/messages/{messageId:guid}")]
public async Task<ActionResult<Message>> GetMessage(Guid roomId, Guid messageId) public async Task<ActionResult<Message>> GetMessage(Guid roomId, Guid messageId)
{ {
var currentUser = HttpContext.Items["CurrentUser"] as Account.Account; var currentUser = HttpContext.Items["CurrentUser"] as Account.Account;
var room = await db.ChatRooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.ChatRooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); if (room is null) return NotFound();
if (!room.IsPublic) if (!room.IsPublic)
{ {
if (currentUser is null) return Unauthorized(); if (currentUser is null) return Unauthorized();
var member = await db.ChatMembers var member = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId) .Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member) if (member == null || member.Role < ChatMemberRole.Member)
return StatusCode(403, "You are not a member of this chat room."); return StatusCode(403, "You are not a member of this chat room.");
} }
var message = await db.ChatMessages var message = await db.ChatMessages
.Where(m => m.Id == messageId && m.ChatRoomId == roomId) .Where(m => m.Id == messageId && m.ChatRoomId == roomId)
.Include(m => m.Sender) .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.Sender.Account.Profile)
.Include(m => m.Attachments) .Include(m => m.Attachments)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (message is null) return NotFound(); if (message is null) return NotFound();
return Ok(message); return Ok(message);
} }
[GeneratedRegex("@([A-Za-z0-9_-]+)")] [GeneratedRegex("@([A-Za-z0-9_-]+)")]
private static partial Regex MentionRegex(); 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) public async Task<ActionResult> SendMessage([FromBody] SendMessageRequest request, Guid roomId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); 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."); return BadRequest("You cannot send an empty message.");
var member = await db.ChatMembers 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)
.Include(m => m.Account.Profile) .Include(m => m.Account.Profile)
.FirstOrDefaultAsync(); .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 var message = new Message
{ {
@ -174,24 +177,24 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller
.OrderBy(f => request.AttachmentsId.IndexOf(f.Id)) .OrderBy(f => request.AttachmentsId.IndexOf(f.Id))
.ToList(); .ToList();
} }
if (request.RepliedMessageId.HasValue) if (request.RepliedMessageId.HasValue)
{ {
var repliedMessage = await db.ChatMessages var repliedMessage = await db.ChatMessages
.FirstOrDefaultAsync(m => m.Id == request.RepliedMessageId.Value && m.ChatRoomId == roomId); .FirstOrDefaultAsync(m => m.Id == request.RepliedMessageId.Value && m.ChatRoomId == roomId);
if (repliedMessage == null) if (repliedMessage == null)
return BadRequest("The message you're replying to does not exist."); return BadRequest("The message you're replying to does not exist.");
message.RepliedMessageId = repliedMessage.Id; message.RepliedMessageId = repliedMessage.Id;
} }
if (request.ForwardedMessageId.HasValue) if (request.ForwardedMessageId.HasValue)
{ {
var forwardedMessage = await db.ChatMessages var forwardedMessage = await db.ChatMessages
.FirstOrDefaultAsync(m => m.Id == request.ForwardedMessageId.Value); .FirstOrDefaultAsync(m => m.Id == request.ForwardedMessageId.Value);
if (forwardedMessage == null) if (forwardedMessage == null)
return BadRequest("The message you're forwarding does not exist."); return BadRequest("The message you're forwarding does not exist.");
message.ForwardedMessageId = forwardedMessage.Id; message.ForwardedMessageId = forwardedMessage.Id;
} }
@ -204,9 +207,9 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller
if (mentioned.Count > 0) if (mentioned.Count > 0)
{ {
var mentionedMembers = await db.ChatMembers var mentionedMembers = await db.ChatMembers
.Where(m => mentioned.Contains(m.Account.Name)) .Where(m => mentioned.Contains(m.Account.Name))
.Select(m => m.Id) .Select(m => m.Id)
.ToListAsync(); .ToListAsync();
message.MembersMentioned = mentionedMembers; message.MembersMentioned = mentionedMembers;
} }
} }
@ -215,25 +218,24 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller
return Ok(result); return Ok(result);
} }
[HttpPatch("{roomId:guid}/messages/{messageId:guid}")] [HttpPatch("{roomId:guid}/messages/{messageId:guid}")]
[Authorize] [Authorize]
public async Task<ActionResult> UpdateMessage([FromBody] SendMessageRequest request, Guid roomId, Guid messageId) public async Task<ActionResult> UpdateMessage([FromBody] SendMessageRequest request, Guid roomId, Guid messageId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var message = await db.ChatMessages var message = await db.ChatMessages
.Include(m => m.Sender) .Include(m => m.Sender)
.Include(m => m.Sender.Account) .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); .FirstOrDefaultAsync(m => m.Id == messageId && m.ChatRoomId == roomId);
if (message == null) return NotFound(); if (message == null) return NotFound();
if (message.Sender.AccountId != currentUser.Id) if (message.Sender.AccountId != currentUser.Id)
return StatusCode(403, "You can only edit your own messages."); return StatusCode(403, "You can only edit your own messages.");
if (request.Content is not null) if (request.Content is not null)
message.Content = request.Content; message.Content = request.Content;
if (request.Meta is not null) 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); .FirstOrDefaultAsync(m => m.Id == request.RepliedMessageId.Value && m.ChatRoomId == roomId);
if (repliedMessage == null) if (repliedMessage == null)
return BadRequest("The message you're replying to does not exist."); return BadRequest("The message you're replying to does not exist.");
message.RepliedMessageId = repliedMessage.Id; message.RepliedMessageId = repliedMessage.Id;
} }
if (request.ForwardedMessageId.HasValue) if (request.ForwardedMessageId.HasValue)
{ {
var forwardedMessage = await db.ChatMessages var forwardedMessage = await db.ChatMessages
.FirstOrDefaultAsync(m => m.Id == request.ForwardedMessageId.Value); .FirstOrDefaultAsync(m => m.Id == request.ForwardedMessageId.Value);
if (forwardedMessage == null) if (forwardedMessage == null)
return BadRequest("The message you're forwarding does not exist."); return BadRequest("The message you're forwarding does not exist.");
message.ForwardedMessageId = forwardedMessage.Id; message.ForwardedMessageId = forwardedMessage.Id;
} }
if (request.AttachmentsId is not null) if (request.AttachmentsId is not null)
{ {
var records = await db.Files.Where(f => request.AttachmentsId.Contains(f.Id)).ToListAsync(); message.Attachments = (await fs.DiffAndMarkFilesAsync(request.AttachmentsId, message.Attachments)).current;
await fs.DiffAndSetExpiresAsync(request.AttachmentsId, Duration.FromDays(30), message.Attachments);
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.EditedAt = SystemClock.Instance.GetCurrentInstant(); message.EditedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(message); db.Update(message);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(message); return Ok(message);
} }
[HttpDelete("{roomId:guid}/messages/{messageId:guid}")] [HttpDelete("{roomId:guid}/messages/{messageId:guid}")]
[Authorize] [Authorize]
public async Task<ActionResult> DeleteMessage(Guid roomId, Guid messageId) public async Task<ActionResult> DeleteMessage(Guid roomId, Guid messageId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var message = await db.ChatMessages var message = await db.ChatMessages
.Include(m => m.Sender) .Include(m => m.Sender)
.FirstOrDefaultAsync(m => m.Id == messageId && m.ChatRoomId == roomId); .FirstOrDefaultAsync(m => m.Id == messageId && m.ChatRoomId == roomId);
if (message == null) return NotFound(); if (message == null) return NotFound();
if (message.Sender.AccountId != currentUser.Id) if (message.Sender.AccountId != currentUser.Id)
return StatusCode(403, "You can only delete your own messages."); return StatusCode(403, "You can only delete your own messages.");
db.ChatMessages.Remove(message); db.ChatMessages.Remove(message);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(); return Ok();
} }
public class SyncRequest public class SyncRequest
{ {
[Required] [Required] public long LastSyncTimestamp { get; set; }
public long LastSyncTimestamp { get; set; }
} }
[HttpPost("{roomId:guid}/sync")] [HttpPost("{roomId:guid}/sync")]

View File

@ -18,7 +18,11 @@ public class ChatService(AppDatabase db, FileService fs, IServiceScopeFactory sc
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var files = message.Attachments.Distinct().ToList(); 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 // Then start the delivery process
// Using ConfigureAwait(false) is correct here since we don't need context to flow // Using ConfigureAwait(false) is correct here since we don't need context to flow

View File

@ -111,22 +111,7 @@ public class PostService(AppDatabase db, FileService fs, ActivityService act)
if (attachments is not null) if (attachments is not null)
{ {
var records = await db.Files.Where(e => attachments.Contains(e.Id)).ToListAsync(); post.Attachments = (await fs.DiffAndMarkFilesAsync(attachments, post.Attachments)).current;
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);
} }
if (tags is not null) if (tags is not null)

View File

@ -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) public class CloudFileUnusedRecyclingJob(AppDatabase db, FileService fs, ILogger<CloudFileUnusedRecyclingJob> logger)