Compare commits

..

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

25 changed files with 10724 additions and 517 deletions

View File

@ -35,9 +35,7 @@ public class Profile : ModelBase
[MaxLength(256)] public string? LastName { get; set; }
[MaxLength(4096)] public string? Bio { get; set; }
public string? PictureId { get; set; }
public Storage.CloudFile? Picture { get; set; }
public string? BackgroundId { get; set; }
public Storage.CloudFile? Background { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;

View File

@ -25,6 +25,8 @@ public class AccountController(
{
var account = await db.Accounts
.Include(e => e.Profile)
.Include(e => e.Profile.Picture)
.Include(e => e.Profile.Background)
.Where(a => a.Name == name)
.FirstOrDefaultAsync();
return account is null ? new NotFoundResult() : account;
@ -112,6 +114,8 @@ public class AccountController(
var account = await db.Accounts
.Include(e => e.Profile)
.Include(e => e.Profile.Picture)
.Include(e => e.Profile.Background)
.Where(e => e.Id == userId)
.FirstOrDefaultAsync();
@ -198,17 +202,4 @@ public class AccountController(
return profile;
}
[HttpGet("search")]
public async Task<List<Account>> Search([FromQuery] string query, [FromQuery] int take = 20)
{
if (string.IsNullOrWhiteSpace(query))
return [];
return await db.Accounts
.Include(e => e.Profile)
.Where(a => EF.Functions.ILike(a.Name, $"%{query}%") ||
EF.Functions.ILike(a.Nick, $"%{query}%"))
.Take(take)
.ToListAsync();
}
}

View File

@ -1,6 +1,5 @@
using CorePush.Apple;
using CorePush.Firebase;
using DysonNetwork.Sphere.Connection;
using Microsoft.EntityFrameworkCore;
using NodaTime;
@ -9,21 +8,18 @@ namespace DysonNetwork.Sphere.Account;
public class NotificationService
{
private readonly AppDatabase _db;
private readonly WebSocketService _ws;
private readonly ILogger<NotificationService> _logger;
private readonly FirebaseSender? _fcm;
private readonly ApnSender? _apns;
public NotificationService(
AppDatabase db,
WebSocketService ws,
IConfiguration cfg,
IHttpClientFactory clientFactory,
ILogger<NotificationService> logger
)
{
_db = db;
_ws = ws;
_logger = logger;
var cfgSection = cfg.GetSection("Notifications:Push");
@ -87,7 +83,6 @@ public class NotificationService
public async Task<Notification> SendNotification(
Account account,
string topic,
string? title = null,
string? subtitle = null,
string? content = null,
@ -102,7 +97,6 @@ public class NotificationService
var notification = new Notification
{
Topic = topic,
Title = title,
Subtitle = subtitle,
Content = content,
@ -120,11 +114,7 @@ public class NotificationService
public async Task DeliveryNotification(Notification notification)
{
_ws.SendPacketToAccount(notification.AccountId, new WebSocketPacket
{
Type = "notifications.new",
Data = notification
});
// TODO send websocket
// Pushing the notification
var subscribers = await _db.NotificationPushSubscriptions

View File

@ -21,6 +21,8 @@ public class ActivityService(AppDatabase db)
{
var posts = await db.Posts.Where(e => postsId.Contains(e.Id))
.Include(e => e.Publisher)
.Include(e => e.Publisher.Picture)
.Include(e => e.Publisher.Background)
.Include(e => e.ThreadedPost)
.Include(e => e.ForwardedPost)
.Include(e => e.Attachments)

View File

@ -16,6 +16,8 @@ public class UserInfoMiddleware(RequestDelegate next, IMemoryCache cache)
.Include(e => e.Challenge)
.Include(e => e.Account)
.Include(e => e.Account.Profile)
.Include(e => e.Account.Profile.Picture)
.Include(e => e.Account.Profile.Background)
.Where(e => e.Id == sessionId)
.FirstOrDefaultAsync();

View File

@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Authorization;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -19,68 +19,23 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller
public class SendMessageRequest
{
[MaxLength(4096)] public string? Content { 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}/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.Sender.Account.Profile)
.Include(m => m.Sender.Account.Profile.Picture)
.Include(m => m.Sender.Account.Profile.Background)
.Include(m => m.Attachments)
.Skip(offset)
.Take(take)
.ToListAsync();
Response.Headers["X-Total"] = totalCount.ToString();
return Ok(messages);
public List<CloudFile>? Attachments { get; set; }
}
[GeneratedRegex(@"@([A-Za-z0-9_-]+)")]
private static partial Regex MentionRegex();
[HttpPost("{roomId:long}/messages")]
[Authorize]
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.AttachmentsId == null || request.AttachmentsId.Count == 0))
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)
.Include(m => m.Account)
.Include(m => m.Account.Profile)
.FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Normal) return StatusCode(403, "You need to be a normal member to send messages here.");
@ -88,20 +43,11 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller
{
SenderId = member.Id,
ChatRoomId = roomId,
Nonce = request.Nonce ?? Guid.NewGuid().ToString(),
Meta = request.Meta ?? new Dictionary<string, object>(),
};
if (request.Content is not null)
message.Content = request.Content;
if (request.AttachmentsId is not null)
{
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.Attachments is not null)
message.Attachments = request.Attachments;
if (request.Content is not null)
{
@ -109,16 +55,17 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller
.Matches(request.Content)
.Select(m => m.Groups[1].Value)
.ToList();
if (mentioned.Count > 0)
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.MembersMentioned = mentionedMembers;
message.MembersMetioned = mentionedMembers;
}
}
member.Account = currentUser;
var result = await cs.SendMessageAsync(message, member, member.ChatRoom);
return Ok(result);
@ -131,7 +78,7 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller
}
[HttpGet("{roomId:long}/sync")]
public async Task<ActionResult<SyncResponse>> GetSyncData([FromBody] SyncRequest request, long roomId)
public async Task<ActionResult<SyncResponse>> GetSyncData([FromQuery] SyncRequest request, long roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();

View File

@ -19,9 +19,7 @@ public class ChatRoom : ModelBase
public ChatRoomType Type { get; set; }
public bool IsPublic { get; set; }
public string? PictureId { get; set; }
public CloudFile? Picture { get; set; }
public string? BackgroundId { get; set; }
public CloudFile? Background { get; set; }
[JsonIgnore] public ICollection<ChatMember> Members { get; set; } = new List<ChatMember>();
@ -48,9 +46,9 @@ public class ChatMember : ModelBase
{
public Guid Id { get; set; }
public long ChatRoomId { get; set; }
public ChatRoom ChatRoom { get; set; } = null!;
[JsonIgnore] public ChatRoom ChatRoom { get; set; } = null!;
public long AccountId { get; set; }
public Account.Account Account { get; set; } = null!;
[JsonIgnore] public Account.Account Account { get; set; } = null!;
[MaxLength(1024)] public string? Nick { get; set; }

View File

@ -10,13 +10,15 @@ namespace DysonNetwork.Sphere.Chat;
[ApiController]
[Route("/chat")]
public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService crs) : ControllerBase
public class ChatRoomController(AppDatabase db, FileService fs) : ControllerBase
{
[HttpGet("{id:long}")]
public async Task<ActionResult<ChatRoom>> GetChatRoom(long id)
{
var chatRoom = await db.ChatRooms
.Where(c => c.Id == id)
.Include(e => e.Picture)
.Include(e => e.Background)
.Include(e => e.Realm)
.FirstOrDefaultAsync();
if (chatRoom is null) return NotFound();
@ -33,6 +35,8 @@ public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService
.Where(m => m.AccountId == userId)
.Where(m => m.JoinedAt != null)
.Include(e => e.ChatRoom)
.Include(e => e.ChatRoom.Picture)
.Include(e => e.ChatRoom.Background)
.Select(m => m.ChatRoom)
.ToListAsync();
@ -218,311 +222,4 @@ public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService
return NoContent();
}
[HttpGet("{roomId:long}/members/me")]
[Authorize]
public async Task<ActionResult<ChatMember>> GetRoomIdentity(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)
.Include(m => m.Account.Profile)
.FirstOrDefaultAsync();
if (member == null)
return NotFound();
return Ok(member);
}
[HttpGet("{roomId:long}/members")]
public async Task<ActionResult<List<ChatMember>>> ListMembers(long roomId, [FromQuery] int take = 20,
[FromQuery] int skip = 0)
{
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
.FirstOrDefaultAsync(m => m.ChatRoomId == roomId && m.AccountId == currentUser.Id);
if (member is null) return StatusCode(403, "You need to be a member to see members of private chat room.");
}
var query = db.ChatMembers
.Where(m => m.ChatRoomId == roomId)
.Include(m => m.Account)
.Include(m => m.Account.Profile);
var total = await query.CountAsync();
Response.Headers.Append("X-Total", total.ToString());
var members = await query
.OrderBy(m => m.JoinedAt)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(members);
}
public class ChatMemberRequest
{
[Required] public long RelatedUserId { get; set; }
[Required] public ChatMemberRole Role { get; set; }
}
[HttpPost("invites/{roomId:long}")]
[Authorize]
public async Task<ActionResult<ChatMember>> InviteMember(long roomId,
[FromBody] ChatMemberRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId);
if (relatedUser is null) return BadRequest("Related user was not found");
var chatRoom = await db.ChatRooms
.Where(p => p.Id == roomId)
.FirstOrDefaultAsync();
if (chatRoom is null) return NotFound();
// Handle realm-owned chat rooms
if (chatRoom.RealmId is not null)
{
var realmMember = await db.RealmMembers
.Where(m => m.AccountId == userId)
.Where(m => m.RealmId == chatRoom.RealmId)
.FirstOrDefaultAsync();
if (realmMember is null || realmMember.Role < RealmMemberRole.Moderator)
return StatusCode(403, "You need at least be a realm moderator to invite members to this chat.");
}
else
{
var chatMember = await db.ChatMembers
.Where(m => m.AccountId == userId)
.Where(m => m.ChatRoomId == roomId)
.FirstOrDefaultAsync();
if (chatMember is null) return StatusCode(403, "You are not even a member of the targeted chat room.");
if (chatMember.Role < ChatMemberRole.Moderator)
return StatusCode(403,
"You need at least be a moderator to invite other members to this chat room.");
if (chatMember.Role < request.Role)
return StatusCode(403, "You cannot invite member with higher permission than yours.");
}
var newMember = new ChatMember
{
AccountId = relatedUser.Id,
ChatRoomId = roomId,
Role = request.Role,
};
db.ChatMembers.Add(newMember);
await db.SaveChangesAsync();
await crs.SendInviteNotify(newMember);
return Ok(newMember);
}
[HttpGet("invites")]
[Authorize]
public async Task<ActionResult<List<ChatMember>>> ListChatInvites()
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var members = await db.ChatMembers
.Where(m => m.AccountId == userId)
.Where(m => m.JoinedAt == null)
.Include(e => e.ChatRoom)
.Include(e => e.Account)
.Include(e => e.Account.Profile)
.ToListAsync();
return members.ToList();
}
[HttpPost("invites/{roomId:long}/accept")]
[Authorize]
public async Task<ActionResult<ChatRoom>> AcceptChatInvite(long roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var member = await db.ChatMembers
.Where(m => m.AccountId == userId)
.Where(m => m.ChatRoomId == roomId)
.Where(m => m.JoinedAt == null)
.FirstOrDefaultAsync();
if (member is null) return NotFound();
member.JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow);
db.Update(member);
await db.SaveChangesAsync();
return Ok(member);
}
[HttpPost("invites/{roomId:long}/decline")]
[Authorize]
public async Task<ActionResult> DeclineChatInvite(long roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var member = await db.ChatMembers
.Where(m => m.AccountId == userId)
.Where(m => m.ChatRoomId == roomId)
.Where(m => m.JoinedAt == null)
.FirstOrDefaultAsync();
if (member is null) return NotFound();
db.ChatMembers.Remove(member);
await db.SaveChangesAsync();
return NoContent();
}
[HttpPatch("{roomId:long}/members/{memberId:long}/role")]
[Authorize]
public async Task<ActionResult<ChatMember>> UpdateChatMemberRole(long roomId, long memberId,
[FromBody] ChatMemberRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var chatRoom = await db.ChatRooms
.Where(r => r.Id == roomId)
.FirstOrDefaultAsync();
if (chatRoom is null) return NotFound();
// Check if the chat room is owned by a realm
if (chatRoom.RealmId is not null)
{
var realmMember = await db.RealmMembers
.Where(m => m.AccountId == currentUser.Id)
.Where(m => m.RealmId == chatRoom.RealmId)
.FirstOrDefaultAsync();
if (realmMember is null || realmMember.Role < RealmMemberRole.Moderator)
return StatusCode(403, "You need at least be a realm moderator to change member roles.");
}
else
{
// Check if the current user has permission to change roles
var currentMember = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId)
.FirstOrDefaultAsync();
if (currentMember is null || currentMember.Role < ChatMemberRole.Moderator)
return StatusCode(403, "You need at least be a moderator to change member roles.");
// Find the target member
var targetMember = await db.ChatMembers
.Where(m => m.AccountId == memberId && m.ChatRoomId == roomId)
.FirstOrDefaultAsync();
if (targetMember is null) return NotFound();
// Check if current user has sufficient permissions
if (currentMember.Role <= targetMember.Role)
return StatusCode(403, "You cannot modify the role of members with equal or higher roles.");
if (currentMember.Role <= request.Role)
return StatusCode(403, "You cannot assign a role equal to or higher than your own.");
targetMember.Role = request.Role;
db.ChatMembers.Update(targetMember);
await db.SaveChangesAsync();
return Ok(targetMember);
}
return BadRequest();
}
[HttpDelete("{roomId:long}/members/{memberId:long}")]
[Authorize]
public async Task<ActionResult> RemoveChatMember(long roomId, long memberId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var chatRoom = await db.ChatRooms
.Where(r => r.Id == roomId)
.FirstOrDefaultAsync();
if (chatRoom is null) return NotFound();
// Check if the chat room is owned by a realm
if (chatRoom.RealmId is not null)
{
var realmMember = await db.RealmMembers
.Where(m => m.AccountId == currentUser.Id)
.Where(m => m.RealmId == chatRoom.RealmId)
.FirstOrDefaultAsync();
if (realmMember is null || realmMember.Role < RealmMemberRole.Moderator)
return StatusCode(403, "You need at least be a realm moderator to remove members.");
}
else
{
// Check if the current user has permission to remove members
var currentMember = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId)
.FirstOrDefaultAsync();
if (currentMember is null || currentMember.Role < ChatMemberRole.Moderator)
return StatusCode(403, "You need at least be a moderator to remove members.");
// Find the target member
var targetMember = await db.ChatMembers
.Where(m => m.AccountId == memberId && m.ChatRoomId == roomId)
.FirstOrDefaultAsync();
if (targetMember is null) return NotFound();
// Check if current user has sufficient permissions
if (currentMember.Role <= targetMember.Role)
return StatusCode(403, "You cannot remove members with equal or higher roles.");
db.ChatMembers.Remove(targetMember);
await db.SaveChangesAsync();
return NoContent();
}
return BadRequest();
}
[HttpDelete("{roomId:long}/members/me")]
[Authorize]
public async Task<ActionResult> LeaveChat(long roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var member = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id)
.Where(m => m.ChatRoomId == roomId)
.FirstOrDefaultAsync();
if (member is null) return NotFound();
if (member.Role == ChatMemberRole.Owner)
{
// Check if this is the only owner
var otherOwners = await db.ChatMembers
.Where(m => m.ChatRoomId == roomId)
.Where(m => m.Role == ChatMemberRole.Owner)
.Where(m => m.AccountId != currentUser.Id)
.AnyAsync();
if (!otherOwners)
return BadRequest("The last owner cannot leave the chat. Transfer ownership first or delete the chat.");
}
db.ChatMembers.Remove(member);
await db.SaveChangesAsync();
return NoContent();
}
}

View File

@ -1,12 +1,5 @@
using DysonNetwork.Sphere.Account;
namespace DysonNetwork.Sphere.Chat;
public class ChatRoomService(NotificationService nty)
public class ChatRoomService()
{
public async Task SendInviteNotify(ChatMember member)
{
await nty.SendNotification(member.Account, "invites.chats", "New Chat Invitation", null,
$"You just got invited to join {member.ChatRoom.Name}");
}
}

View File

@ -5,54 +5,40 @@ using NodaTime;
namespace DysonNetwork.Sphere.Chat;
public class ChatService(AppDatabase db, IServiceScopeFactory scopeFactory)
public class ChatService(AppDatabase db, NotificationService nty, WebSocketService ws)
{
public async Task<Message> SendMessageAsync(Message message, ChatMember sender, ChatRoom room)
{
// First complete the save operation
db.ChatMessages.Add(message);
await db.SaveChangesAsync();
// Then start the delivery process
// Using ConfigureAwait(false) is correct here since we don't need context to flow
_ = Task.Run(() => DeliverMessageAsync(message, sender, room))
.ConfigureAwait(false);
_ = DeliverMessageAsync(message, sender, room).ConfigureAwait(false);
return message;
}
public async Task DeliverMessageAsync(Message message, ChatMember sender, ChatRoom room)
{
var scope = scopeFactory.CreateScope();
var scopedDb = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var scopedWs = scope.ServiceProvider.GetRequiredService<WebSocketService>();
var scopedNty = scope.ServiceProvider.GetRequiredService<NotificationService>();
var roomSubject = room.Realm is not null ? $"{room.Name}, {room.Realm.Name}" : room.Name;
var tasks = new List<Task>();
var members = await scopedDb.ChatMembers
.Where(m => m.ChatRoomId == message.ChatRoomId)
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.MembersMentioned != null && message.MembersMentioned.Contains(m.Id)))
.ToListAsync();
foreach (var member in members)
.Where(m => m.Notify != ChatMemberNotify.Mentions || (message.MembersMetioned != null && message.MembersMetioned.Contains(m.Id)))
.AsAsyncEnumerable()
)
{
scopedWs.SendPacketToAccount(member.AccountId, new WebSocketPacket
ws.SendPacketToAccount(member.AccountId, new WebSocketPacket
{
Type = "messages.new",
Data = message
});
tasks.Add(scopedNty.DeliveryNotification(new Notification
tasks.Add(nty.DeliveryNotification(new Notification
{
AccountId = member.AccountId,
Topic = "messages.new",
Title = $"{sender.Nick ?? sender.Account.Nick} ({roomSubject})",
}));
}
await Task.WhenAll(tasks);
}
@ -108,7 +94,7 @@ public class ChatService(AppDatabase db, IServiceScopeFactory scopeFactory)
.Select(m => new MessageChange
{
MessageId = m.Id,
Action = m.DeletedAt != null ? "delete" : (m.UpdatedAt == null ? "create" : "update"),
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
})

View File

@ -9,10 +9,10 @@ namespace DysonNetwork.Sphere.Chat;
public class Message : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Type { get; set; } = null!;
[MaxLength(4096)] public string Content { get; set; } = string.Empty;
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; }
[Column(TypeName = "jsonb")] public List<Guid>? MembersMentioned { get; set; }
[MaxLength(36)] public string Nonce { get; set; } = null!;
[Column(TypeName = "jsonb")] public List<Guid>? MembersMetioned { get; set; }
public Instant? EditedAt { get; set; }
public ICollection<CloudFile> Attachments { get; set; } = new List<CloudFile>();
@ -28,33 +28,6 @@ public class Message : ModelBase
public ChatMember Sender { get; set; } = null!;
public long ChatRoomId { get; set; }
[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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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" });
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,8 @@ public class PostController(AppDatabase db, PostService ps, RelationshipService
.CountAsync();
var posts = await db.Posts
.Include(e => e.Publisher)
.Include(e => e.Publisher.Picture)
.Include(e => e.Publisher.Background)
.Include(e => e.ThreadedPost)
.Include(e => e.ForwardedPost)
.Include(e => e.Attachments)
@ -52,6 +54,8 @@ public class PostController(AppDatabase db, PostService ps, RelationshipService
var post = await db.Posts
.Where(e => e.Id == id)
.Include(e => e.Publisher)
.Include(e => e.Publisher.Picture)
.Include(e => e.Publisher.Background)
.Include(e => e.RepliedPost)
.Include(e => e.ThreadedPost)
.Include(e => e.ForwardedPost)

View File

@ -22,9 +22,7 @@ public class Publisher : ModelBase
[MaxLength(256)] public string Nick { get; set; } = string.Empty;
[MaxLength(4096)] public string? Bio { get; set; }
public string? PictureId { get; set; }
public CloudFile? Picture { get; set; }
public string? BackgroundId { get; set; }
public CloudFile? Background { get; set; }
[JsonIgnore] public ICollection<Post> Posts { get; set; } = new List<Post>();

View File

@ -20,6 +20,8 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
var publisher = await db.Publishers
.Where(e => e.Name == name)
.Include(e => e.Picture)
.Include(e => e.Background)
.FirstOrDefaultAsync();
if (publisher is null) return NotFound();

View File

@ -19,9 +19,7 @@ public class Realm : ModelBase
public bool IsCommunity { get; set; }
public bool IsPublic { get; set; }
public string? PictureId { get; set; }
public CloudFile? Picture { get; set; }
public string? BackgroundId { get; set; }
public CloudFile? Background { get; set; }
[JsonIgnore] public ICollection<RealmMember> Members { get; set; } = new List<RealmMember>();

View File

@ -34,6 +34,8 @@ public class RealmController(AppDatabase db, RealmService rs, FileService fs) :
.Where(m => m.AccountId == userId)
.Where(m => m.JoinedAt != null)
.Include(e => e.Realm)
.Include(e => e.Realm.Picture)
.Include(e => e.Realm.Background)
.Select(m => m.Realm)
.ToListAsync();
@ -53,6 +55,8 @@ public class RealmController(AppDatabase db, RealmService rs, FileService fs) :
.Where(m => m.AccountId == userId)
.Where(m => m.JoinedAt == null)
.Include(e => e.Realm)
.Include(e => e.Realm.Picture)
.Include(e => e.Realm.Background)
.ToListAsync();
return members.ToList();
@ -77,6 +81,8 @@ public class RealmController(AppDatabase db, RealmService rs, FileService fs) :
var realm = await db.Realms
.Where(p => p.Slug == slug)
.Include(publisher => publisher.Picture)
.Include(publisher => publisher.Background)
.FirstOrDefaultAsync();
if (realm is null) return NotFound();
@ -101,9 +107,6 @@ public class RealmController(AppDatabase db, RealmService rs, FileService fs) :
db.RealmMembers.Add(newMember);
await db.SaveChangesAsync();
newMember.Account = relatedUser;
await rs.SendInviteNotify(newMember);
return Ok(newMember);
}
@ -148,29 +151,6 @@ public class RealmController(AppDatabase db, RealmService rs, FileService fs) :
return NoContent();
}
[HttpDelete("{slug}/members/me")]
[Authorize]
public async Task<ActionResult> LeaveRealm(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var member = await db.RealmMembers
.Where(m => m.AccountId == userId)
.Where(m => m.Realm.Slug == slug)
.Where(m => m.JoinedAt != null)
.FirstOrDefaultAsync();
if (member is null) return NotFound();
if (member.Role == RealmMemberRole.Owner)
return StatusCode(403, "Owner cannot leave their own realm.");
db.RealmMembers.Remove(member);
await db.SaveChangesAsync();
return NoContent();
}
public class RealmRequest
{
[MaxLength(1024)] public string? Slug { get; set; }

View File

@ -1,12 +1,5 @@
using DysonNetwork.Sphere.Account;
namespace DysonNetwork.Sphere.Realm;
public class RealmService(AppDatabase db, NotificationService nty)
public class RealmService(AppDatabase db)
{
public async Task SendInviteNotify(RealmMember member)
{
await nty.SendNotification(member.Account, "invites.realms", "New Realm Invitation", null,
$"You just got invited to join {member.Realm.Name}");
}
}

View File

@ -7,7 +7,7 @@
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_network;Username=postgres;Password=postgres;Include Error Detail=True"
"App": "Host=localhost;Port=5432;Database=dyson_network;Username=postgres;Password=postgres"
},
"Authentication": {
"Schemes": {
@ -38,7 +38,7 @@
"Bucket": "solar-network",
"Endpoint": "0a70a6d1b7128888c823359d0008f4e1.r2.cloudflarestorage.com",
"SecretId": "8ff5d06c7b1639829d60bc6838a542e6",
"SecretKey": "fd58158c5201be16d1872c9209d9cf199421dae3c2f9972f94b2305976580d67",
"SecretKey": "4b000df2b31936e1ceb0aa48bbd4166214945bd7f83b85b26f9d164318587991",
"EnableSigned": true,
"EnableSsl": true
}
@ -47,17 +47,17 @@
"Captcha": {
"Provider": "cloudflare",
"ApiKey": "0x4AAAAAABCDUdOujj4feOb_",
"ApiSecret": "0x4AAAAAABCDUWABiJQweqlB7tYq-IqIm8U"
"ApiSecret": ""
},
"Notifications": {
"Push": {
"Production": true,
"Google": "./Keys/Solian.json",
"Google": "path/to/config.json",
"Apple": {
"PrivateKey": "./Keys/Solian.p8",
"PrivateKeyId": "4US4KSX4W6",
"TeamId": "W7HPZ53V6B",
"BundleIdentifier": "dev.solsynth.solian"
"PrivateKey": "path/to/cert.p8",
"PrivateKeyId": "",
"TeamId": "",
"BundleIdentifier": ""
}
}
},
@ -65,7 +65,7 @@
"Server": "smtpdm.aliyun.com",
"Port": 465,
"Username": "no-reply@mail.solsynth.dev",
"Password": "Pe1UeV405PMcQZgv",
"Password": "",
"FromAddress": "no-reply@mail.solsynth.dev",
"FromName": "Alphabot",
"SubjectPrefix": "Solar Network"