Compare commits

..

No commits in common. "f6acb3f2f021bfc227dc6e958d51ee5ed0dab919" and "17de9a0f23b8fdecaca8744768fe5f35681105c9" have entirely different histories.

25 changed files with 86 additions and 5679 deletions

View File

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

View File

@ -134,6 +134,7 @@ 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>()
@ -192,16 +193,6 @@ 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,6 +2,8 @@ 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

@ -0,0 +1,14 @@
[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,94 +1,12 @@
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 partial class ChatController(AppDatabase db, ChatService cs) : ControllerBase
public class ChatController : 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,13 +35,6 @@ public enum ChatMemberRole
Normal = 0
}
public enum ChatMemberNotify
{
All,
Mentions,
None
}
public class ChatMember : ModelBase
{
public Guid Id { get; set; }
@ -50,10 +43,7 @@ public class ChatMember : ModelBase
public long AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
[MaxLength(1024)] public string? Nick { get; set; }
public ChatMemberRole Role { get; set; } = ChatMemberRole.Normal;
public ChatMemberNotify Notify { get; set; } = ChatMemberNotify.All;
public ChatMemberRole Role { get; set; }
public Instant? JoinedAt { get; set; }
public bool IsBot { get; set; } = false;
}

View File

@ -37,6 +37,7 @@ 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,47 +1,9 @@
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Connection;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Chat;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Chat;
public class ChatService(AppDatabase db, NotificationService nty, WebSocketService ws)
public class ChatService(AppDatabase db)
{
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
@ -83,48 +45,4 @@ public class ChatService(AppDatabase db, NotificationService nty, WebSocketServi
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<CloudFile> Attachments { get; set; } = new List<CloudFile>();
public ICollection<Attachment> Attachments { get; set; } = new List<Attachment>();
public ICollection<MessageReaction> Reactions { get; set; } = new List<MessageReaction>();
public ICollection<MessageStatus> Statuses { get; set; } = new List<MessageStatus>();
@ -39,7 +39,6 @@ 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

@ -1,62 +0,0 @@
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

@ -1,9 +0,0 @@
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,19 +1,11 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using DysonNetwork.Sphere.Chat;
namespace DysonNetwork.Sphere.Connection;
public class WebSocketService
public class WebSocketService(ChatService cs)
{
private readonly IDictionary<string, IWebSocketPacketHandler> _handlerMap;
public WebSocketService(IEnumerable<IWebSocketPacketHandler> handlers)
{
_handlerMap = handlers.ToDictionary(h => h.PacketType);
}
private static readonly ConcurrentDictionary<
public static readonly ConcurrentDictionary<
(long AccountId, string DeviceId),
(WebSocket Socket, CancellationTokenSource Cts)
> ActiveConnections = new();
@ -25,8 +17,7 @@ public class WebSocketService
)
{
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));
}
@ -42,58 +33,40 @@ public class WebSocketService
ActiveConnections.TryRemove(key, out _);
}
public void SendPacketToAccount(long userId, WebSocketPacket packet)
public void HandlePacket(Account.Account currentUser, string deviceId, WebSocketPacket packet, WebSocket socket)
{
var connections = ActiveConnections.Where(c => c.Key.AccountId == userId);
var packetBytes = packet.ToBytes();
var segment = new ArraySegment<byte>(packetBytes);
foreach (var connection in connections)
switch (packet.Type)
{
connection.Value.Socket.SendAsync(
segment,
WebSocketMessageType.Binary,
true,
CancellationToken.None
);
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;
}
}
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,6 +11,8 @@
<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

@ -1,238 +0,0 @@
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" });
}
}
}

View File

@ -1,62 +0,0 @@
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,19 +647,14 @@ namespace DysonNetwork.Sphere.Migrations
modelBuilder.Entity("DysonNetwork.Sphere.Chat.ChatMember", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<long>("ChatRoomId")
.HasColumnType("bigint")
.HasColumnName("chat_room_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");
@ -676,15 +671,6 @@ 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");
@ -693,12 +679,9 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
b.HasKey("ChatRoomId", "AccountId")
.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");
@ -773,167 +756,6 @@ 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")
@ -1128,8 +950,12 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnName("replied_post_id");
b.Property<NpgsqlTsVector>("SearchVector")
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("tsvector")
.HasColumnName("search_vector");
.HasColumnName("search_vector")
.HasAnnotation("Npgsql:TsVectorConfig", "simple")
.HasAnnotation("Npgsql:TsVectorProperties", new[] { "Title", "Description", "Content" });
b.Property<long?>("ThreadedPostId")
.HasColumnType("bigint")
@ -1644,10 +1470,6 @@ 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)")
@ -1694,9 +1516,6 @@ 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");
@ -1955,85 +1774,6 @@ 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")
@ -2225,11 +1965,6 @@ 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")
@ -2312,15 +2047,6 @@ 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,5 +1,6 @@
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,9 +74,6 @@ 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)
@ -121,27 +118,8 @@ public class PostService(AppDatabase db, FileService fs, ActivityService act)
throw new InvalidOperationException("Categories contains one or more categories that wasn't exists.");
}
// 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());
}
if (post.Empty)
throw new InvalidOperationException("Cannot create a post with barely no content.");
// TODO Notify the subscribers
@ -162,9 +140,6 @@ 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)
@ -221,27 +196,8 @@ public class PostService(AppDatabase db, FileService fs, ActivityService act)
throw new InvalidOperationException("Categories contains one or more categories that wasn't exists.");
}
// 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());
}
if (post.Empty)
throw new InvalidOperationException("Cannot edit a post to barely no content.");
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; } = PublisherMemberRole.Viewer;
public PublisherMemberRole Role { get; set; }
public Instant? JoinedAt { get; set; }
}

View File

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

View File

@ -3,17 +3,19 @@ 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;
@ -116,10 +118,6 @@ 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>();
@ -134,7 +132,6 @@ builder.Services.AddScoped<ActivityService>();
builder.Services.AddScoped<PostService>();
builder.Services.AddScoped<RealmService>();
builder.Services.AddScoped<ChatRoomService>();
builder.Services.AddScoped<ChatService>();
// Timed task
@ -216,7 +213,7 @@ app.MapTus("/files/tus", _ => Task.FromResult<DefaultTusConfiguration>(new()
}
var httpContext = eventContext.HttpContext;
if (httpContext.Items["CurrentUser"] is Account user)
var user = httpContext.Items["CurrentUser"] as Account;
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; } = RealmMemberRole.Normal;
public RealmMemberRole Role { get; set; }
public Instant? JoinedAt { get; set; }
}