Compare commits

...

2 Commits

Author SHA1 Message Date
f6acb3f2f0 🗑️ remove Casbin dependency and related configurations
Remove Casbin package references, configurations, and unused imports across multiple files. This change simplifies the codebase by eliminating unnecessary dependencies and reducing complexity.

 add new chat features and improve message handling

Introduce new chat features including message notifications, nicknames, and improved message handling. Enhance the WebSocket service to support new packet handlers and improve message delivery.

🗃️ add new migrations for chat-related changes

Add new migrations to support the latest chat features, including changes to chat members, messages, and reactions. These migrations ensure the database schema is up-to-date with the latest code changes.
2025-05-03 02:02:16 +08:00
46054dfb7b ♻️ Better way to vectorize quill delta 2025-05-03 00:06:01 +08:00
25 changed files with 5679 additions and 86 deletions

View File

@ -1,8 +1,6 @@
using Casbin;
using DysonNetwork.Sphere.Permission;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using NodaTime;
namespace DysonNetwork.Sphere.Account;

View File

@ -134,7 +134,6 @@ public class AppDatabase(
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Post.Post>()
.HasGeneratedTsVectorColumn(p => p.SearchVector, "simple", p => new { p.Title, p.Description, p.Content })
.HasIndex(p => p.SearchVector)
.HasMethod("GIN");
modelBuilder.Entity<Post.Post>()
@ -193,6 +192,16 @@ public class AppDatabase(
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Chat.MessageStatus>()
.HasKey(e => new { e.MessageId, e.SenderId });
modelBuilder.Entity<Chat.Message>()
.HasOne(m => m.ForwardedMessage)
.WithMany()
.HasForeignKey(m => m.ForwardedMessageId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Chat.Message>()
.HasOne(m => m.RepliedMessage)
.WithMany()
.HasForeignKey(m => m.RepliedMessageId)
.OnDelete(DeleteBehavior.Restrict);
// Automatically apply soft-delete filter to all entities inheriting BaseModel
foreach (var entityType in modelBuilder.Model.GetEntityTypes())

View File

@ -2,8 +2,6 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using Casbin;
using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.Tokens;
using NodaTime;

View File

@ -1,14 +0,0 @@
[request_definition]
r = sub, dom, obj, act
[policy_definition]
p = sub, dom, obj, act
[role_definition]
g = _, _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = regexMatch(r.sub, "^super:") || (g(r.sub, p.sub, r.dom) && r.dom == p.dom && r.obj == p.obj && r.act == p.act)

View File

@ -1,12 +1,94 @@
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Chat;
[ApiController]
[Route("/chat")]
public class ChatController : ControllerBase
public partial class ChatController(AppDatabase db, ChatService cs) : ControllerBase
{
public class MarkMessageReadRequest
{
public Guid MessageId { get; set; }
public long ChatRoomId { get; set; }
}
public class SendMessageRequest
{
[MaxLength(4096)] public string? Content { get; set; }
public List<CloudFile>? Attachments { get; set; }
}
[GeneratedRegex(@"@([A-Za-z0-9_-]+)")]
private static partial Regex MentionRegex();
[HttpPost("{roomId:long}/messages")]
public async Task<ActionResult> SendMessage([FromBody] SendMessageRequest request, long roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Content) && (request.Attachments == null || request.Attachments.Count == 0))
return BadRequest("You cannot send an empty message.");
var member = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId)
.Include(m => m.ChatRoom)
.Include(m => m.ChatRoom.Realm)
.FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Normal) return StatusCode(403, "You need to be a normal member to send messages here.");
var message = new Message
{
SenderId = member.Id,
ChatRoomId = roomId,
};
if (request.Content is not null)
message.Content = request.Content;
if (request.Attachments is not null)
message.Attachments = request.Attachments;
if (request.Content is not null)
{
var mentioned = MentionRegex()
.Matches(request.Content)
.Select(m => m.Groups[1].Value)
.ToList();
if (mentioned is not null && mentioned.Count > 0)
{
var mentionedMembers = await db.ChatMembers
.Where(m => mentioned.Contains(m.Account.Name))
.Select(m => m.Id)
.ToListAsync();
message.MembersMetioned = mentionedMembers;
}
}
member.Account = currentUser;
var result = await cs.SendMessageAsync(message, member, member.ChatRoom);
return Ok(result);
}
public class SyncRequest
{
[Required]
public long LastSyncTimestamp { get; set; }
}
[HttpGet("{roomId:long}/sync")]
public async Task<ActionResult<SyncResponse>> GetSyncData([FromQuery] SyncRequest request, long roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
var isMember = await db.ChatMembers
.AnyAsync(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId);
if (!isMember)
return StatusCode(403, "You are not a member of this chat room.");
var response = await cs.GetSyncDataAsync(roomId, request.LastSyncTimestamp);
return Ok(response);
}
}

View File

@ -35,6 +35,13 @@ public enum ChatMemberRole
Normal = 0
}
public enum ChatMemberNotify
{
All,
Mentions,
None
}
public class ChatMember : ModelBase
{
public Guid Id { get; set; }
@ -43,7 +50,10 @@ public class ChatMember : ModelBase
public long AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
public ChatMemberRole Role { get; set; }
[MaxLength(1024)] public string? Nick { get; set; }
public ChatMemberRole Role { get; set; } = ChatMemberRole.Normal;
public ChatMemberNotify Notify { get; set; } = ChatMemberNotify.All;
public Instant? JoinedAt { get; set; }
public bool IsBot { get; set; } = false;
}

View File

@ -37,7 +37,6 @@ public class ChatRoomController(AppDatabase db, FileService fs) : ControllerBase
.Include(e => e.ChatRoom)
.Include(e => e.ChatRoom.Picture)
.Include(e => e.ChatRoom.Background)
.Include(e => e.ChatRoom.Type == ChatRoomType.DirectMessage ? e.ChatRoom.Members : null)
.Select(m => m.ChatRoom)
.ToListAsync();

View File

@ -1,9 +1,47 @@
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Connection;
using Microsoft.EntityFrameworkCore;
using NodaTime;
public class ChatService(AppDatabase db)
namespace DysonNetwork.Sphere.Chat;
public class ChatService(AppDatabase db, NotificationService nty, WebSocketService ws)
{
public async Task<Message> SendMessageAsync(Message message, ChatMember sender, ChatRoom room)
{
db.ChatMessages.Add(message);
await db.SaveChangesAsync();
_ = DeliverMessageAsync(message, sender, room).ConfigureAwait(false);
return message;
}
public async Task DeliverMessageAsync(Message message, ChatMember sender, ChatRoom room)
{
var roomSubject = room.Realm is not null ? $"{room.Name}, {room.Realm.Name}" : room.Name;
var tasks = new List<Task>();
await foreach (
var member in db.ChatMembers
.Where(m => m.ChatRoomId == message.ChatRoomId && m.AccountId != message.Sender.AccountId)
.Where(m => m.Notify != ChatMemberNotify.None)
.Where(m => m.Notify != ChatMemberNotify.Mentions || (message.MembersMetioned != null && message.MembersMetioned.Contains(m.Id)))
.AsAsyncEnumerable()
)
{
ws.SendPacketToAccount(member.AccountId, new WebSocketPacket
{
Type = "messages.new",
Data = message
});
tasks.Add(nty.DeliveryNotification(new Notification
{
AccountId = member.AccountId,
Topic = "messages.new",
Title = $"{sender.Nick ?? sender.Account.Nick} ({roomSubject})",
}));
}
await Task.WhenAll(tasks);
}
public async Task MarkMessageAsReadAsync(Guid messageId, long roomId, long userId)
{
var existingStatus = await db.ChatStatuses
@ -45,4 +83,48 @@ public class ChatService(AppDatabase db)
return messages.Count(m => !m.IsRead);
}
public async Task<SyncResponse> GetSyncDataAsync(long roomId, long lastSyncTimestamp)
{
var timestamp = Instant.FromUnixTimeMilliseconds(lastSyncTimestamp);
var changes = await db.ChatMessages
.IgnoreQueryFilters()
.Where(m => m.ChatRoomId == roomId)
.Where(m => m.UpdatedAt > timestamp || m.DeletedAt > timestamp)
.Select(m => new MessageChange
{
MessageId = m.Id,
Action = m.DeletedAt != null ? "delete" : (m.EditedAt == null ? "create" : "update"),
Message = m.DeletedAt != null ? null : m,
Timestamp = m.DeletedAt != null ? m.DeletedAt.Value : m.UpdatedAt
})
.ToListAsync();
return new SyncResponse
{
Changes = changes,
CurrentTimestamp = SystemClock.Instance.GetCurrentInstant()
};
}
}
public class MessageChangeAction
{
public const string Create = "create";
public const string Update = "update";
public const string Delete = "delete";
}
public class MessageChange
{
public Guid MessageId { get; set; }
public string Action { get; set; } = null!;
public Message? Message { get; set; }
public Instant Timestamp { get; set; }
}
public class SyncResponse
{
public List<MessageChange> Changes { get; set; } = [];
public Instant CurrentTimestamp { get; set; }
}

View File

@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Net.Mail;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Storage;
using NodaTime;
namespace DysonNetwork.Sphere.Chat;
@ -15,7 +15,7 @@ public class Message : ModelBase
[Column(TypeName = "jsonb")] public List<Guid>? MembersMetioned { get; set; }
public Instant? EditedAt { get; set; }
public ICollection<Attachment> Attachments { get; set; } = new List<Attachment>();
public ICollection<CloudFile> Attachments { get; set; } = new List<CloudFile>();
public ICollection<MessageReaction> Reactions { get; set; } = new List<MessageReaction>();
public ICollection<MessageStatus> Statuses { get; set; } = new List<MessageStatus>();
@ -39,6 +39,7 @@ public enum MessageReactionAttitude
public class MessageReaction : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid MessageId { get; set; }
[JsonIgnore] public Message Message { get; set; } = null!;
public Guid SenderId { get; set; }

View File

@ -0,0 +1,62 @@
using System.Net.WebSockets;
using DysonNetwork.Sphere.Chat;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Connection.Handlers;
public class MessageReadHandler(AppDatabase db) : IWebSocketPacketHandler
{
public string PacketType => "message.read";
public async Task HandleAsync(Account.Account currentUser, string deviceId, WebSocketPacket packet, WebSocket socket)
{
var request = packet.GetData<Chat.ChatController.MarkMessageReadRequest>();
if (request is null)
{
await socket.SendAsync(
new ArraySegment<byte>(new WebSocketPacket
{
Type = WebSocketPacketType.Error,
ErrorMessage = "Mark message as read requires you provide the ChatRoomId and MessageId"
}.ToBytes()),
WebSocketMessageType.Binary,
true,
CancellationToken.None
);
return;
}
var existingStatus = await db.ChatStatuses
.FirstOrDefaultAsync(x => x.MessageId == request.MessageId && x.Sender.AccountId == currentUser.Id);
var sender = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == request.ChatRoomId)
.FirstOrDefaultAsync();
if (sender is null)
{
await socket.SendAsync(
new ArraySegment<byte>(new WebSocketPacket
{
Type = WebSocketPacketType.Error,
ErrorMessage = "User is not a member of the chat room."
}.ToBytes()),
WebSocketMessageType.Binary,
true,
CancellationToken.None
);
return;
}
if (existingStatus == null)
{
existingStatus = new MessageStatus
{
MessageId = request.MessageId,
SenderId = sender.Id,
};
db.ChatStatuses.Add(existingStatus);
}
await db.SaveChangesAsync();
}
}

View File

@ -0,0 +1,9 @@
using System.Net.WebSockets;
namespace DysonNetwork.Sphere.Connection;
public interface IWebSocketPacketHandler
{
string PacketType { get; }
Task HandleAsync(Account.Account currentUser, string deviceId, WebSocketPacket packet, WebSocket socket);
}

View File

@ -1,11 +1,19 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using DysonNetwork.Sphere.Chat;
namespace DysonNetwork.Sphere.Connection;
public class WebSocketService(ChatService cs)
public class WebSocketService
{
public static readonly ConcurrentDictionary<
private readonly IDictionary<string, IWebSocketPacketHandler> _handlerMap;
public WebSocketService(IEnumerable<IWebSocketPacketHandler> handlers)
{
_handlerMap = handlers.ToDictionary(h => h.PacketType);
}
private static readonly ConcurrentDictionary<
(long AccountId, string DeviceId),
(WebSocket Socket, CancellationTokenSource Cts)
> ActiveConnections = new();
@ -17,7 +25,8 @@ public class WebSocketService(ChatService cs)
)
{
if (ActiveConnections.TryGetValue(key, out _))
Disconnect(key, "Just connected somewhere else with the same identifier."); // Disconnect the previous one using the same identifier
Disconnect(key,
"Just connected somewhere else with the same identifier."); // Disconnect the previous one using the same identifier
return ActiveConnections.TryAdd(key, (socket, cts));
}
@ -33,40 +42,58 @@ public class WebSocketService(ChatService cs)
ActiveConnections.TryRemove(key, out _);
}
public void HandlePacket(Account.Account currentUser, string deviceId, WebSocketPacket packet, WebSocket socket)
public void SendPacketToAccount(long userId, WebSocketPacket packet)
{
switch (packet.Type)
var connections = ActiveConnections.Where(c => c.Key.AccountId == userId);
var packetBytes = packet.ToBytes();
var segment = new ArraySegment<byte>(packetBytes);
foreach (var connection in connections)
{
case "message.read":
var request = packet.GetData<ChatController.MarkMessageReadRequest>();
if (request is null)
{
socket.SendAsync(
new ArraySegment<byte>(new WebSocketPacket
{
Type = WebSocketPacketType.Error,
ErrorMessage = "Mark message as read requires you provide the ChatRoomId and MessageId"
}.ToBytes()),
WebSocketMessageType.Binary,
true,
CancellationToken.None
);
break;
}
_ = cs.MarkMessageAsReadAsync(request.MessageId, currentUser.Id, currentUser.Id).ConfigureAwait(false);
break;
default:
socket.SendAsync(
new ArraySegment<byte>(new WebSocketPacket
{
Type = WebSocketPacketType.Error,
ErrorMessage = $"Unprocessable packet: {packet.Type}"
}.ToBytes()),
WebSocketMessageType.Binary,
true,
CancellationToken.None
);
break;
connection.Value.Socket.SendAsync(
segment,
WebSocketMessageType.Binary,
true,
CancellationToken.None
);
}
}
public void SendPacketToDevice(string deviceId, WebSocketPacket packet)
{
var connections = ActiveConnections.Where(c => c.Key.DeviceId == deviceId);
var packetBytes = packet.ToBytes();
var segment = new ArraySegment<byte>(packetBytes);
foreach (var connection in connections)
{
connection.Value.Socket.SendAsync(
segment,
WebSocketMessageType.Binary,
true,
CancellationToken.None
);
}
}
public async Task HandlePacket(Account.Account currentUser, string deviceId, WebSocketPacket packet,
WebSocket socket)
{
if (_handlerMap.TryGetValue(packet.Type, out var handler))
{
await handler.HandleAsync(currentUser, deviceId, packet, socket);
return;
}
await socket.SendAsync(
new ArraySegment<byte>(new WebSocketPacket
{
Type = WebSocketPacketType.Error,
ErrorMessage = $"Unprocessable packet: {packet.Type}"
}.ToBytes()),
WebSocketMessageType.Binary,
true,
CancellationToken.None
);
}
}

View File

@ -11,8 +11,6 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Blurhash.ImageSharp" Version="4.0.0" />
<PackageReference Include="Casbin.NET" Version="2.12.0" />
<PackageReference Include="Casbin.NET.Adapter.EFCore" Version="2.5.0" />
<PackageReference Include="CorePush" Version="4.3.0" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="FFMpegCore" Version="5.2.0" />

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,238 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class AddChatMessage : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "pk_chat_members",
table: "chat_members");
migrationBuilder.AddColumn<Guid>(
name: "message_id",
table: "files",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "id",
table: "chat_members",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
migrationBuilder.AddUniqueConstraint(
name: "ak_chat_members_chat_room_id_account_id",
table: "chat_members",
columns: new[] { "chat_room_id", "account_id" });
migrationBuilder.AddPrimaryKey(
name: "pk_chat_members",
table: "chat_members",
column: "id");
migrationBuilder.CreateTable(
name: "chat_messages",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
type = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
content = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
members_metioned = table.Column<List<Guid>>(type: "jsonb", nullable: true),
edited_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
replied_message_id = table.Column<Guid>(type: "uuid", nullable: true),
forwarded_message_id = table.Column<Guid>(type: "uuid", nullable: true),
sender_id = table.Column<Guid>(type: "uuid", nullable: false),
chat_room_id = table.Column<long>(type: "bigint", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_chat_messages", x => x.id);
table.ForeignKey(
name: "fk_chat_messages_chat_members_sender_id",
column: x => x.sender_id,
principalTable: "chat_members",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_chat_messages_chat_messages_forwarded_message_id",
column: x => x.forwarded_message_id,
principalTable: "chat_messages",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_chat_messages_chat_messages_replied_message_id",
column: x => x.replied_message_id,
principalTable: "chat_messages",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_chat_messages_chat_rooms_chat_room_id",
column: x => x.chat_room_id,
principalTable: "chat_rooms",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "chat_statuses",
columns: table => new
{
message_id = table.Column<Guid>(type: "uuid", nullable: false),
sender_id = table.Column<Guid>(type: "uuid", nullable: false),
read_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_chat_statuses", x => new { x.message_id, x.sender_id });
table.ForeignKey(
name: "fk_chat_statuses_chat_members_sender_id",
column: x => x.sender_id,
principalTable: "chat_members",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_chat_statuses_chat_messages_message_id",
column: x => x.message_id,
principalTable: "chat_messages",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "message_reaction",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
message_id = table.Column<Guid>(type: "uuid", nullable: false),
sender_id = table.Column<Guid>(type: "uuid", nullable: false),
symbol = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
attitude = table.Column<int>(type: "integer", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_message_reaction", x => x.id);
table.ForeignKey(
name: "fk_message_reaction_chat_members_sender_id",
column: x => x.sender_id,
principalTable: "chat_members",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_message_reaction_chat_messages_message_id",
column: x => x.message_id,
principalTable: "chat_messages",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_files_message_id",
table: "files",
column: "message_id");
migrationBuilder.CreateIndex(
name: "ix_chat_messages_chat_room_id",
table: "chat_messages",
column: "chat_room_id");
migrationBuilder.CreateIndex(
name: "ix_chat_messages_forwarded_message_id",
table: "chat_messages",
column: "forwarded_message_id");
migrationBuilder.CreateIndex(
name: "ix_chat_messages_replied_message_id",
table: "chat_messages",
column: "replied_message_id");
migrationBuilder.CreateIndex(
name: "ix_chat_messages_sender_id",
table: "chat_messages",
column: "sender_id");
migrationBuilder.CreateIndex(
name: "ix_chat_statuses_sender_id",
table: "chat_statuses",
column: "sender_id");
migrationBuilder.CreateIndex(
name: "ix_message_reaction_message_id",
table: "message_reaction",
column: "message_id");
migrationBuilder.CreateIndex(
name: "ix_message_reaction_sender_id",
table: "message_reaction",
column: "sender_id");
migrationBuilder.AddForeignKey(
name: "fk_files_chat_messages_message_id",
table: "files",
column: "message_id",
principalTable: "chat_messages",
principalColumn: "id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_files_chat_messages_message_id",
table: "files");
migrationBuilder.DropTable(
name: "chat_statuses");
migrationBuilder.DropTable(
name: "message_reaction");
migrationBuilder.DropTable(
name: "chat_messages");
migrationBuilder.DropIndex(
name: "ix_files_message_id",
table: "files");
migrationBuilder.DropUniqueConstraint(
name: "ak_chat_members_chat_room_id_account_id",
table: "chat_members");
migrationBuilder.DropPrimaryKey(
name: "pk_chat_members",
table: "chat_members");
migrationBuilder.DropColumn(
name: "message_id",
table: "files");
migrationBuilder.DropColumn(
name: "id",
table: "chat_members");
migrationBuilder.AddPrimaryKey(
name: "pk_chat_members",
table: "chat_members",
columns: new[] { "chat_room_id", "account_id" });
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore.Migrations;
using NpgsqlTypes;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class DontKnowHowToNameThing : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<NpgsqlTsVector>(
name: "search_vector",
table: "posts",
type: "tsvector",
nullable: true,
oldClrType: typeof(NpgsqlTsVector),
oldType: "tsvector")
.OldAnnotation("Npgsql:TsVectorConfig", "simple")
.OldAnnotation("Npgsql:TsVectorProperties", new[] { "title", "description", "content" });
migrationBuilder.AddColumn<string>(
name: "nick",
table: "chat_members",
type: "character varying(1024)",
maxLength: 1024,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "notify",
table: "chat_members",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "nick",
table: "chat_members");
migrationBuilder.DropColumn(
name: "notify",
table: "chat_members");
migrationBuilder.AlterColumn<NpgsqlTsVector>(
name: "search_vector",
table: "posts",
type: "tsvector",
nullable: false,
oldClrType: typeof(NpgsqlTsVector),
oldType: "tsvector",
oldNullable: true)
.Annotation("Npgsql:TsVectorConfig", "simple")
.Annotation("Npgsql:TsVectorProperties", new[] { "title", "description", "content" });
}
}
}

View File

@ -647,14 +647,19 @@ namespace DysonNetwork.Sphere.Migrations
modelBuilder.Entity("DysonNetwork.Sphere.Chat.ChatMember", b =>
{
b.Property<long>("ChatRoomId")
.HasColumnType("bigint")
.HasColumnName("chat_room_id");
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<long>("AccountId")
.HasColumnType("bigint")
.HasColumnName("account_id");
b.Property<long>("ChatRoomId")
.HasColumnType("bigint")
.HasColumnName("chat_room_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
@ -671,6 +676,15 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("joined_at");
b.Property<string>("Nick")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("nick");
b.Property<int>("Notify")
.HasColumnType("integer")
.HasColumnName("notify");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
@ -679,9 +693,12 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("ChatRoomId", "AccountId")
b.HasKey("Id")
.HasName("pk_chat_members");
b.HasAlternateKey("ChatRoomId", "AccountId")
.HasName("ak_chat_members_chat_room_id_account_id");
b.HasIndex("AccountId")
.HasDatabaseName("ix_chat_members_account_id");
@ -756,6 +773,167 @@ namespace DysonNetwork.Sphere.Migrations
b.ToTable("chat_rooms", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Chat.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<long>("ChatRoomId")
.HasColumnType("bigint")
.HasColumnName("chat_room_id");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("content");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("EditedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("edited_at");
b.Property<Guid?>("ForwardedMessageId")
.HasColumnType("uuid")
.HasColumnName("forwarded_message_id");
b.Property<List<Guid>>("MembersMetioned")
.HasColumnType("jsonb")
.HasColumnName("members_metioned");
b.Property<Dictionary<string, object>>("Meta")
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<Guid?>("RepliedMessageId")
.HasColumnType("uuid")
.HasColumnName("replied_message_id");
b.Property<Guid>("SenderId")
.HasColumnType("uuid")
.HasColumnName("sender_id");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_chat_messages");
b.HasIndex("ChatRoomId")
.HasDatabaseName("ix_chat_messages_chat_room_id");
b.HasIndex("ForwardedMessageId")
.HasDatabaseName("ix_chat_messages_forwarded_message_id");
b.HasIndex("RepliedMessageId")
.HasDatabaseName("ix_chat_messages_replied_message_id");
b.HasIndex("SenderId")
.HasDatabaseName("ix_chat_messages_sender_id");
b.ToTable("chat_messages", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Chat.MessageReaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<int>("Attitude")
.HasColumnType("integer")
.HasColumnName("attitude");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Guid>("MessageId")
.HasColumnType("uuid")
.HasColumnName("message_id");
b.Property<Guid>("SenderId")
.HasColumnType("uuid")
.HasColumnName("sender_id");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("symbol");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_message_reaction");
b.HasIndex("MessageId")
.HasDatabaseName("ix_message_reaction_message_id");
b.HasIndex("SenderId")
.HasDatabaseName("ix_message_reaction_sender_id");
b.ToTable("message_reaction", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Chat.MessageStatus", b =>
{
b.Property<Guid>("MessageId")
.HasColumnType("uuid")
.HasColumnName("message_id");
b.Property<Guid>("SenderId")
.HasColumnType("uuid")
.HasColumnName("sender_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant>("ReadAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("read_at");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("MessageId", "SenderId")
.HasName("pk_chat_statuses");
b.HasIndex("SenderId")
.HasDatabaseName("ix_chat_statuses_sender_id");
b.ToTable("chat_statuses", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Permission.PermissionGroup", b =>
{
b.Property<Guid>("Id")
@ -950,12 +1128,8 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnName("replied_post_id");
b.Property<NpgsqlTsVector>("SearchVector")
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("tsvector")
.HasColumnName("search_vector")
.HasAnnotation("Npgsql:TsVectorConfig", "simple")
.HasAnnotation("Npgsql:TsVectorProperties", new[] { "Title", "Description", "Content" });
.HasColumnName("search_vector");
b.Property<long?>("ThreadedPostId")
.HasColumnType("bigint")
@ -1470,6 +1644,10 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("character varying(256)")
.HasColumnName("hash");
b.Property<Guid?>("MessageId")
.HasColumnType("uuid")
.HasColumnName("message_id");
b.Property<string>("MimeType")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
@ -1516,6 +1694,9 @@ namespace DysonNetwork.Sphere.Migrations
b.HasIndex("AccountId")
.HasDatabaseName("ix_files_account_id");
b.HasIndex("MessageId")
.HasDatabaseName("ix_files_message_id");
b.HasIndex("PostId")
.HasDatabaseName("ix_files_post_id");
@ -1774,6 +1955,85 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Realm");
});
modelBuilder.Entity("DysonNetwork.Sphere.Chat.Message", b =>
{
b.HasOne("DysonNetwork.Sphere.Chat.ChatRoom", "ChatRoom")
.WithMany()
.HasForeignKey("ChatRoomId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_chat_messages_chat_rooms_chat_room_id");
b.HasOne("DysonNetwork.Sphere.Chat.Message", "ForwardedMessage")
.WithMany()
.HasForeignKey("ForwardedMessageId")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_chat_messages_chat_messages_forwarded_message_id");
b.HasOne("DysonNetwork.Sphere.Chat.Message", "RepliedMessage")
.WithMany()
.HasForeignKey("RepliedMessageId")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_chat_messages_chat_messages_replied_message_id");
b.HasOne("DysonNetwork.Sphere.Chat.ChatMember", "Sender")
.WithMany()
.HasForeignKey("SenderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_chat_messages_chat_members_sender_id");
b.Navigation("ChatRoom");
b.Navigation("ForwardedMessage");
b.Navigation("RepliedMessage");
b.Navigation("Sender");
});
modelBuilder.Entity("DysonNetwork.Sphere.Chat.MessageReaction", b =>
{
b.HasOne("DysonNetwork.Sphere.Chat.Message", "Message")
.WithMany("Reactions")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_message_reaction_chat_messages_message_id");
b.HasOne("DysonNetwork.Sphere.Chat.ChatMember", "Sender")
.WithMany()
.HasForeignKey("SenderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_message_reaction_chat_members_sender_id");
b.Navigation("Message");
b.Navigation("Sender");
});
modelBuilder.Entity("DysonNetwork.Sphere.Chat.MessageStatus", b =>
{
b.HasOne("DysonNetwork.Sphere.Chat.Message", "Message")
.WithMany("Statuses")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_chat_statuses_chat_messages_message_id");
b.HasOne("DysonNetwork.Sphere.Chat.ChatMember", "Sender")
.WithMany()
.HasForeignKey("SenderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_chat_statuses_chat_members_sender_id");
b.Navigation("Message");
b.Navigation("Sender");
});
modelBuilder.Entity("DysonNetwork.Sphere.Permission.PermissionGroupMember", b =>
{
b.HasOne("DysonNetwork.Sphere.Permission.PermissionGroup", "Group")
@ -1965,6 +2225,11 @@ namespace DysonNetwork.Sphere.Migrations
.IsRequired()
.HasConstraintName("fk_files_accounts_account_id");
b.HasOne("DysonNetwork.Sphere.Chat.Message", null)
.WithMany("Attachments")
.HasForeignKey("MessageId")
.HasConstraintName("fk_files_chat_messages_message_id");
b.HasOne("DysonNetwork.Sphere.Post.Post", null)
.WithMany("Attachments")
.HasForeignKey("PostId")
@ -2047,6 +2312,15 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Members");
});
modelBuilder.Entity("DysonNetwork.Sphere.Chat.Message", b =>
{
b.Navigation("Attachments");
b.Navigation("Reactions");
b.Navigation("Statuses");
});
modelBuilder.Entity("DysonNetwork.Sphere.Permission.PermissionGroup", b =>
{
b.Navigation("Members");

View File

@ -51,7 +51,7 @@ public class Post : ModelBase
public Post? ForwardedPost { get; set; }
public ICollection<CloudFile> Attachments { get; set; } = new List<CloudFile>();
[JsonIgnore] public NpgsqlTsVector SearchVector { get; set; }
[JsonIgnore] public NpgsqlTsVector? SearchVector { get; set; }
public Publisher Publisher { get; set; } = null!;
public ICollection<PostReaction> Reactions { get; set; } = new List<PostReaction>();

View File

@ -1,6 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Casbin;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Permission;
using Microsoft.AspNetCore.Mvc;

View File

@ -74,6 +74,9 @@ public class PostService(AppDatabase db, FileService fs, ActivityService act)
List<string>? categories = null
)
{
if (post.Empty)
throw new InvalidOperationException("Cannot create a post with barely no content.");
if (post.PublishedAt is not null)
{
if (post.PublishedAt.Value.ToDateTimeUtc() < DateTime.UtcNow)
@ -118,8 +121,27 @@ public class PostService(AppDatabase db, FileService fs, ActivityService act)
throw new InvalidOperationException("Categories contains one or more categories that wasn't exists.");
}
if (post.Empty)
throw new InvalidOperationException("Cannot create a post with barely no content.");
// Vectorize the quill delta content
if (post.Content?.RootElement is { ValueKind: JsonValueKind.Array })
{
var searchTextBuilder = new System.Text.StringBuilder();
if (!string.IsNullOrWhiteSpace(post.Title))
searchTextBuilder.AppendLine(post.Title);
if (!string.IsNullOrWhiteSpace(post.Description))
searchTextBuilder.AppendLine(post.Description);
foreach (var element in post.Content.RootElement.EnumerateArray())
{
if (element is { ValueKind: JsonValueKind.Object } &&
element.TryGetProperty("insert", out var insertProperty) &&
insertProperty.ValueKind == JsonValueKind.String)
{
searchTextBuilder.Append(insertProperty.GetString());
}
}
post.SearchVector = EF.Functions.ToTsVector(searchTextBuilder.ToString().Trim());
}
// TODO Notify the subscribers
@ -140,6 +162,9 @@ public class PostService(AppDatabase db, FileService fs, ActivityService act)
Instant? publishedAt = null
)
{
if (post.Empty)
throw new InvalidOperationException("Cannot edit a post to barely no content.");
post.EditedAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
if (publishedAt is not null)
@ -196,8 +221,27 @@ public class PostService(AppDatabase db, FileService fs, ActivityService act)
throw new InvalidOperationException("Categories contains one or more categories that wasn't exists.");
}
if (post.Empty)
throw new InvalidOperationException("Cannot edit a post to barely no content.");
// Vectorize the quill delta content
if (post.Content?.RootElement is { ValueKind: JsonValueKind.Array })
{
var searchTextBuilder = new System.Text.StringBuilder();
if (!string.IsNullOrWhiteSpace(post.Title))
searchTextBuilder.AppendLine(post.Title);
if (!string.IsNullOrWhiteSpace(post.Description))
searchTextBuilder.AppendLine(post.Description);
foreach (var element in post.Content.RootElement.EnumerateArray())
{
if (element is { ValueKind: JsonValueKind.Object } &&
element.TryGetProperty("insert", out var insertProperty) &&
insertProperty.ValueKind == JsonValueKind.String)
{
searchTextBuilder.Append(insertProperty.GetString());
}
}
post.SearchVector = EF.Functions.ToTsVector(searchTextBuilder.ToString().Trim());
}
db.Update(post);
await db.SaveChangesAsync();

View File

@ -48,6 +48,6 @@ public class PublisherMember : ModelBase
public long AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
public PublisherMemberRole Role { get; set; }
public PublisherMemberRole Role { get; set; } = PublisherMemberRole.Viewer;
public Instant? JoinedAt { get; set; }
}

View File

@ -1,5 +1,4 @@
using System.ComponentModel.DataAnnotations;
using Casbin;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization;

View File

@ -3,19 +3,17 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.RateLimiting;
using Casbin;
using Casbin.Persist.Adapter.EFCore;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Activity;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Connection;
using DysonNetwork.Sphere.Connection.Handlers;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Realm;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
@ -118,6 +116,10 @@ builder.Services.AddSwaggerGen(options =>
});
builder.Services.AddOpenApi();
// The handlers for websocket
builder.Services.AddScoped<IWebSocketPacketHandler, MessageReadHandler>();
// Services
builder.Services.AddScoped<WebSocketService>();
builder.Services.AddScoped<EmailService>();
builder.Services.AddScoped<PermissionService>();
@ -132,6 +134,7 @@ builder.Services.AddScoped<ActivityService>();
builder.Services.AddScoped<PostService>();
builder.Services.AddScoped<RealmService>();
builder.Services.AddScoped<ChatRoomService>();
builder.Services.AddScoped<ChatService>();
// Timed task
@ -213,7 +216,7 @@ app.MapTus("/files/tus", _ => Task.FromResult<DefaultTusConfiguration>(new()
}
var httpContext = eventContext.HttpContext;
var user = httpContext.Items["CurrentUser"] as Account;
if (httpContext.Items["CurrentUser"] is Account user)
if (user is null)
{
eventContext.FailRequest(HttpStatusCode.Unauthorized);

View File

@ -43,6 +43,6 @@ public class RealmMember : ModelBase
public long AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
public RealmMemberRole Role { get; set; }
public RealmMemberRole Role { get; set; } = RealmMemberRole.Normal;
public Instant? JoinedAt { get; set; }
}