From 3c11c4f3be353d4cdd8022defb7cfaaec217d5b8 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Tue, 15 Jul 2025 01:54:27 +0800 Subject: [PATCH] :recycle: I have no idea what I have done --- .../Account/AccountServiceGrpc.cs | 12 +- DysonNetwork.Pass/Account/ActionLog.cs | 39 - DysonNetwork.Pass/Account/Relationship.cs | 2 +- DysonNetwork.Pass/Auth/AuthController.cs | 1 + .../Connection/WebSocketPacket.cs | 9 - .../Connection/WebSocketService.cs | 1 + DysonNetwork.Shared/Data/ActionLog.cs | 40 + DysonNetwork.Shared/Data/WebSocket.cs | 11 + DysonNetwork.Shared/Proto/GrpcTypeHelper.cs | 15 + DysonNetwork.Shared/Proto/account.proto | 10 +- .../Activity/ActivityService.cs | 13 +- DysonNetwork.Sphere/AppDatabase.cs | 53 - DysonNetwork.Sphere/Chat/ChatController.cs | 2 +- .../Chat/ChatRoomController.cs | 393 +++++--- .../Chat/ChatRoomController.cs.bak | 947 ++++++++++++++++++ DysonNetwork.Sphere/Chat/ChatService.cs | 91 +- .../Chat/Realtime/IRealtimeService.cs | 1 + .../Chat/Realtime/LivekitService.cs | 100 +- .../Chat/RealtimeCallController.cs | 15 +- DysonNetwork.Sphere/Developer/CustomApp.cs | 3 +- .../Developer/CustomAppController.cs | 8 +- .../Developer/CustomAppService.cs | 106 +- .../Developer/DeveloperController.cs | 25 +- .../Pages/Posts/PostDetail.cshtml.cs | 2 +- .../Pages/Shared/_Layout.cshtml | 26 - DysonNetwork.Sphere/Permission/Permission.cs | 59 -- .../Permission/PermissionMiddleware.cs | 51 - .../Permission/PermissionService.cs | 197 ---- DysonNetwork.Sphere/Post/PostController.cs | 131 ++- DysonNetwork.Sphere/Post/PostService.cs | 1 - DysonNetwork.Sphere/Publisher/Publisher.cs | 3 + .../Publisher/PublisherController.cs | 311 ++++-- .../PublisherSubscriptionController.cs | 9 +- DysonNetwork.Sphere/Realm/RealmController.cs | 2 - .../Startup/ServiceCollectionExtensions.cs | 2 +- 35 files changed, 1761 insertions(+), 930 deletions(-) create mode 100644 DysonNetwork.Shared/Data/ActionLog.cs create mode 100644 DysonNetwork.Shared/Data/WebSocket.cs create mode 100644 DysonNetwork.Sphere/Chat/ChatRoomController.cs.bak delete mode 100644 DysonNetwork.Sphere/Permission/Permission.cs delete mode 100644 DysonNetwork.Sphere/Permission/PermissionMiddleware.cs delete mode 100644 DysonNetwork.Sphere/Permission/PermissionService.cs diff --git a/DysonNetwork.Pass/Account/AccountServiceGrpc.cs b/DysonNetwork.Pass/Account/AccountServiceGrpc.cs index 1642ed4..9402ef5 100644 --- a/DysonNetwork.Pass/Account/AccountServiceGrpc.cs +++ b/DysonNetwork.Pass/Account/AccountServiceGrpc.cs @@ -99,22 +99,22 @@ public class AccountServiceGrpc( return response; } - public override async Task ListFriends( - ListUserRelationshipSimpleRequest request, ServerCallContext context) + public override async Task ListFriends( + ListRelationshipSimpleRequest request, ServerCallContext context) { var accountId = Guid.Parse(request.AccountId); var relationship = await relationships.ListAccountFriends(accountId); - var resp = new ListUserRelationshipSimpleResponse(); + var resp = new ListRelationshipSimpleResponse(); resp.AccountsId.AddRange(relationship.Select(x => x.ToString())); return resp; } - public override async Task ListBlocked( - ListUserRelationshipSimpleRequest request, ServerCallContext context) + public override async Task ListBlocked( + ListRelationshipSimpleRequest request, ServerCallContext context) { var accountId = Guid.Parse(request.AccountId); var relationship = await relationships.ListAccountBlocked(accountId); - var resp = new ListUserRelationshipSimpleResponse(); + var resp = new ListRelationshipSimpleResponse(); resp.AccountsId.AddRange(relationship.Select(x => x.ToString())); return resp; } diff --git a/DysonNetwork.Pass/Account/ActionLog.cs b/DysonNetwork.Pass/Account/ActionLog.cs index 904064f..827ff20 100644 --- a/DysonNetwork.Pass/Account/ActionLog.cs +++ b/DysonNetwork.Pass/Account/ActionLog.cs @@ -7,45 +7,6 @@ using Point = NetTopologySuite.Geometries.Point; namespace DysonNetwork.Pass.Account; -public abstract class ActionLogType -{ - public const string NewLogin = "login"; - public const string ChallengeAttempt = "challenges.attempt"; - public const string ChallengeSuccess = "challenges.success"; - public const string ChallengeFailure = "challenges.failure"; - public const string PostCreate = "posts.create"; - public const string PostUpdate = "posts.update"; - public const string PostDelete = "posts.delete"; - public const string PostReact = "posts.react"; - public const string MessageCreate = "messages.create"; - public const string MessageUpdate = "messages.update"; - public const string MessageDelete = "messages.delete"; - public const string MessageReact = "messages.react"; - public const string PublisherCreate = "publishers.create"; - public const string PublisherUpdate = "publishers.update"; - public const string PublisherDelete = "publishers.delete"; - public const string PublisherMemberInvite = "publishers.members.invite"; - public const string PublisherMemberJoin = "publishers.members.join"; - public const string PublisherMemberLeave = "publishers.members.leave"; - public const string PublisherMemberKick = "publishers.members.kick"; - public const string RealmCreate = "realms.create"; - public const string RealmUpdate = "realms.update"; - public const string RealmDelete = "realms.delete"; - public const string RealmInvite = "realms.invite"; - public const string RealmJoin = "realms.join"; - public const string RealmLeave = "realms.leave"; - public const string RealmKick = "realms.kick"; - public const string RealmAdjustRole = "realms.role.edit"; - public const string ChatroomCreate = "chatrooms.create"; - public const string ChatroomUpdate = "chatrooms.update"; - public const string ChatroomDelete = "chatrooms.delete"; - public const string ChatroomInvite = "chatrooms.invite"; - public const string ChatroomJoin = "chatrooms.join"; - public const string ChatroomLeave = "chatrooms.leave"; - public const string ChatroomKick = "chatrooms.kick"; - public const string ChatroomAdjustRole = "chatrooms.role.edit"; -} - public class ActionLog : ModelBase { public Guid Id { get; set; } = Guid.NewGuid(); diff --git a/DysonNetwork.Pass/Account/Relationship.cs b/DysonNetwork.Pass/Account/Relationship.cs index 0b169b7..9c605ee 100644 --- a/DysonNetwork.Pass/Account/Relationship.cs +++ b/DysonNetwork.Pass/Account/Relationship.cs @@ -29,7 +29,7 @@ public class Relationship : ModelBase RelatedId = RelatedId.ToString(), Account = Account.ToProtoValue(), Related = Related.ToProtoValue(), - Type = (int)Status, + Status = (int)Status, CreatedAt = CreatedAt.ToTimestamp(), UpdatedAt = UpdatedAt.ToTimestamp() }; diff --git a/DysonNetwork.Pass/Auth/AuthController.cs b/DysonNetwork.Pass/Auth/AuthController.cs index 9722004..5232165 100644 --- a/DysonNetwork.Pass/Auth/AuthController.cs +++ b/DysonNetwork.Pass/Auth/AuthController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using NodaTime; using Microsoft.EntityFrameworkCore; using DysonNetwork.Pass.Account; +using DysonNetwork.Shared.Data; using DysonNetwork.Shared.GeoIp; namespace DysonNetwork.Pass.Auth; diff --git a/DysonNetwork.Pusher/Connection/WebSocketPacket.cs b/DysonNetwork.Pusher/Connection/WebSocketPacket.cs index f20b961..cbb2f50 100644 --- a/DysonNetwork.Pusher/Connection/WebSocketPacket.cs +++ b/DysonNetwork.Pusher/Connection/WebSocketPacket.cs @@ -4,15 +4,6 @@ using NodaTime.Serialization.SystemTextJson; namespace DysonNetwork.Pusher.Connection; -public abstract class WebSocketPacketType -{ - public const string Error = "error"; - public const string MessageNew = "messages.new"; - public const string MessageUpdate = "messages.update"; - public const string MessageDelete = "messages.delete"; - public const string CallParticipantsUpdate = "call.participants.update"; -} - public class WebSocketPacket { public string Type { get; set; } = null!; diff --git a/DysonNetwork.Pusher/Connection/WebSocketService.cs b/DysonNetwork.Pusher/Connection/WebSocketService.cs index c9d9af9..94eeaaf 100644 --- a/DysonNetwork.Pusher/Connection/WebSocketService.cs +++ b/DysonNetwork.Pusher/Connection/WebSocketService.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Net.WebSockets; +using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Proto; namespace DysonNetwork.Pusher.Connection; diff --git a/DysonNetwork.Shared/Data/ActionLog.cs b/DysonNetwork.Shared/Data/ActionLog.cs new file mode 100644 index 0000000..dd2e805 --- /dev/null +++ b/DysonNetwork.Shared/Data/ActionLog.cs @@ -0,0 +1,40 @@ +namespace DysonNetwork.Shared.Data; + +public abstract class ActionLogType +{ + public const string NewLogin = "login"; + public const string ChallengeAttempt = "challenges.attempt"; + public const string ChallengeSuccess = "challenges.success"; + public const string ChallengeFailure = "challenges.failure"; + public const string PostCreate = "posts.create"; + public const string PostUpdate = "posts.update"; + public const string PostDelete = "posts.delete"; + public const string PostReact = "posts.react"; + public const string MessageCreate = "messages.create"; + public const string MessageUpdate = "messages.update"; + public const string MessageDelete = "messages.delete"; + public const string MessageReact = "messages.react"; + public const string PublisherCreate = "publishers.create"; + public const string PublisherUpdate = "publishers.update"; + public const string PublisherDelete = "publishers.delete"; + public const string PublisherMemberInvite = "publishers.members.invite"; + public const string PublisherMemberJoin = "publishers.members.join"; + public const string PublisherMemberLeave = "publishers.members.leave"; + public const string PublisherMemberKick = "publishers.members.kick"; + public const string RealmCreate = "realms.create"; + public const string RealmUpdate = "realms.update"; + public const string RealmDelete = "realms.delete"; + public const string RealmInvite = "realms.invite"; + public const string RealmJoin = "realms.join"; + public const string RealmLeave = "realms.leave"; + public const string RealmKick = "realms.kick"; + public const string RealmAdjustRole = "realms.role.edit"; + public const string ChatroomCreate = "chatrooms.create"; + public const string ChatroomUpdate = "chatrooms.update"; + public const string ChatroomDelete = "chatrooms.delete"; + public const string ChatroomInvite = "chatrooms.invite"; + public const string ChatroomJoin = "chatrooms.join"; + public const string ChatroomLeave = "chatrooms.leave"; + public const string ChatroomKick = "chatrooms.kick"; + public const string ChatroomAdjustRole = "chatrooms.role.edit"; +} \ No newline at end of file diff --git a/DysonNetwork.Shared/Data/WebSocket.cs b/DysonNetwork.Shared/Data/WebSocket.cs new file mode 100644 index 0000000..b7f8be1 --- /dev/null +++ b/DysonNetwork.Shared/Data/WebSocket.cs @@ -0,0 +1,11 @@ +namespace DysonNetwork.Shared.Data; + +public abstract class WebSocketPacketType +{ + public const string Error = "error"; + public const string MessageNew = "messages.new"; + public const string MessageUpdate = "messages.update"; + public const string MessageDelete = "messages.delete"; + public const string CallParticipantsUpdate = "call.participants.update"; +} + diff --git a/DysonNetwork.Shared/Proto/GrpcTypeHelper.cs b/DysonNetwork.Shared/Proto/GrpcTypeHelper.cs index d81d092..7ed044c 100644 --- a/DysonNetwork.Shared/Proto/GrpcTypeHelper.cs +++ b/DysonNetwork.Shared/Proto/GrpcTypeHelper.cs @@ -86,4 +86,19 @@ public abstract class GrpcTypeHelper _ => JsonConvert.DeserializeObject(JsonConvert.SerializeObject(value, SerializerSettings)) }; } + + public static Value ConvertObjectToValue(object? obj) + { + return obj switch + { + string s => Value.ForString(s), + int i => Value.ForNumber(i), + long l => Value.ForNumber(l), + float f => Value.ForNumber(f), + double d => Value.ForNumber(d), + bool b => Value.ForBool(b), + null => Value.ForNull(), + _ => Value.ForString(JsonConvert.SerializeObject(obj, SerializerSettings)) // fallback to JSON string + }; + } } \ No newline at end of file diff --git a/DysonNetwork.Shared/Proto/account.proto b/DysonNetwork.Shared/Proto/account.proto index 7aad9c7..1a2a0cb 100644 --- a/DysonNetwork.Shared/Proto/account.proto +++ b/DysonNetwork.Shared/Proto/account.proto @@ -159,7 +159,7 @@ message Relationship { string related_id = 2; optional Account account = 3; optional Account related = 4; - int32 type = 5; + int32 status = 5; google.protobuf.Timestamp created_at = 6; google.protobuf.Timestamp updated_at = 7; } @@ -218,8 +218,8 @@ service AccountService { rpc GetRelationship(GetRelationshipRequest) returns (GetRelationshipResponse) {} rpc HasRelationship(GetRelationshipRequest) returns (google.protobuf.BoolValue) {} - rpc ListFriends(ListUserRelationshipSimpleRequest) returns (ListUserRelationshipSimpleResponse) {} - rpc ListBlocked(ListUserRelationshipSimpleRequest) returns (ListUserRelationshipSimpleResponse) {} + rpc ListFriends(ListRelationshipSimpleRequest) returns (ListRelationshipSimpleResponse) {} + rpc ListBlocked(ListRelationshipSimpleRequest) returns (ListRelationshipSimpleResponse) {} } // ActionLogService provides operations for action logs @@ -396,10 +396,10 @@ message GetRelationshipResponse { optional Relationship relationship = 1; } -message ListUserRelationshipSimpleRequest { +message ListRelationshipSimpleRequest { string account_id = 1; } -message ListUserRelationshipSimpleResponse { +message ListRelationshipSimpleResponse { repeated string accounts_id = 1; } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Activity/ActivityService.cs b/DysonNetwork.Sphere/Activity/ActivityService.cs index 823c84b..b1cf614 100644 --- a/DysonNetwork.Sphere/Activity/ActivityService.cs +++ b/DysonNetwork.Sphere/Activity/ActivityService.cs @@ -1,5 +1,4 @@ using DysonNetwork.Shared.Proto; -using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.WebReader; using DysonNetwork.Sphere.Discovery; using DysonNetwork.Sphere.Post; @@ -12,9 +11,10 @@ namespace DysonNetwork.Sphere.Activity; public class ActivityService( AppDatabase db, PublisherService pub, - RelationshipService rels, PostService ps, - DiscoveryService ds) + DiscoveryService ds, + AccountService.AccountServiceClient accounts +) { private static double CalculateHotRank(Post.Post post, Instant now) { @@ -125,7 +125,9 @@ public class ActivityService( ) { var activities = new List(); - var userFriends = await rels.ListAccountFriends(currentUser); + var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest + { AccountId = currentUser.Id }); + var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id)); debugInclude ??= []; @@ -242,8 +244,7 @@ public class ActivityService( .ToList(); // Formatting data - foreach (var post in rankedPosts) - activities.Add(post.ToActivity()); + activities.AddRange(rankedPosts.Select(post => post.ToActivity())); if (activities.Count == 0) activities.Add(Activity.Empty()); diff --git a/DysonNetwork.Sphere/AppDatabase.cs b/DysonNetwork.Sphere/AppDatabase.cs index e77bcbc..9b851d5 100644 --- a/DysonNetwork.Sphere/AppDatabase.cs +++ b/DysonNetwork.Sphere/AppDatabase.cs @@ -2,7 +2,6 @@ using System.Linq.Expressions; using System.Reflection; using DysonNetwork.Sphere.Chat; using DysonNetwork.Sphere.Developer; -using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Post; using DysonNetwork.Sphere.Publisher; using DysonNetwork.Sphere.Realm; @@ -32,10 +31,6 @@ public class AppDatabase( IConfiguration configuration ) : DbContext(options) { - public DbSet PermissionNodes { get; set; } - public DbSet PermissionGroups { get; set; } - public DbSet PermissionGroupMembers { get; set; } - public DbSet Publishers { get; set; } public DbSet PublisherMembers { get; set; } public DbSet PublisherSubscriptions { get; set; } @@ -78,38 +73,6 @@ public class AppDatabase( .UseNodaTime() ).UseSnakeCaseNamingConvention(); - optionsBuilder.UseAsyncSeeding(async (context, _, cancellationToken) => - { - var defaultPermissionGroup = await context.Set() - .FirstOrDefaultAsync(g => g.Key == "default", cancellationToken); - if (defaultPermissionGroup is null) - { - context.Set().Add(new PermissionGroup - { - Key = "default", - Nodes = new List - { - "posts.create", - "posts.react", - "publishers.create", - "files.create", - "chat.create", - "chat.messages.create", - "chat.realtime.create", - "accounts.statuses.create", - "accounts.statuses.update", - "stickers.packs.create", - "stickers.create" - }.Select(permission => - PermissionService.NewPermissionNode("group:default", "global", permission, true)) - .ToList() - }); - await context.SaveChangesAsync(cancellationToken); - } - }); - - optionsBuilder.UseSeeding((context, _) => {}); - base.OnConfiguring(optionsBuilder); } @@ -117,14 +80,6 @@ public class AppDatabase( { base.OnModelCreating(modelBuilder); - modelBuilder.Entity() - .HasKey(pg => new { pg.GroupId, pg.Actor }); - modelBuilder.Entity() - .HasOne(pg => pg.Group) - .WithMany(g => g.Members) - .HasForeignKey(pg => pg.GroupId) - .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() .HasKey(pm => new { pm.PublisherId, pm.AccountId }); modelBuilder.Entity() @@ -289,14 +244,6 @@ public class AppDatabaseRecyclingJob(AppDatabase db, ILogger x.ExpiredAt != null && x.ExpiredAt <= now) - .ExecuteDeleteAsync(); - logger.LogDebug("Removed {Count} records of expired permission group members.", affectedRows); - logger.LogInformation("Deleting soft-deleted records..."); var threshold = now - Duration.FromDays(7); diff --git a/DysonNetwork.Sphere/Chat/ChatController.cs b/DysonNetwork.Sphere/Chat/ChatController.cs index dc1a5db..ee54c05 100644 --- a/DysonNetwork.Sphere/Chat/ChatController.cs +++ b/DysonNetwork.Sphere/Chat/ChatController.cs @@ -1,9 +1,9 @@ using System.ComponentModel.DataAnnotations; using System.Text.RegularExpressions; +using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Content; using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Proto; -using DysonNetwork.Sphere.Permission; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; diff --git a/DysonNetwork.Sphere/Chat/ChatRoomController.cs b/DysonNetwork.Sphere/Chat/ChatRoomController.cs index f9cb452..c5304d9 100644 --- a/DysonNetwork.Sphere/Chat/ChatRoomController.cs +++ b/DysonNetwork.Sphere/Chat/ChatRoomController.cs @@ -1,10 +1,13 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.ComponentModel.DataAnnotations; +using DysonNetwork.Shared; +using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Proto; using DysonNetwork.Sphere.Localization; using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Realm; +using Grpc.Core; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Localization; using NodaTime; @@ -20,7 +23,9 @@ public class ChatRoomController( IStringLocalizer localizer, AccountService.AccountServiceClient accounts, FileService.FileServiceClient files, - FileReferenceService.FileReferenceServiceClient fileRefs + FileReferenceService.FileReferenceServiceClient fileRefs, + ActionLogService.ActionLogServiceClient als, + PusherService.PusherServiceClient pusher ) : ControllerBase { [HttpGet("{id:guid}")] @@ -123,10 +128,14 @@ public class ChatRoomController( db.ChatRooms.Add(dmRoom); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( - ActionLogType.ChatroomCreate, - new Dictionary { { "chatroom_id", dmRoom.Id } }, Request - ); + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "chatrooms.create", + Meta = { { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(dmRoom.Id.ToString()) } }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); var invitedMember = dmRoom.Members.First(m => m.AccountId == request.RelatedUserId); invitedMember.ChatRoom = dmRoom; @@ -200,23 +209,44 @@ public class ChatRoomController( if (request.PictureId is not null) { - chatRoom.Picture = (await db.Files.FindAsync(request.PictureId))?.ToReferenceObject(); - if (chatRoom.Picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud."); - await fileRefs.CreateReferenceAsync( - new CreateReferenceRequest + try + { + var fileResponse = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId }); + if (fileResponse == null) return BadRequest("Invalid picture id, unable to find the file on cloud."); + chatRoom.Picture = CloudFileReferenceObject.FromProtoValue(fileResponse); + + await fileRefs.CreateReferenceAsync(new CreateReferenceRequest { - FileId = publisher.Picture.Id, - Usage = "publisher.picture", - ResourceId = publisher.ResourceIdentifier, - } - ); + FileId = fileResponse.Id, + Usage = "chatroom.picture", + ResourceId = chatRoom.ResourceIdentifier, + }); + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) + { + return BadRequest("Invalid picture id, unable to find the file on cloud."); + } } if (request.BackgroundId is not null) { - chatRoom.Background = (await db.Files.FindAsync(request.BackgroundId))?.ToReferenceObject(); - if (chatRoom.Background is null) + try + { + var fileResponse = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId }); + if (fileResponse == null) return BadRequest("Invalid background id, unable to find the file on cloud."); + chatRoom.Background = CloudFileReferenceObject.FromProtoValue(fileResponse); + + await fileRefs.CreateReferenceAsync(new CreateReferenceRequest + { + FileId = fileResponse.Id, + Usage = "chatroom.background", + ResourceId = chatRoom.ResourceIdentifier, + }); + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) + { return BadRequest("Invalid background id, unable to find the file on cloud."); + } } db.ChatRooms.Add(chatRoom); @@ -225,23 +255,33 @@ public class ChatRoomController( var chatRoomResourceId = $"chatroom:{chatRoom.Id}"; if (chatRoom.Picture is not null) - await fileRefService.CreateReferenceAsync( - chatRoom.Picture.Id, - "chat.room.picture", - chatRoomResourceId - ); + { + await fileRefs.CreateReferenceAsync(new CreateReferenceRequest + { + FileId = chatRoom.Picture.Id, + Usage = "chat.room.picture", + ResourceId = chatRoomResourceId + }); + } if (chatRoom.Background is not null) - await fileRefService.CreateReferenceAsync( - chatRoom.Background.Id, - "chat.room.background", - chatRoomResourceId - ); + { + await fileRefs.CreateReferenceAsync(new CreateReferenceRequest + { + FileId = chatRoom.Background.Id, + Usage = "chat.room.background", + ResourceId = chatRoomResourceId + }); + } - als.CreateActionLogFromRequest( - ActionLogType.ChatroomCreate, - new Dictionary { { "chatroom_id", chatRoom.Id } }, Request - ); + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "chatrooms.create", + Meta = { { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(chatRoom.Id.ToString()) } }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); return Ok(chatRoom); } @@ -279,38 +319,62 @@ public class ChatRoomController( if (request.PictureId is not null) { - var picture = await db.Files.FindAsync(request.PictureId); - if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud."); + try + { + var fileResponse = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId }); + if (fileResponse == null) return BadRequest("Invalid picture id, unable to find the file on cloud."); - // Remove old references for pictures - await fileRefService.DeleteResourceReferencesAsync(chatRoom.ResourceIdentifier, "chat.room.picture"); + // Remove old references for pictures + await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest + { + ResourceId = chatRoom.ResourceIdentifier, + Usage = "chat.room.picture" + }); - // Add a new reference - await fileRefService.CreateReferenceAsync( - picture.Id, - "chat.room.picture", - chatRoom.ResourceIdentifier - ); + // Add a new reference + await fileRefs.CreateReferenceAsync(new CreateReferenceRequest + { + FileId = fileResponse.Id, + Usage = "chat.room.picture", + ResourceId = chatRoom.ResourceIdentifier + }); - chatRoom.Picture = picture.ToReferenceObject(); + chatRoom.Picture = CloudFileReferenceObject.FromProtoValue(fileResponse); + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) + { + return BadRequest("Invalid picture id, unable to find the file on cloud."); + } } if (request.BackgroundId is not null) { - var background = await db.Files.FindAsync(request.BackgroundId); - if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud."); + try + { + var fileResponse = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId }); + if (fileResponse == null) return BadRequest("Invalid background id, unable to find the file on cloud."); - // Remove old references for backgrounds - await fileRefService.DeleteResourceReferencesAsync(chatRoom.ResourceIdentifier, "chat.room.background"); + // Remove old references for backgrounds + await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest + { + ResourceId = chatRoom.ResourceIdentifier, + Usage = "chat.room.background" + }); - // Add a new reference - await fileRefService.CreateReferenceAsync( - background.Id, - "chat.room.background", - chatRoom.ResourceIdentifier - ); + // Add a new reference + await fileRefs.CreateReferenceAsync(new CreateReferenceRequest + { + FileId = fileResponse.Id, + Usage = "chat.room.background", + ResourceId = chatRoom.ResourceIdentifier + }); - chatRoom.Background = background.ToReferenceObject(); + chatRoom.Background = CloudFileReferenceObject.FromProtoValue(fileResponse); + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) + { + return BadRequest("Invalid background id, unable to find the file on cloud."); + } } if (request.Name is not null) @@ -325,10 +389,14 @@ public class ChatRoomController( db.ChatRooms.Update(chatRoom); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( - ActionLogType.ChatroomUpdate, - new Dictionary { { "chatroom_id", chatRoom.Id } }, Request - ); + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "chatrooms.update", + Meta = { { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(chatRoom.Id.ToString()) } }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); return Ok(chatRoom); } @@ -355,15 +423,22 @@ public class ChatRoomController( var chatRoomResourceId = $"chatroom:{chatRoom.Id}"; // Delete all file references for this chat room - await fileRefService.DeleteResourceReferencesAsync(chatRoomResourceId); + await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest + { + ResourceId = chatRoomResourceId + }); db.ChatRooms.Remove(chatRoom); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( - ActionLogType.ChatroomDelete, - new Dictionary { { "chatroom_id", chatRoom.Id } }, Request - ); + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "chatrooms.delete", + Meta = { { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(chatRoom.Id.ToString()) } }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); return NoContent(); } @@ -411,44 +486,44 @@ public class ChatRoomController( .Include(m => m.Account) .Include(m => m.Account.Profile); - if (withStatus) - { - var members = await query - .OrderBy(m => m.JoinedAt) - .ToListAsync(); + // if (withStatus) + // { + // var members = await query + // .OrderBy(m => m.JoinedAt) + // .ToListAsync(); + // + // var memberStatuses = await aes.GetStatuses(members.Select(m => m.AccountId).ToList()); + // + // if (!string.IsNullOrEmpty(status)) + // { + // members = members.Where(m => + // memberStatuses.TryGetValue(m.AccountId, out var s) && s.Label != null && + // s.Label.Equals(status, StringComparison.OrdinalIgnoreCase)).ToList(); + // } + // + // members = members.OrderByDescending(m => memberStatuses.TryGetValue(m.AccountId, out var s) && s.IsOnline) + // .ToList(); + // + // var total = members.Count; + // Response.Headers.Append("X-Total", total.ToString()); + // + // var result = members.Skip(skip).Take(take).ToList(); + // + // return Ok(result); + // } + // else + // { + var total = await query.CountAsync(); + Response.Headers.Append("X-Total", total.ToString()); - var memberStatuses = await aes.GetStatuses(members.Select(m => m.AccountId).ToList()); + var members = await query + .OrderBy(m => m.JoinedAt) + .Skip(skip) + .Take(take) + .ToListAsync(); - if (!string.IsNullOrEmpty(status)) - { - members = members.Where(m => - memberStatuses.TryGetValue(m.AccountId, out var s) && s.Label != null && - s.Label.Equals(status, StringComparison.OrdinalIgnoreCase)).ToList(); - } - - members = members.OrderByDescending(m => memberStatuses.TryGetValue(m.AccountId, out var s) && s.IsOnline) - .ToList(); - - var total = members.Count; - Response.Headers.Append("X-Total", total.ToString()); - - var result = members.Skip(skip).Take(take).ToList(); - - return Ok(result); - } - else - { - 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); - } + return Ok(members); + // } } @@ -463,14 +538,23 @@ public class ChatRoomController( public async Task> InviteMember(Guid roomId, [FromBody] ChatMemberRequest request) { - if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); var accountId = Guid.Parse(currentUser.Id); - var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId); - if (relatedUser is null) return BadRequest("Related user was not found"); + // Get related user account + var relatedUser = + await accounts.GetAccountAsync(new GetAccountRequest { Id = request.RelatedUserId.ToString() }); + if (relatedUser == null) return BadRequest("Related user was not found"); - if (await rels.HasRelationshipWithStatus(Guid.Parse(currentUser.Id), relatedUser.Id, - RelationshipStatus.Blocked)) + // Check if the user has blocked the current user + var relationship = await accounts.GetRelationshipAsync(new GetRelationshipRequest + { + AccountId = currentUser.Id, + RelatedId = relatedUser.Id, + Status = -100 + }); + + if (relationship != null && relationship.Relationship.Status == -100) return StatusCode(403, "You cannot invite a user that blocked you."); var chatRoom = await db.ChatRooms @@ -512,7 +596,7 @@ public class ChatRoomController( var newMember = new ChatMember { - AccountId = relatedUser.Id, + AccountId = Guid.Parse(relatedUser.Id), ChatRoomId = roomId, Role = request.Role, }; @@ -523,10 +607,18 @@ public class ChatRoomController( newMember.ChatRoom = chatRoom; await _SendInviteNotify(newMember, currentUser); - als.CreateActionLogFromRequest( - ActionLogType.ChatroomInvite, - new Dictionary { { "chatroom_id", chatRoom.Id }, { "account_id", relatedUser.Id } }, Request - ); + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "chatrooms.invite", + Meta = + { + { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(chatRoom.Id.ToString()) }, + { "account_id", Google.Protobuf.WellKnownTypes.Value.ForString(relatedUser.Id.ToString()) } + }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); return Ok(newMember); } @@ -575,10 +667,14 @@ public class ChatRoomController( await db.SaveChangesAsync(); _ = crs.PurgeRoomMembersCache(roomId); - als.CreateActionLogFromRequest( - ActionLogType.ChatroomJoin, - new Dictionary { { "chatroom_id", roomId } }, Request - ); + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = ActionLogType.ChatroomJoin, + Meta = { { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(roomId.ToString()) } }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); return Ok(member); } @@ -688,12 +784,19 @@ public class ChatRoomController( await crs.PurgeRoomMembersCache(roomId); - als.CreateActionLogFromRequest( - ActionLogType.RealmAdjustRole, - new Dictionary - { { "chatroom_id", roomId }, { "account_id", memberId }, { "new_role", newRole } }, - Request - ); + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "chatrooms.role.edit", + Meta = + { + { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(roomId.ToString()) }, + { "account_id", Google.Protobuf.WellKnownTypes.Value.ForString(memberId.ToString()) }, + { "new_role", Google.Protobuf.WellKnownTypes.Value.ForNumber(newRole) } + }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); return Ok(targetMember); } @@ -738,10 +841,18 @@ public class ChatRoomController( await db.SaveChangesAsync(); _ = crs.PurgeRoomMembersCache(roomId); - als.CreateActionLogFromRequest( - ActionLogType.ChatroomKick, - new Dictionary { { "chatroom_id", roomId }, { "account_id", memberId } }, Request - ); + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "chatrooms.kick", + Meta = + { + { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(roomId.ToString()) }, + { "account_id", Google.Protobuf.WellKnownTypes.Value.ForString(memberId.ToString()) } + }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); return NoContent(); } @@ -780,10 +891,14 @@ public class ChatRoomController( await db.SaveChangesAsync(); _ = crs.PurgeRoomMembersCache(roomId); - als.CreateActionLogFromRequest( - ActionLogType.ChatroomJoin, - new Dictionary { { "chatroom_id", roomId } }, Request - ); + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = ActionLogType.ChatroomJoin, + Meta = { { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(roomId.ToString()) } }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); return Ok(chatRoom); } @@ -817,10 +932,14 @@ public class ChatRoomController( await db.SaveChangesAsync(); await crs.PurgeRoomMembersCache(roomId); - als.CreateActionLogFromRequest( - ActionLogType.ChatroomLeave, - new Dictionary { { "chatroom_id", roomId } }, Request - ); + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = ActionLogType.ChatroomLeave, + Meta = { { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(roomId.ToString()) } }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); return NoContent(); } @@ -833,7 +952,19 @@ public class ChatRoomController( ? localizer["ChatInviteDirectBody", sender.Nick] : localizer["ChatInviteBody", member.ChatRoom.Name ?? "Unnamed"]; - AccountService.SetCultureInfo(member.Account); - await nty.SendNotification(member.Account, "invites.chats", title, null, body, actionUri: "/chat"); + CultureService.SetCultureInfo(member.Account); + await pusher.SendPushNotificationToUserAsync( + new SendPushNotificationToUserRequest + { + UserId = member.Account.Id.ToString(), + Notification = new PushNotification + { + Topic = "invites.chats", + Title = title, + Body = body, + IsSavable = false + } + } + ); } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Chat/ChatRoomController.cs.bak b/DysonNetwork.Sphere/Chat/ChatRoomController.cs.bak new file mode 100644 index 0000000..f5d702c --- /dev/null +++ b/DysonNetwork.Sphere/Chat/ChatRoomController.cs.bak @@ -0,0 +1,947 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; +using DysonNetwork.Shared.Data; +using DysonNetwork.Shared.Proto; +using DysonNetwork.Sphere.Localization; +using DysonNetwork.Sphere.Permission; +using DysonNetwork.Sphere.Realm; +using Grpc.Core; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Localization; +using NodaTime; + +namespace DysonNetwork.Sphere.Chat; + +[ApiController] +[Route("/api/chat")] +public class ChatRoomController( + AppDatabase db, + ChatRoomService crs, + RealmService rs, + IStringLocalizer localizer, + AccountService.AccountServiceClient accounts, + FileService.FileServiceClient files, + FileReferenceService.FileReferenceServiceClient fileRefs, + ActionLogService.ActionLogServiceClient als +) : ControllerBase +{ + [HttpGet("{id:guid}")] + public async Task> GetChatRoom(Guid id) + { + var chatRoom = await db.ChatRooms + .Where(c => c.Id == id) + .Include(e => e.Realm) + .FirstOrDefaultAsync(); + if (chatRoom is null) return NotFound(); + if (chatRoom.Type != ChatRoomType.DirectMessage) return Ok(chatRoom); + + if (HttpContext.Items["CurrentUser"] is Account currentUser) + chatRoom = await crs.LoadDirectMessageMembers(chatRoom, Guid.Parse(currentUser.Id)); + + return Ok(chatRoom); + } + + [HttpGet] + [Authorize] + public async Task>> ListJoinedChatRooms() + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + var accountId = Guid.Parse(currentUser.Id); + + var chatRooms = await db.ChatMembers + .Where(m => m.AccountId == accountId) + .Where(m => m.JoinedAt != null) + .Where(m => m.LeaveAt == null) + .Include(m => m.ChatRoom) + .Select(m => m.ChatRoom) + .ToListAsync(); + chatRooms = await crs.LoadDirectMessageMembers(chatRooms, accountId); + chatRooms = await crs.SortChatRoomByLastMessage(chatRooms); + + return Ok(chatRooms); + } + + public class DirectMessageRequest + { + [Required] public Guid RelatedUserId { get; set; } + } + + [HttpPost("direct")] + [Authorize] + public async Task> CreateDirectMessage([FromBody] DirectMessageRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser) + return Unauthorized(); + + var relatedUser = await accounts.GetAccountAsync( + new GetAccountRequest { Id = request.RelatedUserId.ToString() } + ); + if (relatedUser is null) + return BadRequest("Related user was not found"); + + var hasBlocked = await accounts.HasRelationshipAsync(new GetRelationshipRequest() + { + AccountId = currentUser.Id, + RelatedId = request.RelatedUserId.ToString(), + Status = -100 + }); + if (hasBlocked?.Value ?? false) + return StatusCode(403, "You cannot create direct message with a user that blocked you."); + + // Check if DM already exists between these users + var existingDm = await db.ChatRooms + .Include(c => c.Members) + .Where(c => c.Type == ChatRoomType.DirectMessage && c.Members.Count == 2) + .Where(c => c.Members.Any(m => m.AccountId == Guid.Parse(currentUser.Id))) + .Where(c => c.Members.Any(m => m.AccountId == request.RelatedUserId)) + .FirstOrDefaultAsync(); + + if (existingDm != null) + return BadRequest("You already have a DM with this user."); + + // Create new DM chat room + var dmRoom = new ChatRoom + { + Type = ChatRoomType.DirectMessage, + IsPublic = false, + Members = new List + { + new() + { + AccountId = Guid.Parse(currentUser.Id), + Role = ChatMemberRole.Owner, + JoinedAt = Instant.FromDateTimeUtc(DateTime.UtcNow) + }, + new() + { + AccountId = request.RelatedUserId, + Role = ChatMemberRole.Member, + JoinedAt = null, // Pending status + } + } + }; + + db.ChatRooms.Add(dmRoom); + await db.SaveChangesAsync(); + + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "chatrooms.create", + Meta = { { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(dmRoom.Id.ToString()) } }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); + + var invitedMember = dmRoom.Members.First(m => m.AccountId == request.RelatedUserId); + invitedMember.ChatRoom = dmRoom; + await _SendInviteNotify(invitedMember, currentUser); + + return Ok(dmRoom); + } + + [HttpGet("direct/{accountId:guid}")] + [Authorize] + public async Task> GetDirectChatRoom(Guid accountId) + { + if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser) + return Unauthorized(); + + var room = await db.ChatRooms + .Include(c => c.Members) + .Where(c => c.Type == ChatRoomType.DirectMessage && c.Members.Count == 2) + .Where(c => c.Members.Any(m => m.AccountId == Guid.Parse(currentUser.Id))) + .Where(c => c.Members.Any(m => m.AccountId == accountId)) + .FirstOrDefaultAsync(); + if (room is null) return NotFound(); + + return Ok(room); + } + + public class ChatRoomRequest + { + [Required] [MaxLength(1024)] public string? Name { get; set; } + [MaxLength(4096)] public string? Description { get; set; } + [MaxLength(32)] public string? PictureId { get; set; } + [MaxLength(32)] public string? BackgroundId { get; set; } + public Guid? RealmId { get; set; } + public bool? IsCommunity { get; set; } + public bool? IsPublic { get; set; } + } + + [HttpPost] + [Authorize] + [RequiredPermission("global", "chat.create")] + public async Task> CreateChatRoom(ChatRoomRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser) return Unauthorized(); + if (request.Name is null) return BadRequest("You cannot create a chat room without a name."); + + var chatRoom = new ChatRoom + { + Name = request.Name, + Description = request.Description ?? string.Empty, + IsCommunity = request.IsCommunity ?? false, + IsPublic = request.IsPublic ?? false, + Type = ChatRoomType.Group, + Members = new List + { + new() + { + Role = ChatMemberRole.Owner, + AccountId = Guid.Parse(currentUser.Id), + JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow) + } + } + }; + + if (request.RealmId is not null) + { + if (!await rs.IsMemberWithRole(request.RealmId.Value, Guid.Parse(currentUser.Id), + RealmMemberRole.Moderator)) + return StatusCode(403, "You need at least be a moderator to create chat linked to the realm."); + chatRoom.RealmId = request.RealmId; + } + + if (request.PictureId is not null) + { + try + { + var fileResponse = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId }); + if (fileResponse == null) return BadRequest("Invalid picture id, unable to find the file on cloud."); + chatRoom.Picture = CloudFileReferenceObject.FromProtoValue(fileResponse); + + await fileRefs.CreateReferenceAsync(new CreateReferenceRequest + { + FileId = fileResponse.Id, + Usage = "chatroom.picture", + ResourceId = chatRoom.ResourceIdentifier, + }); + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) + { + return BadRequest("Invalid picture id, unable to find the file on cloud."); + } + } + + if (request.BackgroundId is not null) + { + try + { + var fileResponse = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId }); + if (fileResponse == null) return BadRequest("Invalid background id, unable to find the file on cloud."); + chatRoom.Background = CloudFileReferenceObject.FromProtoValue(fileResponse); + + await fileRefs.CreateReferenceAsync(new CreateReferenceRequest + { + FileId = fileResponse.Id, + Usage = "chatroom.background", + ResourceId = chatRoom.ResourceIdentifier, + }); + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) + { + return BadRequest("Invalid background id, unable to find the file on cloud."); + } + } + + db.ChatRooms.Add(chatRoom); + await db.SaveChangesAsync(); + + var chatRoomResourceId = $"chatroom:{chatRoom.Id}"; + + if (chatRoom.Picture is not null) + { + await fileRefs.CreateReferenceAsync(new CreateReferenceRequest + { + FileId = chatRoom.Picture.Id, + Usage = "chat.room.picture", + ResourceId = chatRoomResourceId + }); + } + + if (chatRoom.Background is not null) + { + await fileRefs.CreateReferenceAsync(new CreateReferenceRequest + { + FileId = chatRoom.Background.Id, + Usage = "chat.room.background", + ResourceId = chatRoomResourceId + }); + } + + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "chatrooms.create", + Meta = { { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(chatRoom.Id.ToString()) } }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); + + return Ok(chatRoom); + } + + + [HttpPatch("{id:guid}")] + public async Task> UpdateChatRoom(Guid id, [FromBody] ChatRoomRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser) return Unauthorized(); + + var chatRoom = await db.ChatRooms + .Where(e => e.Id == id) + .FirstOrDefaultAsync(); + if (chatRoom is null) return NotFound(); + + if (chatRoom.RealmId is not null) + { + if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id), + RealmMemberRole.Moderator)) + return StatusCode(403, "You need at least be a realm moderator to update the chat."); + } + else if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), ChatMemberRole.Moderator)) + return StatusCode(403, "You need at least be a moderator to update the chat."); + + if (request.RealmId is not null) + { + var member = await db.RealmMembers + .Where(m => m.AccountId == Guid.Parse(currentUser.Id)) + .Where(m => m.RealmId == request.RealmId) + .FirstOrDefaultAsync(); + if (member is null || member.Role < RealmMemberRole.Moderator) + return StatusCode(403, "You need at least be a moderator to transfer the chat linked to the realm."); + chatRoom.RealmId = member.RealmId; + } + + if (request.PictureId is not null) + { + try + { + var fileResponse = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId }); + if (fileResponse == null) return BadRequest("Invalid picture id, unable to find the file on cloud."); + + // Remove old references for pictures + await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest + { + ResourceId = chatRoom.ResourceIdentifier, + Usage = "chat.room.picture" + }); + + // Add a new reference + await fileRefs.CreateReferenceAsync(new CreateReferenceRequest + { + FileId = fileResponse.Id, + Usage = "chat.room.picture", + ResourceId = chatRoom.ResourceIdentifier + }); + + chatRoom.Picture = CloudFileReferenceObject.FromProtoValue(fileResponse); + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) + { + return BadRequest("Invalid picture id, unable to find the file on cloud."); + } + } + + if (request.BackgroundId is not null) + { + try + { + var fileResponse = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId }); + if (fileResponse == null) return BadRequest("Invalid background id, unable to find the file on cloud."); + + // Remove old references for backgrounds + await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest + { + ResourceId = chatRoom.ResourceIdentifier, + Usage = "chat.room.background" + }); + + // Add a new reference + await fileRefs.CreateReferenceAsync(new CreateReferenceRequest + { + FileId = fileResponse.Id, + Usage = "chat.room.background", + ResourceId = chatRoom.ResourceIdentifier + }); + + chatRoom.Background = CloudFileReferenceObject.FromProtoValue(fileResponse); + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) + { + return BadRequest("Invalid background id, unable to find the file on cloud."); + } + } + + if (request.Name is not null) + chatRoom.Name = request.Name; + if (request.Description is not null) + chatRoom.Description = request.Description; + if (request.IsCommunity is not null) + chatRoom.IsCommunity = request.IsCommunity.Value; + if (request.IsPublic is not null) + chatRoom.IsPublic = request.IsPublic.Value; + + db.ChatRooms.Update(chatRoom); + await db.SaveChangesAsync(); + + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "chatrooms.update", + Meta = { { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(chatRoom.Id.ToString()) } }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); + + return Ok(chatRoom); + } + + [HttpDelete("{id:guid}")] + public async Task DeleteChatRoom(Guid id) + { + if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser) return Unauthorized(); + + var chatRoom = await db.ChatRooms + .Where(e => e.Id == id) + .FirstOrDefaultAsync(); + if (chatRoom is null) return NotFound(); + + if (chatRoom.RealmId is not null) + { + if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id), + RealmMemberRole.Moderator)) + return StatusCode(403, "You need at least be a realm moderator to delete the chat."); + } + else if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), ChatMemberRole.Owner)) + return StatusCode(403, "You need at least be the owner to delete the chat."); + + var chatRoomResourceId = $"chatroom:{chatRoom.Id}"; + + // Delete all file references for this chat room + await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest + { + ResourceId = chatRoomResourceId + }); + + db.ChatRooms.Remove(chatRoom); + await db.SaveChangesAsync(); + + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "chatrooms.delete", + Meta = { { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(chatRoom.Id.ToString()) } }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); + + return NoContent(); + } + + [HttpGet("{roomId:guid}/members/me")] + [Authorize] + public async Task> GetRoomIdentity(Guid roomId) + { + if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser) + return Unauthorized(); + + var member = await db.ChatMembers + .Where(m => m.AccountId == Guid.Parse(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:guid}/members")] + public async Task>> ListMembers(Guid roomId, [FromQuery] int take = 20, + [FromQuery] int skip = 0, [FromQuery] bool withStatus = false, [FromQuery] string? status = null) + { + var currentUser = HttpContext.Items["CurrentUser"] as Shared.Proto.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 == Guid.Parse(currentUser.Id)); + if (member is null) return StatusCode(403, "You need to be a member to see members of private chat room."); + } + + IQueryable query = db.ChatMembers + .Where(m => m.ChatRoomId == roomId) + .Where(m => m.LeaveAt == null) // Add this condition to exclude left members + .Include(m => m.Account) + .Include(m => m.Account.Profile); + + // if (withStatus) + // { + // var members = await query + // .OrderBy(m => m.JoinedAt) + // .ToListAsync(); + // + // var memberStatuses = await aes.GetStatuses(members.Select(m => m.AccountId).ToList()); + // + // if (!string.IsNullOrEmpty(status)) + // { + // members = members.Where(m => + // memberStatuses.TryGetValue(m.AccountId, out var s) && s.Label != null && + // s.Label.Equals(status, StringComparison.OrdinalIgnoreCase)).ToList(); + // } + // + // members = members.OrderByDescending(m => memberStatuses.TryGetValue(m.AccountId, out var s) && s.IsOnline) + // .ToList(); + // + // var total = members.Count; + // Response.Headers.Append("X-Total", total.ToString()); + // + // var result = members.Skip(skip).Take(take).ToList(); + // + // return Ok(result); + // } + // else + // { + 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 Guid RelatedUserId { get; set; } + [Required] public int Role { get; set; } + } + + [HttpPost("invites/{roomId:guid}")] + [Authorize] + public async Task> InviteMember(Guid roomId, + [FromBody] ChatMemberRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + var accountId = Guid.Parse(currentUser.Id); + + // Get related user account + var relatedUser = await accounts.GetAccountAsync(new GetAccountRequest { Id = request.RelatedUserId.ToString() }); + if (relatedUser == null) return BadRequest("Related user was not found"); + + // Check if the user has blocked the current user + var relationship = await accounts.GetRelationshipAsync(new GetRelationshipRequest + { + AccountId = currentUser.Id, + RelatedId = relatedUser.Id, + Status = -100 + }); + + if (relationship != null && relationship.Relationship.Status == -100) + return StatusCode(403, "You cannot invite a user that blocked you."); + + 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 == accountId) + .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 == accountId) + .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 hasExistingMember = await db.ChatMembers + .Where(m => m.AccountId == request.RelatedUserId) + .Where(m => m.ChatRoomId == roomId) + .Where(m => m.LeaveAt == null) + .AnyAsync(); + if (hasExistingMember) + return BadRequest("This user has been joined the chat cannot be invited again."); + + var newMember = new ChatMember + { + AccountId = Guid.Parse(relatedUser.Id), + ChatRoomId = roomId, + Role = request.Role, + }; + + db.ChatMembers.Add(newMember); + await db.SaveChangesAsync(); + + newMember.ChatRoom = chatRoom; + await _SendInviteNotify(newMember, currentUser); + + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "chatrooms.invite", + Meta = + { + { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(chatRoom.Id.ToString()) }, + { "account_id", Google.Protobuf.WellKnownTypes.Value.ForString(relatedUser.Id.ToString()) } + }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); + + return Ok(newMember); + } + + [HttpGet("invites")] + [Authorize] + public async Task>> ListChatInvites() + { + if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser) return Unauthorized(); + var accountId = Guid.Parse(currentUser.Id); + + var members = await db.ChatMembers + .Where(m => m.AccountId == accountId) + .Where(m => m.JoinedAt == null) + .Include(e => e.ChatRoom) + .Include(e => e.Account) + .Include(e => e.Account.Profile) + .ToListAsync(); + + var chatRooms = members.Select(m => m.ChatRoom).ToList(); + var directMembers = + (await crs.LoadDirectMessageMembers(chatRooms, accountId)).ToDictionary(c => c.Id, c => c.Members); + + foreach (var member in members.Where(member => member.ChatRoom.Type == ChatRoomType.DirectMessage)) + member.ChatRoom.Members = directMembers[member.ChatRoom.Id]; + + return members.ToList(); + } + + [HttpPost("invites/{roomId:guid}/accept")] + [Authorize] + public async Task> AcceptChatInvite(Guid roomId) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + var accountId = Guid.Parse(currentUser.Id); + + var member = await db.ChatMembers + .Where(m => m.AccountId == accountId) + .Where(m => m.ChatRoomId == roomId) + .Where(m => m.JoinedAt == null) + .FirstOrDefaultAsync(); + if (member is null) return NotFound(); + + member.JoinedAt = Instant.FromDateTimeUtc(DateTime.UtcNow); + db.Update(member); + await db.SaveChangesAsync(); + _ = crs.PurgeRoomMembersCache(roomId); + + als.CreateActionLogFromRequest( + ActionLogType.ChatroomJoin, + new Dictionary { { "chatroom_id", roomId } }, Request + ); + + return Ok(member); + } + + [HttpPost("invites/{roomId:guid}/decline")] + [Authorize] + public async Task DeclineChatInvite(Guid roomId) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + var accountId = Guid.Parse(currentUser.Id); + + var member = await db.ChatMembers + .Where(m => m.AccountId == accountId) + .Where(m => m.ChatRoomId == roomId) + .Where(m => m.JoinedAt == null) + .FirstOrDefaultAsync(); + if (member is null) return NotFound(); + + member.LeaveAt = SystemClock.Instance.GetCurrentInstant(); + await db.SaveChangesAsync(); + + return NoContent(); + } + + public class ChatMemberNotifyRequest + { + public ChatMemberNotify? NotifyLevel { get; set; } + public Instant? BreakUntil { get; set; } + } + + [HttpPatch("{roomId:guid}/members/me/notify")] + [Authorize] + public async Task> UpdateChatMemberNotify( + Guid roomId, + [FromBody] ChatMemberNotifyRequest request + ) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var chatRoom = await db.ChatRooms + .Where(r => r.Id == roomId) + .FirstOrDefaultAsync(); + if (chatRoom is null) return NotFound(); + + var accountId = Guid.Parse(currentUser.Id); + var targetMember = await db.ChatMembers + .Where(m => m.AccountId == accountId && m.ChatRoomId == roomId) + .FirstOrDefaultAsync(); + if (targetMember is null) return BadRequest("You have not joined this chat room."); + if (request.NotifyLevel is not null) + targetMember.Notify = request.NotifyLevel.Value; + if (request.BreakUntil is not null) + targetMember.BreakUntil = request.BreakUntil.Value; + + db.ChatMembers.Update(targetMember); + await db.SaveChangesAsync(); + + await crs.PurgeRoomMembersCache(roomId); + + return Ok(targetMember); + } + + [HttpPatch("{roomId:guid}/members/{memberId:guid}/role")] + [Authorize] + public async Task> UpdateChatMemberRole(Guid roomId, Guid memberId, [FromBody] int newRole) + { + if (newRole >= ChatMemberRole.Owner) return BadRequest("Unable to set chat member to owner or greater role."); + if (HttpContext.Items["CurrentUser"] is not 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 == Guid.Parse(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 + { + var targetMember = await db.ChatMembers + .Where(m => m.AccountId == memberId && m.ChatRoomId == roomId) + .FirstOrDefaultAsync(); + if (targetMember is null) return NotFound(); + + // Check if the current user has permission to change roles + if ( + !await crs.IsMemberWithRole( + chatRoom.Id, + Guid.Parse(currentUser.Id), + ChatMemberRole.Moderator, + targetMember.Role, + newRole + ) + ) + return StatusCode(403, "You don't have enough permission to edit the roles of members."); + + targetMember.Role = newRole; + db.ChatMembers.Update(targetMember); + await db.SaveChangesAsync(); + + await crs.PurgeRoomMembersCache(roomId); + + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "chatrooms.role.edit", + Meta = + { + { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(roomId.ToString()) }, + { "account_id", Google.Protobuf.WellKnownTypes.Value.ForString(memberId.ToString()) }, + { "new_role", Google.Protobuf.WellKnownTypes.Value.ForNumber(newRole) } + }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); + + return Ok(targetMember); + } + + return BadRequest(); + } + + [HttpDelete("{roomId:guid}/members/{memberId:guid}")] + [Authorize] + public async Task RemoveChatMember(Guid roomId, Guid memberId) + { + if (HttpContext.Items["CurrentUser"] is not 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) + { + if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id), + RealmMemberRole.Moderator)) + return StatusCode(403, "You need at least be a realm moderator to remove members."); + } + else + { + if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), ChatMemberRole.Moderator)) + return StatusCode(403, "You need at least be a moderator to remove members."); + + // Find the target member + var member = await db.ChatMembers + .Where(m => m.AccountId == memberId && m.ChatRoomId == roomId) + .FirstOrDefaultAsync(); + if (member is null) return NotFound(); + + // Check if the current user has sufficient permissions + if (!await crs.IsMemberWithRole(chatRoom.Id, memberId, member.Role)) + return StatusCode(403, "You cannot remove members with equal or higher roles."); + + member.LeaveAt = SystemClock.Instance.GetCurrentInstant(); + await db.SaveChangesAsync(); + _ = crs.PurgeRoomMembersCache(roomId); + + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "chatrooms.kick", + Meta = + { + { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(roomId.ToString()) }, + { "account_id", Google.Protobuf.WellKnownTypes.Value.ForString(memberId.ToString()) } + }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); + + return NoContent(); + } + + return BadRequest(); + } + + + [HttpPost("{roomId:guid}/members/me")] + [Authorize] + public async Task> JoinChatRoom(Guid roomId) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var chatRoom = await db.ChatRooms + .Where(r => r.Id == roomId) + .FirstOrDefaultAsync(); + if (chatRoom is null) return NotFound(); + if (!chatRoom.IsCommunity) + return StatusCode(403, "This chat room isn't a community. You need an invitation to join."); + + var existingMember = await db.ChatMembers + .FirstOrDefaultAsync(m => m.AccountId == Guid.Parse(currentUser.Id) && m.ChatRoomId == roomId); + if (existingMember != null) + return BadRequest("You are already a member of this chat room."); + + var newMember = new ChatMember + { + AccountId = Guid.Parse(currentUser.Id), + ChatRoomId = roomId, + Role = ChatMemberRole.Member, + JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow) + }; + + db.ChatMembers.Add(newMember); + await db.SaveChangesAsync(); + _ = crs.PurgeRoomMembersCache(roomId); + + als.CreateActionLogFromRequest( + ActionLogType.ChatroomJoin, + new Dictionary { { "chatroom_id", roomId } }, Request + ); + + return Ok(chatRoom); + } + + [HttpDelete("{roomId:guid}/members/me")] + [Authorize] + public async Task LeaveChat(Guid roomId) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var member = await db.ChatMembers + .Where(m => m.AccountId == Guid.Parse(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 != Guid.Parse(currentUser.Id)) + .AnyAsync(); + + if (!otherOwners) + return BadRequest("The last owner cannot leave the chat. Transfer ownership first or delete the chat."); + } + + member.LeaveAt = Instant.FromDateTimeUtc(DateTime.UtcNow); + await db.SaveChangesAsync(); + await crs.PurgeRoomMembersCache(roomId); + + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "chatrooms.leave", + Meta = { { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(roomId.ToString()) } }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); + + return NoContent(); + } + + private async Task _SendInviteNotify(ChatMember member, Account sender) + { + string title = localizer["ChatInviteTitle"]; + + string body = member.ChatRoom.Type == ChatRoomType.DirectMessage + ? localizer["ChatInviteDirectBody", sender.Nick] + : localizer["ChatInviteBody", member.ChatRoom.Name ?? "Unnamed"]; + + AccountService.SetCultureInfo(member.Account); + await nty.SendNotification(member.Account, "invites.chats", title, null, body, actionUri: "/chat"); + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Chat/ChatService.cs b/DysonNetwork.Sphere/Chat/ChatService.cs index bdad2d9..af40154 100644 --- a/DysonNetwork.Sphere/Chat/ChatService.cs +++ b/DysonNetwork.Sphere/Chat/ChatService.cs @@ -1,8 +1,7 @@ using System.Text.RegularExpressions; -using DysonNetwork.Sphere.Account; +using DysonNetwork.Shared.Data; +using DysonNetwork.Shared.Proto; using DysonNetwork.Sphere.Chat.Realtime; -using DysonNetwork.Sphere.Connection; -using DysonNetwork.Sphere.Storage; using Microsoft.EntityFrameworkCore; using NodaTime; @@ -10,7 +9,8 @@ namespace DysonNetwork.Sphere.Chat; public partial class ChatService( AppDatabase db, - FileReferenceService fileRefService, + FileService.FileServiceClient filesClient, + FileReferenceService.FileReferenceServiceClient fileRefs, IServiceScopeFactory scopeFactory, IRealtimeService realtime, ILogger logger @@ -33,7 +33,7 @@ public partial class ChatService( // Create a new scope for database operations using var scope = scopeFactory.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - var webReader = scope.ServiceProvider.GetRequiredService(); + var webReader = scope.ServiceProvider.GetRequiredService(); var newChat = scope.ServiceProvider.GetRequiredService(); // Preview the links in the message @@ -86,7 +86,7 @@ public partial class ChatService( /// The web reader service /// The message with link previews added to its meta data public async Task PreviewMessageLinkAsync(Message message, - Connection.WebReader.WebReaderService? webReader = null) + WebReader.WebReaderService? webReader = null) { if (string.IsNullOrEmpty(message.Content)) return message; @@ -109,7 +109,7 @@ public partial class ChatService( var embeds = (List>)message.Meta["embeds"]; webReader ??= scopeFactory.CreateScope().ServiceProvider - .GetRequiredService(); + .GetRequiredService(); // Process up to 3 links to avoid excessive processing var processedLinks = 0; @@ -157,16 +157,13 @@ public partial class ChatService( var files = message.Attachments.Distinct().ToList(); if (files.Count != 0) { - var messageResourceId = $"message:{message.Id}"; - foreach (var file in files) + var request = new CreateReferenceBatchRequest { - await fileRefService.CreateReferenceAsync( - file.Id, - ChatFileUsageIdentifier, - messageResourceId, - duration: Duration.FromDays(30) - ); - } + Usage = ChatFileUsageIdentifier, + ResourceId = message.ResourceIdentifier, + }; + request.FilesId.AddRange(message.Attachments.Select(a => a.Id)); + await fileRefs.CreateReferenceBatchAsync(request); } // Then start the delivery process @@ -203,8 +200,7 @@ public partial class ChatService( message.ChatRoom = room; using var scope = scopeFactory.CreateScope(); - var scopedWs = scope.ServiceProvider.GetRequiredService(); - var scopedNty = scope.ServiceProvider.GetRequiredService(); + var scopedNty = scope.ServiceProvider.GetRequiredService(); var scopedCrs = scope.ServiceProvider.GetRequiredService(); var roomSubject = room is { Type: ChatRoomType.DirectMessage, Name: null } ? "DM" : @@ -230,30 +226,32 @@ public partial class ChatService( if (!string.IsNullOrEmpty(room.Name)) metaDict["room_name"] = room.Name; - var notification = new Notification + var notification = new PushNotification { Topic = "messages.new", Title = $"{sender.Nick ?? sender.Account.Nick} ({roomSubject})", - Content = !string.IsNullOrEmpty(message.Content) + Body = !string.IsNullOrEmpty(message.Content) ? message.Content[..Math.Min(message.Content.Length, 100)] : "", - Meta = metaDict, - Priority = 10, }; + notification.Meta.Add(GrpcTypeHelper.ConvertToValueMap(metaDict)); List accountsToNotify = []; foreach (var member in members) { - scopedWs.SendPacketToAccount(member.AccountId, new WebSocketPacket + await scopedNty.PushWebSocketPacketToUsersAsync(new PushWebSocketPacketToUsersRequest { - Type = type, - Data = message + Packet = new WebSocketPacket + { + Type = type, + Data = GrpcTypeHelper.ConvertObjectToValue(metaDict), + }, }); - if (member.Account.Id == sender.AccountId) continue; + if (member.AccountId == sender.AccountId) continue; if (member.Notify == ChatMemberNotify.None) continue; // if (scopedWs.IsUserSubscribedToChatRoom(member.AccountId, room.Id.ToString())) continue; - if (message.MembersMentioned is null || !message.MembersMentioned.Contains(member.Account.Id)) + if (message.MembersMentioned is null || !message.MembersMentioned.Contains(member.AccountId)) { var now = SystemClock.Instance.GetCurrentInstant(); if (member.BreakUntil is not null && member.BreakUntil > now) continue; @@ -265,8 +263,10 @@ public partial class ChatService( logger.LogInformation($"Trying to deliver message to {accountsToNotify.Count} accounts..."); // Only send notifications if there are accounts to notify + var ntyRequest = new SendPushNotificationToUsersRequest { Notification = notification }; + ntyRequest.UserIds.AddRange(accountsToNotify.Select(a => a.Id.ToString())); if (accountsToNotify.Count > 0) - await scopedNty.SendNotificationBatch(notification, accountsToNotify, save: false); + await scopedNty.SendPushNotificationToUsersAsync(ntyRequest); logger.LogInformation($"Delivered message to {accountsToNotify.Count} accounts."); } @@ -495,25 +495,24 @@ public partial class ChatService( var messageResourceId = $"message:{message.Id}"; // Delete existing references for this message - await fileRefService.DeleteResourceReferencesAsync(messageResourceId); + await fileRefs.DeleteResourceReferencesAsync( + new DeleteResourceReferencesRequest { ResourceId = messageResourceId } + ); // Create new references for each attachment - foreach (var fileId in attachmentsId) + var createRequest = new CreateReferenceBatchRequest { - await fileRefService.CreateReferenceAsync( - fileId, - ChatFileUsageIdentifier, - messageResourceId, - duration: Duration.FromDays(30) - ); - } + Usage = ChatFileUsageIdentifier, + ResourceId = messageResourceId, + }; + createRequest.FilesId.AddRange(attachmentsId); + await fileRefs.CreateReferenceBatchAsync(createRequest); - // Update message attachments by getting files from database - var files = await db.Files - .Where(f => attachmentsId.Contains(f.Id)) - .ToListAsync(); - - message.Attachments = files.Select(x => x.ToReferenceObject()).ToList(); + // Update message attachments by getting files from da + var queryRequest = new GetFileBatchRequest(); + queryRequest.Ids.AddRange(attachmentsId); + var queryResult = await filesClient.GetFileBatchAsync(queryRequest); + message.Attachments = queryResult.Files.Select(CloudFileReferenceObject.FromProtoValue).ToList(); } message.EditedAt = SystemClock.Instance.GetCurrentInstant(); @@ -542,7 +541,9 @@ public partial class ChatService( { // Remove all file references for this message var messageResourceId = $"message:{message.Id}"; - await fileRefService.DeleteResourceReferencesAsync(messageResourceId); + await fileRefs.DeleteResourceReferencesAsync( + new DeleteResourceReferencesRequest { ResourceId = messageResourceId } + ); db.ChatMessages.Remove(message); await db.SaveChangesAsync(); @@ -575,4 +576,4 @@ public class SyncResponse { public List Changes { get; set; } = []; public Instant CurrentTimestamp { get; set; } -} +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Chat/Realtime/IRealtimeService.cs b/DysonNetwork.Sphere/Chat/Realtime/IRealtimeService.cs index cd34f8b..1d08b18 100644 --- a/DysonNetwork.Sphere/Chat/Realtime/IRealtimeService.cs +++ b/DysonNetwork.Sphere/Chat/Realtime/IRealtimeService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using DysonNetwork.Shared.Proto; namespace DysonNetwork.Sphere.Chat.Realtime; diff --git a/DysonNetwork.Sphere/Chat/Realtime/LivekitService.cs b/DysonNetwork.Sphere/Chat/Realtime/LivekitService.cs index 4313910..f3dfe23 100644 --- a/DysonNetwork.Sphere/Chat/Realtime/LivekitService.cs +++ b/DysonNetwork.Sphere/Chat/Realtime/LivekitService.cs @@ -1,32 +1,31 @@ -using DysonNetwork.Sphere.Connection; -using DysonNetwork.Sphere.Storage; using Livekit.Server.Sdk.Dotnet; using Microsoft.EntityFrameworkCore; using NodaTime; using System.Text.Json; +using DysonNetwork.Shared.Cache; +using DysonNetwork.Shared.Data; +using DysonNetwork.Shared.Proto; namespace DysonNetwork.Sphere.Chat.Realtime; /// /// LiveKit implementation of the real-time communication service /// -public class LivekitRealtimeService : IRealtimeService +public class LiveKitRealtimeService : IRealtimeService { private readonly AppDatabase _db; private readonly ICacheService _cache; - private readonly WebSocketService _ws; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly RoomServiceClient _roomService; private readonly AccessToken _accessToken; private readonly WebhookReceiver _webhookReceiver; - public LivekitRealtimeService( + public LiveKitRealtimeService( IConfiguration configuration, - ILogger logger, + ILogger logger, AppDatabase db, - ICacheService cache, - WebSocketService ws + ICacheService cache ) { _logger = logger; @@ -45,7 +44,6 @@ public class LivekitRealtimeService : IRealtimeService _db = db; _cache = cache; - _ws = ws; } /// @@ -159,7 +157,7 @@ public class LivekitRealtimeService : IRealtimeService evt.Room.Name, evt.Participant.Identity); // Broadcast participant list update to all participants - await _BroadcastParticipantUpdate(evt.Room.Name); + // await _BroadcastParticipantUpdate(evt.Room.Name); } break; @@ -174,7 +172,7 @@ public class LivekitRealtimeService : IRealtimeService evt.Room.Name, evt.Participant.Identity); // Broadcast participant list update to all participants - await _BroadcastParticipantUpdate(evt.Room.Name); + // await _BroadcastParticipantUpdate(evt.Room.Name); } break; @@ -310,82 +308,4 @@ public class LivekitRealtimeService : IRealtimeService JoinedAt = DateTime.UtcNow }; } - - // Broadcast participant update to all participants in a room - private async Task _BroadcastParticipantUpdate(string roomName) - { - try - { - // Get the room ID from the session name - var roomInfo = await _db.ChatRealtimeCall - .Where(c => c.SessionId == roomName && c.EndedAt == null) - .Select(c => new { c.RoomId, c.Id }) - .FirstOrDefaultAsync(); - - if (roomInfo == null) - { - _logger.LogWarning("Could not find room info for session: {SessionName}", roomName); - return; - } - - // Get current participants - var livekitParticipants = await GetRoomParticipantsAsync(roomName); - - // Get all room members who should receive this update - var roomMembers = await _db.ChatMembers - .Where(m => m.ChatRoomId == roomInfo.RoomId && m.LeaveAt == null) - .Select(m => m.AccountId) - .ToListAsync(); - - // Get member profiles for participants who have account IDs - var accountIds = livekitParticipants - .Where(p => p.AccountId.HasValue) - .Select(p => p.AccountId!.Value) - .ToList(); - - var memberProfiles = new Dictionary(); - if (accountIds.Count != 0) - { - memberProfiles = await _db.ChatMembers - .Where(m => m.ChatRoomId == roomInfo.RoomId && accountIds.Contains(m.AccountId)) - .Include(m => m.Account) - .ThenInclude(m => m.Profile) - .ToDictionaryAsync(m => m.AccountId, m => m); - } - - // Convert to CallParticipant objects - var participants = livekitParticipants.Select(p => new CallParticipant - { - Identity = p.Identity, - Name = p.Name, - AccountId = p.AccountId, - JoinedAt = p.JoinedAt, - Profile = p.AccountId.HasValue && memberProfiles.TryGetValue(p.AccountId.Value, out var profile) - ? profile - : null - }).ToList(); - - // Create the update packet with CallParticipant objects - var updatePacket = new WebSocketPacket - { - Type = WebSocketPacketType.CallParticipantsUpdate, - Data = new Dictionary - { - { "room_id", roomInfo.RoomId }, - { "call_id", roomInfo.Id }, - { "participants", participants } - } - }; - - // Send the update to all members - foreach (var accountId in roomMembers) - { - _ws.SendPacketToAccount(accountId, updatePacket); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error broadcasting participant update for room {RoomName}", roomName); - } - } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Chat/RealtimeCallController.cs b/DysonNetwork.Sphere/Chat/RealtimeCallController.cs index 6d300a2..998b9a5 100644 --- a/DysonNetwork.Sphere/Chat/RealtimeCallController.cs +++ b/DysonNetwork.Sphere/Chat/RealtimeCallController.cs @@ -1,3 +1,4 @@ +using DysonNetwork.Shared.Proto; using DysonNetwork.Sphere.Chat.Realtime; using Livekit.Server.Sdk.Dotnet; using Microsoft.AspNetCore.Authorization; @@ -48,8 +49,9 @@ public class RealtimeCallController( { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + var accountId = Guid.Parse(currentUser.Id); var member = await db.ChatMembers - .Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId) + .Where(m => m.AccountId == accountId && m.ChatRoomId == roomId) .FirstOrDefaultAsync(); if (member == null || member.Role < ChatMemberRole.Member) @@ -74,8 +76,9 @@ public class RealtimeCallController( if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); // Check if the user is a member of the chat room + var accountId = Guid.Parse(currentUser.Id); var member = await db.ChatMembers - .Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId) + .Where(m => m.AccountId == accountId && m.ChatRoomId == roomId) .FirstOrDefaultAsync(); if (member == null || member.Role < ChatMemberRole.Member) @@ -102,7 +105,7 @@ public class RealtimeCallController( // Get current participants from the LiveKit service var participants = new List(); - if (realtime is LivekitRealtimeService livekitService) + if (realtime is LiveKitRealtimeService livekitService) { var roomParticipants = await livekitService.GetRoomParticipantsAsync(ongoingCall.SessionId); participants = []; @@ -146,8 +149,9 @@ public class RealtimeCallController( { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + var accountId = Guid.Parse(currentUser.Id); var member = await db.ChatMembers - .Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId) + .Where(m => m.AccountId == accountId && m.ChatRoomId == roomId) .Include(m => m.ChatRoom) .FirstOrDefaultAsync(); if (member == null || member.Role < ChatMemberRole.Member) @@ -165,8 +169,9 @@ public class RealtimeCallController( { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + var accountId = Guid.Parse(currentUser.Id); var member = await db.ChatMembers - .Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId) + .Where(m => m.AccountId == accountId && m.ChatRoomId == roomId) .FirstOrDefaultAsync(); if (member == null || member.Role < ChatMemberRole.Member) return StatusCode(403, "You need to be a normal member to end a call."); diff --git a/DysonNetwork.Sphere/Developer/CustomApp.cs b/DysonNetwork.Sphere/Developer/CustomApp.cs index 0d3ba5d..425e646 100644 --- a/DysonNetwork.Sphere/Developer/CustomApp.cs +++ b/DysonNetwork.Sphere/Developer/CustomApp.cs @@ -1,8 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; -using DysonNetwork.Sphere.Account; -using DysonNetwork.Sphere.Storage; +using DysonNetwork.Shared.Data; using NodaTime; namespace DysonNetwork.Sphere.Developer; diff --git a/DysonNetwork.Sphere/Developer/CustomAppController.cs b/DysonNetwork.Sphere/Developer/CustomAppController.cs index c0e42ec..e9d5eb0 100644 --- a/DysonNetwork.Sphere/Developer/CustomAppController.cs +++ b/DysonNetwork.Sphere/Developer/CustomAppController.cs @@ -1,5 +1,5 @@ using System.ComponentModel.DataAnnotations; -using DysonNetwork.Sphere.Account; +using DysonNetwork.Shared.Proto; using DysonNetwork.Sphere.Publisher; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -55,7 +55,7 @@ public class CustomAppController(CustomAppService customApps, PublisherService p var publisher = await ps.GetPublisherByName(pubName); if (publisher is null) return NotFound(); - if (!await ps.IsMemberWithRole(publisher.Id, currentUser.Id, PublisherMemberRole.Editor)) + if (!await ps.IsMemberWithRole(publisher.Id, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) return StatusCode(403, "You must be an editor of the publisher to create a custom app"); if (!await ps.HasFeature(publisher.Id, PublisherFeatureFlag.Develop)) return StatusCode(403, "Publisher must be a developer to create a custom app"); @@ -84,7 +84,7 @@ public class CustomAppController(CustomAppService customApps, PublisherService p var publisher = await ps.GetPublisherByName(pubName); if (publisher is null) return NotFound(); - if (!await ps.IsMemberWithRole(publisher.Id, currentUser.Id, PublisherMemberRole.Editor)) + if (!await ps.IsMemberWithRole(publisher.Id, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) return StatusCode(403, "You must be an editor of the publisher to update a custom app"); var app = await customApps.GetAppAsync(id, publisherId: publisher.Id); @@ -114,7 +114,7 @@ public class CustomAppController(CustomAppService customApps, PublisherService p var publisher = await ps.GetPublisherByName(pubName); if (publisher is null) return NotFound(); - if (!await ps.IsMemberWithRole(publisher.Id, currentUser.Id, PublisherMemberRole.Editor)) + if (!await ps.IsMemberWithRole(publisher.Id, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) return StatusCode(403, "You must be an editor of the publisher to delete a custom app"); var app = await customApps.GetAppAsync(id, publisherId: publisher.Id); diff --git a/DysonNetwork.Sphere/Developer/CustomAppService.cs b/DysonNetwork.Sphere/Developer/CustomAppService.cs index 6455b37..d2fc0a3 100644 --- a/DysonNetwork.Sphere/Developer/CustomAppService.cs +++ b/DysonNetwork.Sphere/Developer/CustomAppService.cs @@ -1,10 +1,14 @@ -using DysonNetwork.Sphere.Publisher; -using DysonNetwork.Sphere.Storage; +using DysonNetwork.Shared.Data; +using DysonNetwork.Shared.Proto; using Microsoft.EntityFrameworkCore; namespace DysonNetwork.Sphere.Developer; -public class CustomAppService(AppDatabase db, FileReferenceService fileRefService) +public class CustomAppService( + AppDatabase db, + FileReferenceService.FileReferenceServiceClient fileRefs, + FileService.FileServiceClient files +) { public async Task CreateAppAsync( Publisher.Publisher pub, @@ -21,36 +25,46 @@ public class CustomAppService(AppDatabase db, FileReferenceService fileRefServic OauthConfig = request.OauthConfig, PublisherId = pub.Id }; - + if (request.PictureId is not null) { - var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync(); + var picture = await files.GetFileAsync( + new GetFileRequest + { + Id = request.PictureId + } + ); if (picture is null) throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); - - app.Picture = picture.ToReferenceObject(); + app.Picture = CloudFileReferenceObject.FromProtoValue(picture); // Create a new reference - await fileRefService.CreateReferenceAsync( - picture.Id, - "custom-apps.picture", - app.ResourceIdentifier + await fileRefs.CreateReferenceAsync( + new CreateReferenceRequest + { + FileId = picture.Id, + Usage = "custom-apps.picture", + ResourceId = app.ResourceIdentifier + } ); } - if (request.BackgroundId is not null) { - var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync(); + var background = await files.GetFileAsync( + new GetFileRequest { Id = request.BackgroundId } + ); if (background is null) throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); - - app.Background = background.ToReferenceObject(); + app.Background = CloudFileReferenceObject.FromProtoValue(background); // Create a new reference - await fileRefService.CreateReferenceAsync( - background.Id, - "custom-apps.background", - app.ResourceIdentifier + await fileRefs.CreateReferenceAsync( + new CreateReferenceRequest + { + FileId = background.Id, + Usage = "custom-apps.background", + ResourceId = app.ResourceIdentifier + } ); } @@ -90,39 +104,43 @@ public class CustomAppService(AppDatabase db, FileReferenceService fileRefServic if (request.PictureId is not null) { - var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync(); + var picture = await files.GetFileAsync( + new GetFileRequest + { + Id = request.PictureId + } + ); if (picture is null) throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); - - if (app.Picture is not null) - await fileRefService.DeleteResourceReferencesAsync(app.ResourceIdentifier, "custom-apps.picture"); - - app.Picture = picture.ToReferenceObject(); + app.Picture = CloudFileReferenceObject.FromProtoValue(picture); // Create a new reference - await fileRefService.CreateReferenceAsync( - picture.Id, - "custom-apps.picture", - app.ResourceIdentifier + await fileRefs.CreateReferenceAsync( + new CreateReferenceRequest + { + FileId = picture.Id, + Usage = "custom-apps.picture", + ResourceId = app.ResourceIdentifier + } ); } - if (request.BackgroundId is not null) { - var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync(); + var background = await files.GetFileAsync( + new GetFileRequest { Id = request.BackgroundId } + ); if (background is null) throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); - - if (app.Background is not null) - await fileRefService.DeleteResourceReferencesAsync(app.ResourceIdentifier, "custom-apps.background"); - - app.Background = background.ToReferenceObject(); + app.Background = CloudFileReferenceObject.FromProtoValue(background); // Create a new reference - await fileRefService.CreateReferenceAsync( - background.Id, - "custom-apps.background", - app.ResourceIdentifier + await fileRefs.CreateReferenceAsync( + new CreateReferenceRequest + { + FileId = background.Id, + Usage = "custom-apps.background", + ResourceId = app.ResourceIdentifier + } ); } @@ -142,8 +160,12 @@ public class CustomAppService(AppDatabase db, FileReferenceService fileRefServic db.CustomApps.Remove(app); await db.SaveChangesAsync(); - - await fileRefService.DeleteResourceReferencesAsync(app.ResourceIdentifier); + + await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest + { + ResourceId = app.ResourceIdentifier + } + ); return true; } diff --git a/DysonNetwork.Sphere/Developer/DeveloperController.cs b/DysonNetwork.Sphere/Developer/DeveloperController.cs index 4981bf6..2f251ea 100644 --- a/DysonNetwork.Sphere/Developer/DeveloperController.cs +++ b/DysonNetwork.Sphere/Developer/DeveloperController.cs @@ -1,4 +1,4 @@ -using DysonNetwork.Sphere.Account; +using DysonNetwork.Shared.Proto; using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Publisher; using Microsoft.AspNetCore.Authorization; @@ -13,7 +13,7 @@ namespace DysonNetwork.Sphere.Developer; public class DeveloperController( AppDatabase db, PublisherService ps, - ActionLogService als + ActionLogService.ActionLogServiceClient als ) : ControllerBase { @@ -64,10 +64,10 @@ public class DeveloperController( public async Task>> ListJoinedDevelopers() { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); - var userId = currentUser.Id; + var accountId = Guid.Parse(currentUser.Id); var members = await db.PublisherMembers - .Where(m => m.AccountId == userId) + .Where(m => m.AccountId == accountId) .Where(m => m.JoinedAt != null) .Include(e => e.Publisher) .ToListAsync(); @@ -94,7 +94,7 @@ public class DeveloperController( public async Task> EnrollDeveloperProgram(string name) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); - var userId = currentUser.Id; + var accountId = Guid.Parse(currentUser.Id); var publisher = await db.Publishers .Where(p => p.Name == name) @@ -105,7 +105,7 @@ public class DeveloperController( var isOwner = await db.PublisherMembers .AnyAsync(m => m.PublisherId == publisher.Id && - m.AccountId == userId && + m.AccountId == accountId && m.Role == PublisherMemberRole.Owner && m.JoinedAt != null); @@ -132,6 +132,19 @@ public class DeveloperController( db.PublisherFeatures.Add(feature); await db.SaveChangesAsync(); + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "developers.enroll", + Meta = + { + { "publisher_id", Google.Protobuf.WellKnownTypes.Value.ForString(publisher.Id.ToString()) }, + { "publisher_name", Google.Protobuf.WellKnownTypes.Value.ForString(publisher.Name) } + }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); + return Ok(publisher); } diff --git a/DysonNetwork.Sphere/Pages/Posts/PostDetail.cshtml.cs b/DysonNetwork.Sphere/Pages/Posts/PostDetail.cshtml.cs index 27a43d8..b14a396 100644 --- a/DysonNetwork.Sphere/Pages/Posts/PostDetail.cshtml.cs +++ b/DysonNetwork.Sphere/Pages/Posts/PostDetail.cshtml.cs @@ -28,7 +28,7 @@ public class PostDetailModel( var userFriends = currentUser is null ? [] : (await accounts.ListFriendsAsync( - new ListUserRelationshipSimpleRequest { AccountId = currentUser.Id } + new ListRelationshipSimpleRequest { AccountId = currentUser.Id } )).AccountsId.Select(Guid.Parse).ToList(); var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(accountId); diff --git a/DysonNetwork.Sphere/Pages/Shared/_Layout.cshtml b/DysonNetwork.Sphere/Pages/Shared/_Layout.cshtml index d229ef9..4c20d58 100644 --- a/DysonNetwork.Sphere/Pages/Shared/_Layout.cshtml +++ b/DysonNetwork.Sphere/Pages/Shared/_Layout.cshtml @@ -1,4 +1,3 @@ -@using DysonNetwork.Sphere.Auth @@ -24,31 +23,6 @@ Solar Network
-
diff --git a/DysonNetwork.Sphere/Permission/Permission.cs b/DysonNetwork.Sphere/Permission/Permission.cs deleted file mode 100644 index fe68844..0000000 --- a/DysonNetwork.Sphere/Permission/Permission.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.EntityFrameworkCore; -using NodaTime; - -namespace DysonNetwork.Sphere.Permission; - -/// The permission node model provides the infrastructure of permission control in Dyson Network. -/// It based on the ABAC permission model. -/// -/// The value can be any type, boolean and number for most cases and stored in jsonb. -/// -/// The area represents the region this permission affects. For example, the pub:<publisherId> -/// indicates it's a permission node for the publishers managing. -/// -/// And the actor shows who owns the permission, in most cases, the user:<userId> -/// and when the permission node has a GroupId, the actor will be set to the group, but it won't work on checking -/// expect the member of that permission group inherent the permission from the group. -[Index(nameof(Key), nameof(Area), nameof(Actor))] -public class PermissionNode : ModelBase, IDisposable -{ - public Guid Id { get; set; } = Guid.NewGuid(); - [MaxLength(1024)] public string Actor { get; set; } = null!; - [MaxLength(1024)] public string Area { get; set; } = null!; - [MaxLength(1024)] public string Key { get; set; } = null!; - [Column(TypeName = "jsonb")] public JsonDocument Value { get; set; } = null!; - public Instant? ExpiredAt { get; set; } = null; - public Instant? AffectedAt { get; set; } = null; - - public Guid? GroupId { get; set; } = null; - [JsonIgnore] public PermissionGroup? Group { get; set; } = null; - - public void Dispose() - { - Value.Dispose(); - GC.SuppressFinalize(this); - } -} - -public class PermissionGroup : ModelBase -{ - public Guid Id { get; set; } = Guid.NewGuid(); - [MaxLength(1024)] public string Key { get; set; } = null!; - - public ICollection Nodes { get; set; } = new List(); - [JsonIgnore] public ICollection Members { get; set; } = new List(); -} - -public class PermissionGroupMember : ModelBase -{ - public Guid GroupId { get; set; } - public PermissionGroup Group { get; set; } = null!; - [MaxLength(1024)] public string Actor { get; set; } = null!; - - public Instant? ExpiredAt { get; set; } - public Instant? AffectedAt { get; set; } -} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Permission/PermissionMiddleware.cs b/DysonNetwork.Sphere/Permission/PermissionMiddleware.cs deleted file mode 100644 index b67ba20..0000000 --- a/DysonNetwork.Sphere/Permission/PermissionMiddleware.cs +++ /dev/null @@ -1,51 +0,0 @@ -namespace DysonNetwork.Sphere.Permission; - -using System; - -[AttributeUsage(AttributeTargets.Method, Inherited = true)] -public class RequiredPermissionAttribute(string area, string key) : Attribute -{ - public string Area { get; set; } = area; - public string Key { get; } = key; -} - -public class PermissionMiddleware(RequestDelegate next) -{ - public async Task InvokeAsync(HttpContext httpContext, PermissionService pm) - { - var endpoint = httpContext.GetEndpoint(); - - var attr = endpoint?.Metadata - .OfType() - .FirstOrDefault(); - - if (attr != null) - { - if (httpContext.Items["CurrentUser"] is not Account currentUser) - { - httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; - await httpContext.Response.WriteAsync("Unauthorized"); - return; - } - - if (currentUser.IsSuperuser) - { - // Bypass the permission check for performance - await next(httpContext); - return; - } - - var actor = $"user:{currentUser.Id}"; - var permNode = await pm.GetPermissionAsync(actor, attr.Area, attr.Key); - - if (!permNode) - { - httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; - await httpContext.Response.WriteAsync($"Permission {attr.Area}/{attr.Key} = {true} was required."); - return; - } - } - - await next(httpContext); - } -} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Permission/PermissionService.cs b/DysonNetwork.Sphere/Permission/PermissionService.cs deleted file mode 100644 index 1c7a865..0000000 --- a/DysonNetwork.Sphere/Permission/PermissionService.cs +++ /dev/null @@ -1,197 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using NodaTime; -using System.Text.Json; -using DysonNetwork.Sphere.Storage; - -namespace DysonNetwork.Sphere.Permission; - -public class PermissionService( - AppDatabase db, - ICacheService cache -) -{ - private static readonly TimeSpan CacheExpiration = TimeSpan.FromMinutes(1); - - private const string PermCacheKeyPrefix = "perm:"; - private const string PermGroupCacheKeyPrefix = "perm-cg:"; - private const string PermissionGroupPrefix = "perm-g:"; - - private static string _GetPermissionCacheKey(string actor, string area, string key) => - PermCacheKeyPrefix + actor + ":" + area + ":" + key; - - private static string _GetGroupsCacheKey(string actor) => - PermGroupCacheKeyPrefix + actor; - - private static string _GetPermissionGroupKey(string actor) => - PermissionGroupPrefix + actor; - - public async Task HasPermissionAsync(string actor, string area, string key) - { - var value = await GetPermissionAsync(actor, area, key); - return value; - } - - public async Task GetPermissionAsync(string actor, string area, string key) - { - var cacheKey = _GetPermissionCacheKey(actor, area, key); - - var (hit, cachedValue) = await cache.GetAsyncWithStatus(cacheKey); - if (hit) - return cachedValue; - - var now = SystemClock.Instance.GetCurrentInstant(); - var groupsKey = _GetGroupsCacheKey(actor); - - var groupsId = await cache.GetAsync>(groupsKey); - if (groupsId == null) - { - groupsId = await db.PermissionGroupMembers - .Where(n => n.Actor == actor) - .Where(n => n.ExpiredAt == null || n.ExpiredAt > now) - .Where(n => n.AffectedAt == null || n.AffectedAt <= now) - .Select(e => e.GroupId) - .ToListAsync(); - - await cache.SetWithGroupsAsync(groupsKey, groupsId, - [_GetPermissionGroupKey(actor)], - CacheExpiration); - } - - var permission = await db.PermissionNodes - .Where(n => (n.GroupId == null && n.Actor == actor) || - (n.GroupId != null && groupsId.Contains(n.GroupId.Value))) - .Where(n => n.Key == key && n.Area == area) - .Where(n => n.ExpiredAt == null || n.ExpiredAt > now) - .Where(n => n.AffectedAt == null || n.AffectedAt <= now) - .FirstOrDefaultAsync(); - - var result = permission is not null ? _DeserializePermissionValue(permission.Value) : default; - - await cache.SetWithGroupsAsync(cacheKey, result, - [_GetPermissionGroupKey(actor)], - CacheExpiration); - - return result; - } - - public async Task AddPermissionNode( - string actor, - string area, - string key, - T value, - Instant? expiredAt = null, - Instant? affectedAt = null - ) - { - if (value is null) throw new ArgumentNullException(nameof(value)); - - var node = new PermissionNode - { - Actor = actor, - Key = key, - Area = area, - Value = _SerializePermissionValue(value), - ExpiredAt = expiredAt, - AffectedAt = affectedAt - }; - - db.PermissionNodes.Add(node); - await db.SaveChangesAsync(); - - // Invalidate related caches - await InvalidatePermissionCacheAsync(actor, area, key); - - return node; - } - - public async Task AddPermissionNodeToGroup( - PermissionGroup group, - string actor, - string area, - string key, - T value, - Instant? expiredAt = null, - Instant? affectedAt = null - ) - { - if (value is null) throw new ArgumentNullException(nameof(value)); - - var node = new PermissionNode - { - Actor = actor, - Key = key, - Area = area, - Value = _SerializePermissionValue(value), - ExpiredAt = expiredAt, - AffectedAt = affectedAt, - Group = group, - GroupId = group.Id - }; - - db.PermissionNodes.Add(node); - await db.SaveChangesAsync(); - - // Invalidate related caches - await InvalidatePermissionCacheAsync(actor, area, key); - await cache.RemoveAsync(_GetGroupsCacheKey(actor)); - await cache.RemoveGroupAsync(_GetPermissionGroupKey(actor)); - - return node; - } - - public async Task RemovePermissionNode(string actor, string area, string key) - { - var node = await db.PermissionNodes - .Where(n => n.Actor == actor && n.Area == area && n.Key == key) - .FirstOrDefaultAsync(); - if (node is not null) db.PermissionNodes.Remove(node); - await db.SaveChangesAsync(); - - // Invalidate cache - await InvalidatePermissionCacheAsync(actor, area, key); - } - - public async Task RemovePermissionNodeFromGroup(PermissionGroup group, string actor, string area, string key) - { - var node = await db.PermissionNodes - .Where(n => n.GroupId == group.Id) - .Where(n => n.Actor == actor && n.Area == area && n.Key == key) - .FirstOrDefaultAsync(); - if (node is null) return; - db.PermissionNodes.Remove(node); - await db.SaveChangesAsync(); - - // Invalidate caches - await InvalidatePermissionCacheAsync(actor, area, key); - await cache.RemoveAsync(_GetGroupsCacheKey(actor)); - await cache.RemoveGroupAsync(_GetPermissionGroupKey(actor)); - } - - private async Task InvalidatePermissionCacheAsync(string actor, string area, string key) - { - var cacheKey = _GetPermissionCacheKey(actor, area, key); - await cache.RemoveAsync(cacheKey); - } - - private static T? _DeserializePermissionValue(JsonDocument json) - { - return JsonSerializer.Deserialize(json.RootElement.GetRawText()); - } - - private static JsonDocument _SerializePermissionValue(T obj) - { - var str = JsonSerializer.Serialize(obj); - return JsonDocument.Parse(str); - } - - public static PermissionNode NewPermissionNode(string actor, string area, string key, T value) - { - return new PermissionNode - { - Actor = actor, - Area = area, - Key = key, - Value = _SerializePermissionValue(value), - }; - } -} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Post/PostController.cs b/DysonNetwork.Sphere/Post/PostController.cs index 3def48a..3af0f0c 100644 --- a/DysonNetwork.Sphere/Post/PostController.cs +++ b/DysonNetwork.Sphere/Post/PostController.cs @@ -1,4 +1,6 @@ using System.ComponentModel.DataAnnotations; +using DysonNetwork.Shared.Content; +using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Proto; using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Publisher; @@ -29,8 +31,16 @@ public class PostController( { HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); var currentUser = currentUserValue as Account; - var userFriends = currentUser is null ? [] : await rels.ListAccountFriends(currentUser); - var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(currentUser.Id); + + List userFriends = []; + if (currentUser != null) + { + var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest + { AccountId = currentUser.Id }); + userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); + } + + var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(Guid.Parse(currentUser.Id)); var publisher = pubName == null ? null : await db.Publishers.FirstOrDefaultAsync(p => p.Name == pubName); @@ -64,11 +74,18 @@ public class PostController( { if (HttpContext.Items["IsWebPage"] as bool? ?? true) return RedirectToPage("/Posts/PostDetail", new { PostId = id }); - + HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); var currentUser = currentUserValue as Account; - var userFriends = currentUser is null ? [] : await rels.ListAccountFriends(currentUser); - var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(currentUser.Id); + List userFriends = []; + if (currentUser != null) + { + var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest + { AccountId = currentUser.Id }); + userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); + } + + var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(Guid.Parse(currentUser.Id)); var post = await db.Posts .Where(e => e.Id == id) @@ -99,8 +116,14 @@ public class PostController( HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); var currentUser = currentUserValue as Account; - var userFriends = currentUser is null ? [] : await rels.ListAccountFriends(currentUser); - var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(currentUser.Id); + List userFriends = []; + if (currentUser != null) + { + var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest + { AccountId = currentUser.Id }); + userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); + } + var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(Guid.Parse(currentUser.Id)); var queryable = db.Posts .FilterWithVisibility(currentUser, userFriends, userPublishers, isListing: true) @@ -136,8 +159,16 @@ public class PostController( { HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); var currentUser = currentUserValue as Account; - var userFriends = currentUser is null ? [] : await rels.ListAccountFriends(currentUser); - var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(currentUser.Id); + + List userFriends = []; + if (currentUser != null) + { + var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest + { AccountId = currentUser.Id }); + userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); + } + + var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(Guid.Parse(currentUser.Id)); var parent = await db.Posts .Where(e => e.Id == id) @@ -199,12 +230,14 @@ public class PostController( return BadRequest("Content is required."); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + var accountId = Guid.Parse(currentUser.Id); + Publisher.Publisher? publisher; if (publisherName is null) { // Use the first personal publisher publisher = await db.Publishers.FirstOrDefaultAsync(e => - e.AccountId == currentUser.Id && e.Type == PublisherType.Individual); + e.AccountId == accountId && e.Type == PublisherType.Individual); } else { @@ -212,7 +245,7 @@ public class PostController( if (publisher is null) return BadRequest("Publisher was not found."); var member = await db.PublisherMembers.FirstOrDefaultAsync(e => - e.AccountId == currentUser.Id && e.PublisherId == publisher.Id); + e.AccountId == accountId && e.PublisherId == publisher.Id); if (member is null) return StatusCode(403, "You even wasn't a member of the publisher you specified."); if (member.Role < PublisherMemberRole.Editor) return StatusCode(403, "You need at least be an editor to post as this publisher."); @@ -263,10 +296,14 @@ public class PostController( return BadRequest(err.Message); } - als.CreateActionLogFromRequest( - ActionLogType.PostCreate, - new Dictionary { { "post_id", post.Id } }, Request - ); + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = ActionLogType.PostCreate, + Meta = { { "post_id", Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString()) } }, + AccountId = currentUser.Id.ToString(), + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); return post; } @@ -282,31 +319,34 @@ public class PostController( [RequiredPermission("global", "posts.react")] public async Task> ReactPost(Guid id, [FromBody] PostReactionRequest request) { - HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); - if (currentUserValue is not Account currentUser) return Unauthorized(); - var userFriends = await rels.ListAccountFriends(currentUser); - var userPublishers = await pub.GetUserPublishers(currentUser.Id); + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var friendsResponse = + await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest + { AccountId = currentUser.Id.ToString() }); + var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); + var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id)); var post = await db.Posts .Where(e => e.Id == id) .Include(e => e.Publisher) - .ThenInclude(e => e.Account) .FilterWithVisibility(currentUser, userFriends, userPublishers) .FirstOrDefaultAsync(); if (post is null) return NotFound(); - var isSelfReact = post.Publisher.AccountId is not null && post.Publisher.AccountId == currentUser.Id; + var accountId = Guid.Parse(currentUser.Id); + var isSelfReact = post.Publisher.AccountId is not null && post.Publisher.AccountId == accountId; var isExistingReaction = await db.PostReactions .AnyAsync(r => r.PostId == post.Id && r.Symbol == request.Symbol && - r.AccountId == currentUser.Id); + r.AccountId == accountId); var reaction = new PostReaction { Symbol = request.Symbol, Attitude = request.Attitude, PostId = post.Id, - AccountId = currentUser.Id + AccountId = accountId }; var isRemoving = await ps.ModifyPostVotes( post, @@ -318,10 +358,18 @@ public class PostController( if (isRemoving) return NoContent(); - als.CreateActionLogFromRequest( - ActionLogType.PostReact, - new Dictionary { { "post_id", post.Id }, { "reaction", request.Symbol } }, Request - ); + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = ActionLogType.PostReact, + Meta = + { + { "post_id", Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString()) }, + { "reaction", Google.Protobuf.WellKnownTypes.Value.ForString(request.Symbol) } + }, + AccountId = currentUser.Id.ToString(), + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); return Ok(reaction); } @@ -342,7 +390,8 @@ public class PostController( .FirstOrDefaultAsync(); if (post is null) return NotFound(); - if (!await pub.IsMemberWithRole(post.Publisher.Id, currentUser.Id, PublisherMemberRole.Editor)) + var accountId = Guid.Parse(currentUser.Id); + if (!await pub.IsMemberWithRole(post.Publisher.Id, accountId, PublisherMemberRole.Editor)) return StatusCode(403, "You need at least be an editor to edit this publisher's post."); if (request.Title is not null) post.Title = request.Title; @@ -367,10 +416,14 @@ public class PostController( return BadRequest(err.Message); } - als.CreateActionLogFromRequest( - ActionLogType.PostUpdate, - new Dictionary { { "post_id", post.Id } }, Request - ); + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = ActionLogType.PostUpdate, + Meta = { { "post_id", Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString()) } }, + AccountId = currentUser.Id.ToString(), + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); return Ok(post); } @@ -386,15 +439,19 @@ public class PostController( .FirstOrDefaultAsync(); if (post is null) return NotFound(); - if (!await pub.IsMemberWithRole(post.Publisher.Id, currentUser.Id, PublisherMemberRole.Editor)) + if (!await pub.IsMemberWithRole(post.Publisher.Id, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) return StatusCode(403, "You need at least be an editor to delete the publisher's post."); await ps.DeletePostAsync(post); - als.CreateActionLogFromRequest( - ActionLogType.PostDelete, - new Dictionary { { "post_id", post.Id } }, Request - ); + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = ActionLogType.PostDelete, + Meta = { { "post_id", Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString()) } }, + AccountId = currentUser.Id.ToString(), + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); return NoContent(); } diff --git a/DysonNetwork.Sphere/Post/PostService.cs b/DysonNetwork.Sphere/Post/PostService.cs index 6a208ac..d8f60e0 100644 --- a/DysonNetwork.Sphere/Post/PostService.cs +++ b/DysonNetwork.Sphere/Post/PostService.cs @@ -136,7 +136,6 @@ public partial class PostService( // Create file references for each attachment if (post.Attachments.Count != 0) { - var postResourceId = $"post:{post.Id}"; var request = new CreateReferenceBatchRequest { Usage = PostFileUsageIdentifier, diff --git a/DysonNetwork.Sphere/Publisher/Publisher.cs b/DysonNetwork.Sphere/Publisher/Publisher.cs index 30975e4..c8bfc81 100644 --- a/DysonNetwork.Sphere/Publisher/Publisher.cs +++ b/DysonNetwork.Sphere/Publisher/Publisher.cs @@ -2,9 +2,11 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; using DysonNetwork.Shared.Data; +using DysonNetwork.Shared.Proto; using DysonNetwork.Sphere.Post; using Microsoft.EntityFrameworkCore; using NodaTime; +using VerificationMark = DysonNetwork.Shared.Data.VerificationMark; namespace DysonNetwork.Sphere.Publisher; @@ -43,6 +45,7 @@ public class Publisher : ModelBase, IIdentifiedResource public Guid? AccountId { get; set; } public Guid? RealmId { get; set; } [JsonIgnore] public Realm.Realm? Realm { get; set; } + [NotMapped] public Account? Account { get; set; } public string ResourceIdentifier => $"publisher:{Id}"; } diff --git a/DysonNetwork.Sphere/Publisher/PublisherController.cs b/DysonNetwork.Sphere/Publisher/PublisherController.cs index aaa2ba8..e541d68 100644 --- a/DysonNetwork.Sphere/Publisher/PublisherController.cs +++ b/DysonNetwork.Sphere/Publisher/PublisherController.cs @@ -1,8 +1,8 @@ using System.ComponentModel.DataAnnotations; -using DysonNetwork.Sphere.Account; -using DysonNetwork.Sphere.Permission; +using DysonNetwork.Shared.Auth; +using DysonNetwork.Shared.Data; +using DysonNetwork.Shared.Proto; using DysonNetwork.Sphere.Realm; -using DysonNetwork.Sphere.Storage; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -15,8 +15,11 @@ namespace DysonNetwork.Sphere.Publisher; public class PublisherController( AppDatabase db, PublisherService ps, - FileReferenceService fileRefService, - ActionLogService als) + AccountService.AccountServiceClient accounts, + FileService.FileServiceClient files, + FileReferenceService.FileReferenceServiceClient fileRefs, + ActionLogService.ActionLogServiceClient als +) : ControllerBase { [HttpGet("{name}")] @@ -28,10 +31,9 @@ public class PublisherController( if (publisher is null) return NotFound(); if (publisher.AccountId is null) return Ok(publisher); - var account = await db.Accounts - .Where(a => a.Id == publisher.AccountId) - .Include(a => a.Profile) - .FirstOrDefaultAsync(); + var account = await accounts.GetAccountAsync( + new GetAccountRequest { Id = publisher.AccountId.Value.ToString() } + ); publisher.Account = account; return Ok(publisher); @@ -50,10 +52,10 @@ public class PublisherController( public async Task>> ListManagedPublishers() { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); - var userId = currentUser.Id; + var accountId = Guid.Parse(currentUser.Id); var members = await db.PublisherMembers - .Where(m => m.AccountId == userId) + .Where(m => m.AccountId == accountId) .Where(m => m.JoinedAt != null) .Include(e => e.Publisher) .ToListAsync(); @@ -66,10 +68,10 @@ public class PublisherController( public async Task>> ListInvites() { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); - var userId = currentUser.Id; + var accountId = Guid.Parse(currentUser.Id); var members = await db.PublisherMembers - .Where(m => m.AccountId == userId) + .Where(m => m.AccountId == accountId) .Where(m => m.JoinedAt == null) .Include(e => e.Publisher) .ToListAsync(); @@ -89,22 +91,23 @@ public class PublisherController( [FromBody] PublisherMemberRequest request) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); - var userId = currentUser.Id; + var accountId = Guid.Parse(currentUser.Id); - var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId); - if (relatedUser is null) return BadRequest("Related user was not found"); + var relatedUser = + await accounts.GetAccountAsync(new GetAccountRequest { Id = request.RelatedUserId.ToString() }); + if (relatedUser == null) return BadRequest("Related user was not found"); var publisher = await db.Publishers .Where(p => p.Name == name) .FirstOrDefaultAsync(); if (publisher is null) return NotFound(); - if (!await ps.IsMemberWithRole(publisher.Id, currentUser.Id, request.Role)) + if (!await ps.IsMemberWithRole(publisher.Id, accountId, request.Role)) return StatusCode(403, "You cannot invite member has higher permission than yours."); var newMember = new PublisherMember { - AccountId = relatedUser.Id, + AccountId = Guid.Parse(relatedUser.Id), PublisherId = publisher.Id, Role = request.Role, }; @@ -112,14 +115,18 @@ public class PublisherController( db.PublisherMembers.Add(newMember); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( - ActionLogType.PublisherMemberInvite, - new Dictionary + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "publishers.members.invite", + Meta = { - { "publisher_id", publisher.Id }, - { "account_id", relatedUser.Id } - }, Request - ); + { "publisher_id", Google.Protobuf.WellKnownTypes.Value.ForString(publisher.Id.ToString()) }, + { "account_id", Google.Protobuf.WellKnownTypes.Value.ForString(relatedUser.Id.ToString()) } + }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); return Ok(newMember); } @@ -129,10 +136,10 @@ public class PublisherController( public async Task> AcceptMemberInvite(string name) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); - var userId = currentUser.Id; + var accountId = Guid.Parse(currentUser.Id); var member = await db.PublisherMembers - .Where(m => m.AccountId == userId) + .Where(m => m.AccountId == accountId) .Where(m => m.Publisher.Name == name) .Where(m => m.JoinedAt == null) .FirstOrDefaultAsync(); @@ -142,10 +149,18 @@ public class PublisherController( db.Update(member); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( - ActionLogType.PublisherMemberJoin, - new Dictionary { { "account_id", member.AccountId } }, Request - ); + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "publishers.members.join", + Meta = + { + { "publisher_id", Google.Protobuf.WellKnownTypes.Value.ForString(member.PublisherId.ToString()) }, + { "account_id", Google.Protobuf.WellKnownTypes.Value.ForString(member.AccountId.ToString()) } + }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); return Ok(member); } @@ -155,10 +170,10 @@ public class PublisherController( public async Task DeclineMemberInvite(string name) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); - var userId = currentUser.Id; + var accountId = Guid.Parse(currentUser.Id); var member = await db.PublisherMembers - .Where(m => m.AccountId == userId) + .Where(m => m.AccountId == accountId) .Where(m => m.Publisher.Name == name) .Where(m => m.JoinedAt == null) .FirstOrDefaultAsync(); @@ -167,10 +182,18 @@ public class PublisherController( db.PublisherMembers.Remove(member); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( - ActionLogType.PublisherMemberLeave, - new Dictionary { { "account_id", member.AccountId } }, Request - ); + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "publishers.members.decline", + Meta = + { + { "publisher_id", Google.Protobuf.WellKnownTypes.Value.ForString(member.PublisherId.ToString()) }, + { "account_id", Google.Protobuf.WellKnownTypes.Value.ForString(member.AccountId.ToString()) } + }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); return NoContent(); } @@ -190,21 +213,27 @@ public class PublisherController( .Where(m => m.AccountId == memberId) .Where(m => m.PublisherId == publisher.Id) .FirstOrDefaultAsync(); + var accountId = Guid.Parse(currentUser.Id); if (member is null) return NotFound("Member was not found"); - if (!await ps.IsMemberWithRole(publisher.Id, currentUser.Id, PublisherMemberRole.Manager)) + if (!await ps.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Manager)) return StatusCode(403, "You need at least be a manager to remove members from this publisher."); db.PublisherMembers.Remove(member); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( - ActionLogType.PublisherMemberKick, - new Dictionary + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "publishers.members.kick", + Meta = { - { "publisher_id", publisher.Id }, - { "account_id", memberId } - }, Request - ); + { "publisher_id", Google.Protobuf.WellKnownTypes.Value.ForString(publisher.Id.ToString()) }, + { "account_id", Google.Protobuf.WellKnownTypes.Value.ForString(memberId.ToString()) }, + { "kicked_by", Google.Protobuf.WellKnownTypes.Value.ForString(currentUser.Id) } + }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); return NoContent(); } @@ -238,17 +267,25 @@ public class PublisherController( "your name firstly to get your name back." ); - CloudFile? picture = null, background = null; + CloudFileReferenceObject? picture = null, background = null; if (request.PictureId is not null) { - picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync(); - if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud."); + var queryResult = await files.GetFileAsync( + new GetFileRequest { Id = request.PictureId } + ); + if (queryResult is null) + throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); + picture = CloudFileReferenceObject.FromProtoValue(queryResult); } if (request.BackgroundId is not null) { - background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync(); - if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud."); + var queryResult = await files.GetFileAsync( + new GetFileRequest { Id = request.BackgroundId } + ); + if (queryResult is null) + throw new InvalidOperationException("Invalid background id, unable to find the file on cloud."); + background = CloudFileReferenceObject.FromProtoValue(queryResult); } var publisher = await ps.CreateIndividualPublisher( @@ -260,10 +297,19 @@ public class PublisherController( background ); - als.CreateActionLogFromRequest( - ActionLogType.PublisherCreate, - new Dictionary { { "publisher_id", publisher.Id } }, Request - ); + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "publishers.create", + Meta = + { + { "publisher_id", Google.Protobuf.WellKnownTypes.Value.ForString(publisher.Id.ToString()) }, + { "publisher_name", Google.Protobuf.WellKnownTypes.Value.ForString(publisher.Name) }, + { "publisher_type", Google.Protobuf.WellKnownTypes.Value.ForString("Individual") } + }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); return Ok(publisher); } @@ -279,9 +325,10 @@ public class PublisherController( var realm = await db.Realms.FirstOrDefaultAsync(r => r.Slug == realmSlug); if (realm == null) return NotFound("Realm not found"); + var accountId = Guid.Parse(currentUser.Id); var isAdmin = await db.RealmMembers .AnyAsync(m => - m.RealmId == realm.Id && m.AccountId == currentUser.Id && m.Role >= RealmMemberRole.Moderator); + m.RealmId == realm.Id && m.AccountId == accountId && m.Role >= RealmMemberRole.Moderator); if (!isAdmin) return StatusCode(403, "You need to be a moderator of the realm to create an organization publisher"); @@ -292,17 +339,25 @@ public class PublisherController( if (duplicateNameCount > 0) return BadRequest("The name you requested has already been taken"); - CloudFile? picture = null, background = null; + CloudFileReferenceObject? picture = null, background = null; if (request.PictureId is not null) { - picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync(); - if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud."); + var queryResult = await files.GetFileAsync( + new GetFileRequest { Id = request.PictureId } + ); + if (queryResult is null) + throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); + picture = CloudFileReferenceObject.FromProtoValue(queryResult); } if (request.BackgroundId is not null) { - background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync(); - if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud."); + var queryResult = await files.GetFileAsync( + new GetFileRequest { Id = request.BackgroundId } + ); + if (queryResult is null) + throw new InvalidOperationException("Invalid background id, unable to find the file on cloud."); + background = CloudFileReferenceObject.FromProtoValue(queryResult); } var publisher = await ps.CreateOrganizationPublisher( @@ -315,10 +370,20 @@ public class PublisherController( background ); - als.CreateActionLogFromRequest( - ActionLogType.PublisherCreate, - new Dictionary { { "publisher_id", publisher.Id } }, Request - ); + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "publishers.create", + Meta = + { + { "publisher_id", Google.Protobuf.WellKnownTypes.Value.ForString(publisher.Id.ToString()) }, + { "publisher_name", Google.Protobuf.WellKnownTypes.Value.ForString(publisher.Name) }, + { "publisher_type", Google.Protobuf.WellKnownTypes.Value.ForString("Organization") }, + { "realm_slug", Google.Protobuf.WellKnownTypes.Value.ForString(realm.Slug) } + }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); return Ok(publisher); } @@ -329,7 +394,7 @@ public class PublisherController( public async Task> UpdatePublisher(string name, PublisherRequest request) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); - var userId = currentUser.Id; + var accountId = Guid.Parse(currentUser.Id); var publisher = await db.Publishers .Where(p => p.Name == name) @@ -337,7 +402,7 @@ public class PublisherController( if (publisher is null) return NotFound(); var member = await db.PublisherMembers - .Where(m => m.AccountId == userId) + .Where(m => m.AccountId == accountId) .Where(m => m.PublisherId == publisher.Id) .FirstOrDefaultAsync(); if (member is null) return StatusCode(403, "You are not even a member of the targeted publisher."); @@ -349,54 +414,81 @@ public class PublisherController( if (request.Bio is not null) publisher.Bio = request.Bio; if (request.PictureId is not null) { - var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync(); - if (picture is null) return BadRequest("Invalid picture id."); + var queryResult = await files.GetFileAsync( + new GetFileRequest { Id = request.PictureId } + ); + if (queryResult is null) + throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); + var picture = CloudFileReferenceObject.FromProtoValue(queryResult); // Remove old references for the publisher picture if (publisher.Picture is not null) - { - await fileRefService.DeleteResourceReferencesAsync(publisher.ResourceIdentifier, "publisher.picture"); - } + await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest + { + ResourceId = publisher.ResourceIdentifier + }); - publisher.Picture = picture.ToReferenceObject(); + publisher.Picture = picture; - // Create a new reference - await fileRefService.CreateReferenceAsync( - picture.Id, - "publisher.picture", - publisher.ResourceIdentifier + await fileRefs.CreateReferenceAsync( + new CreateReferenceRequest + { + FileId = picture.Id, + Usage = "publisher.picture", + ResourceId = publisher.ResourceIdentifier + } ); } if (request.BackgroundId is not null) { - var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync(); - if (background is null) return BadRequest("Invalid background id."); + var queryResult = await files.GetFileAsync( + new GetFileRequest { Id = request.BackgroundId } + ); + if (queryResult is null) + throw new InvalidOperationException("Invalid background id, unable to find the file on cloud."); + var background = CloudFileReferenceObject.FromProtoValue(queryResult); // Remove old references for the publisher background if (publisher.Background is not null) { - await fileRefService.DeleteResourceReferencesAsync(publisher.ResourceIdentifier, - "publisher.background"); + await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest + { + ResourceId = publisher.ResourceIdentifier + }); } - publisher.Background = background.ToReferenceObject(); + publisher.Background = background; - // Create a new reference - await fileRefService.CreateReferenceAsync( - background.Id, - "publisher.background", - publisher.ResourceIdentifier + await fileRefs.CreateReferenceAsync( + new CreateReferenceRequest + { + FileId = background.Id, + Usage = "publisher.background", + ResourceId = publisher.ResourceIdentifier + } ); } db.Update(publisher); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( - ActionLogType.PublisherUpdate, - new Dictionary { { "publisher_id", publisher.Id } }, Request - ); + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "publishers.update", + Meta = + { + { "publisher_id", Google.Protobuf.WellKnownTypes.Value.ForString(publisher.Id.ToString()) }, + { "name_updated", Google.Protobuf.WellKnownTypes.Value.ForBool(!string.IsNullOrEmpty(request.Name)) }, + { "nick_updated", Google.Protobuf.WellKnownTypes.Value.ForBool(!string.IsNullOrEmpty(request.Nick)) }, + { "bio_updated", Google.Protobuf.WellKnownTypes.Value.ForBool(!string.IsNullOrEmpty(request.Bio)) }, + { "picture_updated", Google.Protobuf.WellKnownTypes.Value.ForBool(request.PictureId != null) }, + { "background_updated", Google.Protobuf.WellKnownTypes.Value.ForBool(request.BackgroundId != null) } + }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); return Ok(publisher); } @@ -406,7 +498,7 @@ public class PublisherController( public async Task> DeletePublisher(string name) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); - var userId = currentUser.Id; + var accountId = Guid.Parse(currentUser.Id); var publisher = await db.Publishers .Where(p => p.Name == name) @@ -416,7 +508,7 @@ public class PublisherController( if (publisher is null) return NotFound(); var member = await db.PublisherMembers - .Where(m => m.AccountId == userId) + .Where(m => m.AccountId == accountId) .Where(m => m.PublisherId == publisher.Id) .FirstOrDefaultAsync(); if (member is null) return StatusCode(403, "You are not even a member of the targeted publisher."); @@ -426,15 +518,26 @@ public class PublisherController( var publisherResourceId = $"publisher:{publisher.Id}"; // Delete all file references for this publisher - await fileRefService.DeleteResourceReferencesAsync(publisherResourceId); + await fileRefs.DeleteResourceReferencesAsync( + new DeleteResourceReferencesRequest { ResourceId = publisherResourceId } + ); db.Publishers.Remove(publisher); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( - ActionLogType.PublisherDelete, - new Dictionary { { "publisher_id", publisher.Id } }, Request - ); + _ = als.CreateActionLogAsync(new CreateActionLogRequest + { + Action = "publishers.delete", + Meta = + { + { "publisher_id", Google.Protobuf.WellKnownTypes.Value.ForString(publisher.Id.ToString()) }, + { "publisher_name", Google.Protobuf.WellKnownTypes.Value.ForString(publisher.Name) }, + { "publisher_type", Google.Protobuf.WellKnownTypes.Value.ForString(publisher.Type.ToString()) } + }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() + }); return NoContent(); } @@ -451,11 +554,9 @@ public class PublisherController( .FirstOrDefaultAsync(); if (publisher is null) return NotFound(); - IQueryable query = db.PublisherMembers + var query = db.PublisherMembers .Where(m => m.PublisherId == publisher.Id) - .Where(m => m.JoinedAt != null) - .Include(m => m.Account) - .ThenInclude(m => m.Profile); + .Where(m => m.JoinedAt != null); var total = await query.CountAsync(); Response.Headers["X-Total"] = total.ToString(); @@ -474,7 +575,7 @@ public class PublisherController( public async Task> GetCurrentIdentity(string name) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); - var userId = currentUser.Id; + var accountId = Guid.Parse(currentUser.Id); var publisher = await db.Publishers .Where(p => p.Name == name) @@ -482,10 +583,8 @@ public class PublisherController( if (publisher is null) return NotFound(); var member = await db.PublisherMembers - .Where(m => m.AccountId == userId) + .Where(m => m.AccountId == accountId) .Where(m => m.PublisherId == publisher.Id) - .Include(m => m.Account) - .ThenInclude(m => m.Profile) .FirstOrDefaultAsync(); if (member is null) return NotFound(); diff --git a/DysonNetwork.Sphere/Publisher/PublisherSubscriptionController.cs b/DysonNetwork.Sphere/Publisher/PublisherSubscriptionController.cs index b5270c4..9220111 100644 --- a/DysonNetwork.Sphere/Publisher/PublisherSubscriptionController.cs +++ b/DysonNetwork.Sphere/Publisher/PublisherSubscriptionController.cs @@ -1,3 +1,4 @@ +using DysonNetwork.Shared.Proto; using DysonNetwork.Sphere.Post; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -37,7 +38,7 @@ public class PublisherSubscriptionController( if (publisher == null) return NotFound("Publisher not found"); - var isSubscribed = await subs.SubscriptionExistsAsync(currentUser.Id, publisher.Id); + var isSubscribed = await subs.SubscriptionExistsAsync(Guid.Parse(currentUser.Id), publisher.Id); return new SubscriptionStatusResponse { @@ -63,7 +64,7 @@ public class PublisherSubscriptionController( try { var subscription = await subs.CreateSubscriptionAsync( - currentUser.Id, + Guid.Parse(currentUser.Id), publisher.Id, request.Tier ?? 0 ); @@ -88,7 +89,7 @@ public class PublisherSubscriptionController( if (publisher == null) return NotFound("Publisher not found"); - var success = await subs.CancelSubscriptionAsync(currentUser.Id, publisher.Id); + var success = await subs.CancelSubscriptionAsync(Guid.Parse(currentUser.Id), publisher.Id); if (success) return Ok(new { message = "Subscription cancelled successfully" }); @@ -106,7 +107,7 @@ public class PublisherSubscriptionController( { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); - var subscriptions = await subs.GetAccountSubscriptionsAsync(currentUser.Id); + var subscriptions = await subs.GetAccountSubscriptionsAsync(Guid.Parse(currentUser.Id)); return subscriptions; } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Realm/RealmController.cs b/DysonNetwork.Sphere/Realm/RealmController.cs index adc0603..ca73f2b 100644 --- a/DysonNetwork.Sphere/Realm/RealmController.cs +++ b/DysonNetwork.Sphere/Realm/RealmController.cs @@ -1,6 +1,4 @@ using System.ComponentModel.DataAnnotations; -using DysonNetwork.Sphere.Account; -using DysonNetwork.Sphere.Storage; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; diff --git a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs index 31d083f..502039f 100644 --- a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs @@ -178,7 +178,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped();