🗃️ Add nonce column to chat messages and fix column typo

This migration adds a new "nonce" column to the "chat_messages" table to ensure message uniqueness or integrity. Additionally, it corrects a typo in the "members_mentioned" column name to improve consistency and clarity.
This commit is contained in:
LittleSheep 2025-05-03 13:16:18 +08:00
parent f6acb3f2f0
commit 196547e50f
8 changed files with 4927 additions and 21 deletions

View File

@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -19,17 +20,75 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller
public class SendMessageRequest public class SendMessageRequest
{ {
[MaxLength(4096)] public string? Content { get; set; } [MaxLength(4096)] public string? Content { get; set; }
public List<CloudFile>? Attachments { get; set; } [MaxLength(36)] public string? Nonce { get; set; }
public List<string>? AttachmentsId { get; set; }
public Dictionary<string, object>? Meta { get; set; }
}
[HttpGet("{roomId:long}/members/me")]
[Authorize]
public async Task<ActionResult<ChatMember>> GetCurrentIdentity(long roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
var member = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId)
.Include(m => m.Account)
.FirstOrDefaultAsync();
if (member == null)
return NotFound();
return Ok(member);
}
[HttpGet("{roomId:long}/messages")]
public async Task<ActionResult<List<Message>>> ListMessages(long roomId, [FromQuery] int offset, [FromQuery] int take = 20)
{
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.Normal)
return StatusCode(403, "You are not a member of this chat room.");
}
var totalCount = await db.ChatMessages
.Where(m => m.ChatRoomId == roomId)
.CountAsync();
var messages = await db.ChatMessages
.Where(m => m.ChatRoomId == roomId)
.OrderByDescending(m => m.CreatedAt)
.Include(m => m.Sender)
.Include(m => m.Sender.Account)
.Include(m => m.Attachments)
.Skip(offset)
.Take(take)
.ToListAsync();
Response.Headers["X-Total"] = totalCount.ToString();
return Ok(messages);
} }
[GeneratedRegex(@"@([A-Za-z0-9_-]+)")] [GeneratedRegex(@"@([A-Za-z0-9_-]+)")]
private static partial Regex MentionRegex(); private static partial Regex MentionRegex();
[HttpPost("{roomId:long}/messages")] [HttpPost("{roomId:long}/messages")]
[Authorize]
public async Task<ActionResult> SendMessage([FromBody] SendMessageRequest request, long roomId) public async Task<ActionResult> SendMessage([FromBody] SendMessageRequest request, long 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.Attachments == null || request.Attachments.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
@ -38,16 +97,27 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller
.Include(m => m.ChatRoom.Realm) .Include(m => m.ChatRoom.Realm)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Normal) return StatusCode(403, "You need to be a normal member to send messages here."); if (member == null || member.Role < ChatMemberRole.Normal) return StatusCode(403, "You need to be a normal member to send messages here.");
currentUser.Profile = null;
member.Account = currentUser;
var message = new Message var message = new Message
{ {
SenderId = member.Id, SenderId = member.Id,
ChatRoomId = roomId, ChatRoomId = roomId,
Nonce = request.Nonce ?? Guid.NewGuid().ToString(),
Meta = request.Meta ?? new Dictionary<string, object>(),
}; };
if (request.Content is not null) if (request.Content is not null)
message.Content = request.Content; message.Content = request.Content;
if (request.Attachments is not null) if (request.AttachmentsId is not null)
message.Attachments = request.Attachments; {
var attachments = await db.Files
.Where(f => request.AttachmentsId.Contains(f.Id))
.ToListAsync();
message.Attachments = attachments
.OrderBy(f => request.AttachmentsId.IndexOf(f.Id))
.ToList();
}
if (request.Content is not null) if (request.Content is not null)
{ {
@ -55,17 +125,16 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller
.Matches(request.Content) .Matches(request.Content)
.Select(m => m.Groups[1].Value) .Select(m => m.Groups[1].Value)
.ToList(); .ToList();
if (mentioned is not null && 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.MembersMetioned = mentionedMembers; message.MembersMentioned = mentionedMembers;
} }
} }
member.Account = currentUser;
var result = await cs.SendMessageAsync(message, member, member.ChatRoom); var result = await cs.SendMessageAsync(message, member, member.ChatRoom);
return Ok(result); return Ok(result);
@ -78,9 +147,9 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller
} }
[HttpGet("{roomId:long}/sync")] [HttpGet("{roomId:long}/sync")]
public async Task<ActionResult<SyncResponse>> GetSyncData([FromQuery] SyncRequest request, long roomId) public async Task<ActionResult<SyncResponse>> GetSyncData([FromBody] SyncRequest request, long roomId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized(); return Unauthorized();
var isMember = await db.ChatMembers var isMember = await db.ChatMembers

View File

@ -23,7 +23,7 @@ public class ChatService(AppDatabase db, NotificationService nty, WebSocketServi
var member in db.ChatMembers var member in db.ChatMembers
.Where(m => m.ChatRoomId == message.ChatRoomId && m.AccountId != message.Sender.AccountId) .Where(m => m.ChatRoomId == message.ChatRoomId && m.AccountId != message.Sender.AccountId)
.Where(m => m.Notify != ChatMemberNotify.None) .Where(m => m.Notify != ChatMemberNotify.None)
.Where(m => m.Notify != ChatMemberNotify.Mentions || (message.MembersMetioned != null && message.MembersMetioned.Contains(m.Id))) .Where(m => m.Notify != ChatMemberNotify.Mentions || (message.MembersMentioned != null && message.MembersMentioned.Contains(m.Id)))
.AsAsyncEnumerable() .AsAsyncEnumerable()
) )
{ {
@ -94,7 +94,7 @@ public class ChatService(AppDatabase db, NotificationService nty, WebSocketServi
.Select(m => new MessageChange .Select(m => new MessageChange
{ {
MessageId = m.Id, MessageId = m.Id,
Action = m.DeletedAt != null ? "delete" : (m.EditedAt == null ? "create" : "update"), Action = m.DeletedAt != null ? "delete" : (m.UpdatedAt == null ? "create" : "update"),
Message = m.DeletedAt != null ? null : m, Message = m.DeletedAt != null ? null : m,
Timestamp = m.DeletedAt != null ? m.DeletedAt.Value : m.UpdatedAt Timestamp = m.DeletedAt != null ? m.DeletedAt.Value : m.UpdatedAt
}) })

View File

@ -9,10 +9,10 @@ namespace DysonNetwork.Sphere.Chat;
public class Message : ModelBase public class Message : ModelBase
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Type { get; set; } = null!;
[MaxLength(4096)] public string Content { get; set; } = string.Empty; [MaxLength(4096)] public string Content { get; set; } = string.Empty;
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; } [Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; }
[Column(TypeName = "jsonb")] public List<Guid>? MembersMetioned { get; set; } [Column(TypeName = "jsonb")] public List<Guid>? MembersMentioned { get; set; }
[MaxLength(36)] public string Nonce { get; set; } = null!;
public Instant? EditedAt { get; set; } public Instant? EditedAt { get; set; }
public ICollection<CloudFile> Attachments { get; set; } = new List<CloudFile>(); public ICollection<CloudFile> Attachments { get; set; } = new List<CloudFile>();
@ -28,6 +28,33 @@ public class Message : ModelBase
public ChatMember Sender { get; set; } = null!; public ChatMember Sender { get; set; } = null!;
public long ChatRoomId { get; set; } public long ChatRoomId { get; set; }
[JsonIgnore] public ChatRoom ChatRoom { get; set; } = null!; [JsonIgnore] public ChatRoom ChatRoom { get; set; } = null!;
public Message Clone()
{
return new Message
{
Id = Id,
Content = Content,
Meta = Meta?.ToDictionary(entry => entry.Key, entry => entry.Value),
MembersMentioned = MembersMentioned?.ToList(),
Nonce = Nonce,
EditedAt = EditedAt,
Attachments = new List<CloudFile>(Attachments),
Reactions = new List<MessageReaction>(Reactions),
Statuses = new List<MessageStatus>(Statuses),
RepliedMessageId = RepliedMessageId,
RepliedMessage = RepliedMessage?.Clone() as Message,
ForwardedMessageId = ForwardedMessageId,
ForwardedMessage = ForwardedMessage?.Clone() as Message,
SenderId = SenderId,
Sender = Sender,
ChatRoomId = ChatRoomId,
ChatRoom = ChatRoom,
CreatedAt = CreatedAt,
UpdatedAt = UpdatedAt,
DeletedAt = DeletedAt
};
}
} }
public enum MessageReactionAttitude public enum MessageReactionAttitude

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class AddChatMessageNonce : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "members_metioned",
table: "chat_messages",
newName: "members_mentioned");
migrationBuilder.AddColumn<string>(
name: "nonce",
table: "chat_messages",
type: "text",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "nonce",
table: "chat_messages");
migrationBuilder.RenameColumn(
name: "members_mentioned",
table: "chat_messages",
newName: "members_metioned");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class DontKnowHowToNameThing2 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "type",
table: "chat_messages");
migrationBuilder.AlterColumn<string>(
name: "nonce",
table: "chat_messages",
type: "character varying(36)",
maxLength: 36,
nullable: false,
oldClrType: typeof(string),
oldType: "text");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "nonce",
table: "chat_messages",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(36)",
oldMaxLength: 36);
migrationBuilder.AddColumn<string>(
name: "type",
table: "chat_messages",
type: "character varying(1024)",
maxLength: 1024,
nullable: false,
defaultValue: "");
}
}
}

View File

@ -806,14 +806,20 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("forwarded_message_id"); .HasColumnName("forwarded_message_id");
b.Property<List<Guid>>("MembersMetioned") b.Property<List<Guid>>("MembersMentioned")
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("members_metioned"); .HasColumnName("members_mentioned");
b.Property<Dictionary<string, object>>("Meta") b.Property<Dictionary<string, object>>("Meta")
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("meta"); .HasColumnName("meta");
b.Property<string>("Nonce")
.IsRequired()
.HasMaxLength(36)
.HasColumnType("character varying(36)")
.HasColumnName("nonce");
b.Property<Guid?>("RepliedMessageId") b.Property<Guid?>("RepliedMessageId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("replied_message_id"); .HasColumnName("replied_message_id");
@ -822,12 +828,6 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("sender_id"); .HasColumnName("sender_id");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt") b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("updated_at"); .HasColumnName("updated_at");