Done mixing

This commit is contained in:
2025-07-15 16:10:57 +08:00
parent 3c11c4f3be
commit 8fbc81cab9
34 changed files with 3314 additions and 1378 deletions

View File

@@ -74,7 +74,7 @@ public class ChatMember : ModelBase
public Guid ChatRoomId { get; set; }
public ChatRoom ChatRoom { get; set; } = null!;
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
[NotMapped] public Account Account { get; set; } = null!;
[MaxLength(1024)] public string? Nick { get; set; }
@@ -106,7 +106,7 @@ public class ChatMemberTransmissionObject : ModelBase
public Guid Id { get; set; }
public Guid ChatRoomId { get; set; }
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
[NotMapped] public Account Account { get; set; } = null!;
[MaxLength(1024)] public string? Nick { get; set; }

View File

@@ -2,10 +2,10 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared;
using DysonNetwork.Shared.Auth;
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;
@@ -962,7 +962,7 @@ public class ChatRoomController(
Topic = "invites.chats",
Title = title,
Body = body,
IsSavable = false
IsSavable = true
}
}
);

View File

@@ -1,947 +0,0 @@
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<NotificationResource> localizer,
AccountService.AccountServiceClient accounts,
FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs,
ActionLogService.ActionLogServiceClient als
) : ControllerBase
{
[HttpGet("{id:guid}")]
public async Task<ActionResult<ChatRoom>> 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<ActionResult<List<ChatRoom>>> 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<ActionResult<ChatRoom>> 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<ChatMember>
{
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<ActionResult<ChatRoom>> 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<ActionResult<ChatRoom>> 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<ChatMember>
{
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<ActionResult<ChatRoom>> 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<ActionResult> 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<ActionResult<ChatMember>> 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<ActionResult<List<ChatMember>>> 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<ChatMember> 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<ActionResult<ChatMember>> 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<ActionResult<List<ChatMember>>> 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<ActionResult<ChatRoom>> 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<string, object> { { "chatroom_id", roomId } }, Request
);
return Ok(member);
}
[HttpPost("invites/{roomId:guid}/decline")]
[Authorize]
public async Task<ActionResult> 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<ActionResult<ChatMember>> 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<ActionResult<ChatMember>> 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<ActionResult> 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<ActionResult<ChatRoom>> 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<string, object> { { "chatroom_id", roomId } }, Request
);
return Ok(chatRoom);
}
[HttpDelete("{roomId:guid}/members/me")]
[Authorize]
public async Task<ActionResult> 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");
}
}

View File

@@ -233,6 +233,7 @@ public partial class ChatService(
Body = !string.IsNullOrEmpty(message.Content)
? message.Content[..Math.Min(message.Content.Length, 100)]
: "<no content>",
IsSavable = false
};
notification.Meta.Add(GrpcTypeHelper.ConvertToValueMap(metaDict));

View File

@@ -1,5 +1,5 @@
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

View File

@@ -1,8 +1,8 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Content;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

View File

@@ -1,3 +1,5 @@
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Startup;
using Microsoft.EntityFrameworkCore;
@@ -12,13 +14,15 @@ builder.ConfigureAppKestrel();
builder.Services.AddAppMetrics();
// Add application services
builder.Services.AddRegistryService(builder.Configuration);
builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication();
builder.Services.AddAppSwagger();
// Add file storage
builder.Services.AddAppFileStorage(builder.Configuration);
builder.Services.AddDysonAuth();
builder.Services.AddAccountService();
builder.Services.AddPusherService();
builder.Services.AddDriveService();
// Add flush handlers and websocket handlers
builder.Services.AddAppFlushHandlers();
@@ -38,10 +42,7 @@ using (var scope = app.Services.CreateScope())
await db.Database.MigrateAsync();
}
// Get the TusDiskStore instance
var tusDiskStore = app.Services.GetRequiredService<TusDiskStore>();
// Configure application middleware pipeline
app.ConfigureAppMiddleware(builder.Configuration, tusDiskStore);
app.ConfigureAppMiddleware(builder.Configuration);
app.Run();

View File

@@ -1,7 +1,8 @@
using DysonNetwork.Sphere.Account;
using DysonNetwork.Shared;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
@@ -9,10 +10,11 @@ namespace DysonNetwork.Sphere.Publisher;
public class PublisherSubscriptionService(
AppDatabase db,
NotificationService nty,
PostService ps,
IStringLocalizer<NotificationResource> localizer,
ICacheService cache
ICacheService cache,
PusherService.PusherServiceClient pusher,
AccountService.AccountServiceClient accounts
)
{
/// <summary>
@@ -50,7 +52,6 @@ public class PublisherSubscriptionService(
public async Task<int> NotifySubscriberPost(Post.Post post)
{
var subscribers = await db.PublisherSubscriptions
.Include(p => p.Account)
.Where(p => p.PublisherId == post.PublisherId &&
p.Status == PublisherSubscriptionStatus.Active)
.ToListAsync();
@@ -67,23 +68,35 @@ public class PublisherSubscriptionService(
{ "publisher_id", post.Publisher.Id.ToString() }
};
var queryRequest = new GetAccountBatchRequest();
queryRequest.Id.AddRange(subscribers.DistinctBy(s => s.AccountId).Select(m => m.AccountId.ToString()));
var queryResponse = await accounts.GetAccountBatchAsync(queryRequest);
var notification = new PushNotification
{
Topic = "posts.new",
Title = localizer["PostSubscriptionTitle", post.Publisher.Name, title],
Body = message,
IsSavable = true,
ActionUri = $"/posts/{post.Id}"
};
notification.Meta.Add(GrpcTypeHelper.ConvertToValueMap(data));
// Notify each subscriber
var notifiedCount = 0;
foreach (var subscription in subscribers.DistinctBy(s => s.AccountId))
foreach (var target in queryResponse.Accounts)
{
try
{
AccountService.SetCultureInfo(subscription.Account);
await nty.SendNotification(
subscription.Account,
"posts.new",
localizer["PostSubscriptionTitle", post.Publisher.Name, title],
null,
message,
data,
actionUri: $"/posts/{post.Id}"
CultureService.SetCultureInfo(target);
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = target.Id,
Notification = notification
}
);
notifiedCount++;
}
catch (Exception)
@@ -117,7 +130,6 @@ public class PublisherSubscriptionService(
public async Task<List<PublisherSubscription>> GetPublisherSubscribersAsync(Guid publisherId)
{
return await db.PublisherSubscriptions
.Include(ps => ps.Account)
.Where(ps => ps.PublisherId == publisherId && ps.Status == PublisherSubscriptionStatus.Active)
.ToListAsync();
}

View File

@@ -1,8 +1,8 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using NodaTime;
@@ -25,7 +25,7 @@ public class Realm : ModelBase, IIdentifiedResource
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
[Column(TypeName = "jsonb")] public Account.VerificationMark? Verification { get; set; }
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
[JsonIgnore] public ICollection<RealmMember> Members { get; set; } = new List<RealmMember>();
[JsonIgnore] public ICollection<ChatRoom> ChatRooms { get; set; } = new List<ChatRoom>();
@@ -48,7 +48,6 @@ public class RealmMember : ModelBase
public Guid RealmId { get; set; }
public Realm Realm { get; set; } = null!;
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
public int Role { get; set; } = RealmMemberRole.Normal;
public Instant? JoinedAt { get; set; }

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Chat;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -14,6 +15,7 @@ public class RealmChatController(AppDatabase db, RealmService rs) : ControllerBa
public async Task<ActionResult<List<ChatRoom>>> ListRealmChat(string slug)
{
var currentUser = HttpContext.Items["CurrentUser"] as Account;
var accountId = currentUser is null ? Guid.Empty : Guid.Parse(currentUser.Id);
var realm = await db.Realms
.Where(r => r.Slug == slug)
@@ -22,7 +24,7 @@ public class RealmChatController(AppDatabase db, RealmService rs) : ControllerBa
if (!realm.IsPublic)
{
if (currentUser is null) return Unauthorized();
if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Normal))
if (!await rs.IsMemberWithRole(realm.Id, accountId, RealmMemberRole.Normal))
return StatusCode(403, "You need at least one member to view the realm's chat.");
}

View File

@@ -1,8 +1,11 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Google.Protobuf.WellKnownTypes;
namespace DysonNetwork.Sphere.Realm;
@@ -11,10 +14,10 @@ namespace DysonNetwork.Sphere.Realm;
public class RealmController(
AppDatabase db,
RealmService rs,
FileReferenceService fileRefService,
RelationshipService rels,
ActionLogService als,
AccountEventService aes
FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs,
ActionLogService.ActionLogServiceClient als,
AccountService.AccountServiceClient accounts
) : Controller
{
[HttpGet("{slug}")]
@@ -33,10 +36,10 @@ public class RealmController(
public async Task<ActionResult<List<Realm>>> ListJoinedRealms()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var accountId = Guid.Parse(currentUser.Id);
var members = await db.RealmMembers
.Where(m => m.AccountId == userId)
.Where(m => m.AccountId == accountId)
.Where(m => m.JoinedAt != null)
.Where(m => m.LeaveAt == null)
.Include(e => e.Realm)
@@ -51,10 +54,10 @@ public class RealmController(
public async Task<ActionResult<List<RealmMember>>> 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.RealmMembers
.Where(m => m.AccountId == userId)
.Where(m => m.AccountId == accountId)
.Where(m => m.JoinedAt == null)
.Include(e => e.Realm)
.ToListAsync();
@@ -74,12 +77,19 @@ public class RealmController(
[FromBody] RealmMemberRequest 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");
if (await rels.HasRelationshipWithStatus(currentUser.Id, relatedUser.Id, RelationshipStatus.Blocked))
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 invite a user that blocked you.");
var realm = await db.Realms
@@ -87,11 +97,11 @@ public class RealmController(
.FirstOrDefaultAsync();
if (realm is null) return NotFound();
if (!await rs.IsMemberWithRole(realm.Id, userId, request.Role))
if (!await rs.IsMemberWithRole(realm.Id, accountId, request.Role))
return StatusCode(403, "You cannot invite member has higher permission than yours.");
var hasExistingMember = await db.RealmMembers
.Where(m => m.AccountId == request.RelatedUserId)
.Where(m => m.AccountId == Guid.Parse(relatedUser.Id))
.Where(m => m.RealmId == realm.Id)
.Where(m => m.LeaveAt == null)
.AnyAsync();
@@ -100,7 +110,7 @@ public class RealmController(
var member = new RealmMember
{
AccountId = relatedUser.Id,
AccountId = Guid.Parse(relatedUser.Id),
RealmId = realm.Id,
Role = request.Role,
};
@@ -108,12 +118,21 @@ public class RealmController(
db.RealmMembers.Add(member);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.RealmInvite,
new Dictionary<string, object> { { "realm_id", realm.Id }, { "account_id", member.AccountId } }, Request
);
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.members.invite",
Meta =
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(member.AccountId.ToString()) },
{ "role", Value.ForNumber(request.Role) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
member.Account = relatedUser;
member.AccountId = Guid.Parse(relatedUser.Id);
member.Realm = realm;
await rs.SendInviteNotify(member);
@@ -125,10 +144,10 @@ public class RealmController(
public async Task<ActionResult<Realm>> AcceptMemberInvite(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var accountId = Guid.Parse(currentUser.Id);
var member = await db.RealmMembers
.Where(m => m.AccountId == userId)
.Where(m => m.AccountId == accountId)
.Where(m => m.Realm.Slug == slug)
.Where(m => m.JoinedAt == null)
.FirstOrDefaultAsync();
@@ -138,11 +157,18 @@ public class RealmController(
db.Update(member);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.RealmJoin,
new Dictionary<string, object> { { "realm_id", member.RealmId }, { "account_id", member.AccountId } },
Request
);
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.members.join",
Meta =
{
{ "realm_id", Value.ForString(member.RealmId.ToString()) },
{ "account_id", Value.ForString(member.AccountId.ToString()) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return Ok(member);
}
@@ -152,10 +178,10 @@ public class RealmController(
public async Task<ActionResult> DeclineMemberInvite(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var accountId = Guid.Parse(currentUser.Id);
var member = await db.RealmMembers
.Where(m => m.AccountId == userId)
.Where(m => m.AccountId == accountId)
.Where(m => m.Realm.Slug == slug)
.Where(m => m.JoinedAt == null)
.FirstOrDefaultAsync();
@@ -164,11 +190,19 @@ public class RealmController(
member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.RealmLeave,
new Dictionary<string, object> { { "realm_id", member.RealmId }, { "account_id", member.AccountId } },
Request
);
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.members.decline_invite",
Meta =
{
{ "realm_id", Value.ForString(member.RealmId.ToString()) },
{ "account_id", Value.ForString(member.AccountId.ToString()) },
{ "decliner_id", Value.ForString(currentUser.Id) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return NoContent();
}
@@ -191,43 +225,41 @@ public class RealmController(
if (!realm.IsPublic)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Normal))
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Normal))
return StatusCode(403, "You must be a member to view this realm's members.");
}
IQueryable<RealmMember> query = db.RealmMembers
var query = db.RealmMembers
.Where(m => m.RealmId == realm.Id)
.Where(m => m.LeaveAt == null)
.Include(m => m.Account)
.Include(m => m.Account.Profile);
.Where(m => m.LeaveAt == null);
if (withStatus)
{
var members = await query
.OrderBy(m => m.CreatedAt)
.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["X-Total"] = total.ToString();
var result = members.Skip(offset).Take(take).ToList();
return Ok(result);
}
else
{
// if (withStatus)
// {
// var members = await query
// .OrderBy(m => m.CreatedAt)
// .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["X-Total"] = total.ToString();
//
// var result = members.Skip(offset).Take(take).ToList();
//
// return Ok(result);
// }
// else
// {
var total = await query.CountAsync();
Response.Headers["X-Total"] = total.ToString();
@@ -238,23 +270,20 @@ public class RealmController(
.ToListAsync();
return Ok(members);
}
// }
}
[HttpGet("{slug}/members/me")]
[Authorize]
public async Task<ActionResult<RealmMember>> GetCurrentIdentity(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var accountId = Guid.Parse(currentUser.Id);
var member = await db.RealmMembers
.Where(m => m.AccountId == userId)
.Where(m => m.AccountId == accountId)
.Where(m => m.Realm.Slug == slug)
.Include(m => m.Account)
.Include(m => m.Account.Profile)
.FirstOrDefaultAsync();
if (member is null) return NotFound();
@@ -266,10 +295,10 @@ public class RealmController(
public async Task<ActionResult> LeaveRealm(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var accountId = Guid.Parse(currentUser.Id);
var member = await db.RealmMembers
.Where(m => m.AccountId == userId)
.Where(m => m.AccountId == accountId)
.Where(m => m.Realm.Slug == slug)
.Where(m => m.JoinedAt != null)
.FirstOrDefaultAsync();
@@ -281,11 +310,19 @@ public class RealmController(
member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.RealmLeave,
new Dictionary<string, object> { { "realm_id", member.RealmId }, { "account_id", member.AccountId } },
Request
);
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.members.leave",
Meta =
{
{ "realm_id", Value.ForString(member.RealmId.ToString()) },
{ "account_id", Value.ForString(member.AccountId.ToString()) },
{ "leaver_id", Value.ForString(currentUser.Id) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return NoContent();
}
@@ -317,7 +354,7 @@ public class RealmController(
Name = request.Name!,
Slug = request.Slug!,
Description = request.Description!,
AccountId = currentUser.Id,
AccountId = Guid.Parse(currentUser.Id),
IsCommunity = request.IsCommunity ?? false,
IsPublic = request.IsPublic ?? false,
Members = new List<RealmMember>
@@ -325,7 +362,7 @@ public class RealmController(
new()
{
Role = RealmMemberRole.Owner,
AccountId = currentUser.Id,
AccountId = Guid.Parse(currentUser.Id),
JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow)
}
}
@@ -333,42 +370,56 @@ public class RealmController(
if (request.PictureId is not null)
{
realm.Picture = (await db.Files.FindAsync(request.PictureId))?.ToReferenceObject();
if (realm.Picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud.");
var pictureResult = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId });
if (pictureResult is null) return BadRequest("Invalid picture id, unable to find the file on cloud.");
realm.Picture = CloudFileReferenceObject.FromProtoValue(pictureResult);
}
if (request.BackgroundId is not null)
{
realm.Background = (await db.Files.FindAsync(request.BackgroundId))?.ToReferenceObject();
if (realm.Background is null) return BadRequest("Invalid background id, unable to find the file on cloud.");
var backgroundResult = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId });
if (backgroundResult is null) return BadRequest("Invalid background id, unable to find the file on cloud.");
realm.Background = CloudFileReferenceObject.FromProtoValue(backgroundResult);
}
db.Realms.Add(realm);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.RealmCreate,
new Dictionary<string, object> { { "realm_id", realm.Id } }, Request
);
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.create",
Meta =
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "name", Value.ForString(realm.Name) },
{ "slug", Value.ForString(realm.Slug) },
{ "is_community", Value.ForBool(realm.IsCommunity) },
{ "is_public", Value.ForBool(realm.IsPublic) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
var realmResourceId = $"realm:{realm.Id}";
if (realm.Picture is not null)
{
await fileRefService.CreateReferenceAsync(
realm.Picture.Id,
"realm.picture",
realmResourceId
);
await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
{
FileId = realm.Picture.Id,
Usage = "realm.picture",
ResourceId = realmResourceId
});
}
if (realm.Background is not null)
{
await fileRefService.CreateReferenceAsync(
realm.Background.Id,
"realm.background",
realmResourceId
);
await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
{
FileId = realm.Background.Id,
Usage = "realm.background",
ResourceId = realmResourceId
});
}
return Ok(realm);
@@ -385,8 +436,9 @@ public class RealmController(
.FirstOrDefaultAsync();
if (realm is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id);
var member = await db.RealmMembers
.Where(m => m.AccountId == currentUser.Id && m.RealmId == realm.Id && m.JoinedAt != null)
.Where(m => m.AccountId == accountId && m.RealmId == realm.Id && m.JoinedAt != null)
.FirstOrDefaultAsync();
if (member is null || member.Role < RealmMemberRole.Moderator)
return StatusCode(403, "You do not have permission to update this realm.");
@@ -409,53 +461,75 @@ public class RealmController(
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.");
var pictureResult = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId });
if (pictureResult is null) return BadRequest("Invalid picture id, unable to find the file on cloud.");
// Remove old references for the realm picture
if (realm.Picture is not null)
{
await fileRefService.DeleteResourceReferencesAsync(realm.ResourceIdentifier, "realm.picture");
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
{
ResourceId = realm.ResourceIdentifier
});
}
realm.Picture = picture.ToReferenceObject();
realm.Picture = CloudFileReferenceObject.FromProtoValue(pictureResult);
// Create a new reference
await fileRefService.CreateReferenceAsync(
picture.Id,
"realm.picture",
realm.ResourceIdentifier
);
await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
{
FileId = realm.Picture.Id,
Usage = "realm.picture",
ResourceId = realm.ResourceIdentifier
});
}
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.");
var backgroundResult = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId });
if (backgroundResult is null) return BadRequest("Invalid background id, unable to find the file on cloud.");
// Remove old references for the realm background
if (realm.Background is not null)
{
await fileRefService.DeleteResourceReferencesAsync(realm.ResourceIdentifier, "realm.background");
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
{
ResourceId = realm.ResourceIdentifier
});
}
realm.Background = background.ToReferenceObject();
realm.Background = CloudFileReferenceObject.FromProtoValue(backgroundResult);
// Create a new reference
await fileRefService.CreateReferenceAsync(
background.Id,
"realm.background",
realm.ResourceIdentifier
);
await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
{
FileId = realm.Background.Id,
Usage = "realm.background",
ResourceId = realm.ResourceIdentifier
});
}
db.Realms.Update(realm);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.RealmUpdate,
new Dictionary<string, object> { { "realm_id", realm.Id } }, Request
);
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.update",
Meta =
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "name_updated", Value.ForBool(request.Name != null) },
{ "slug_updated", Value.ForBool(request.Slug != null) },
{ "description_updated", Value.ForBool(request.Description != null) },
{ "picture_updated", Value.ForBool(request.PictureId != null) },
{ "background_updated", Value.ForBool(request.BackgroundId != null) },
{ "is_community_updated", Value.ForBool(request.IsCommunity != null) },
{ "is_public_updated", Value.ForBool(request.IsPublic != null) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return Ok(realm);
}
@@ -475,14 +549,14 @@ public class RealmController(
return StatusCode(403, "Only community realms can be joined without invitation.");
var existingMember = await db.RealmMembers
.Where(m => m.AccountId == currentUser.Id && m.RealmId == realm.Id)
.Where(m => m.AccountId == Guid.Parse(currentUser.Id) && m.RealmId == realm.Id)
.FirstOrDefaultAsync();
if (existingMember is not null)
return BadRequest("You are already a member of this realm.");
var member = new RealmMember
{
AccountId = currentUser.Id,
AccountId = Guid.Parse(currentUser.Id),
RealmId = realm.Id,
Role = RealmMemberRole.Normal,
JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow)
@@ -491,11 +565,19 @@ public class RealmController(
db.RealmMembers.Add(member);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.RealmJoin,
new Dictionary<string, object> { { "realm_id", realm.Id }, { "account_id", currentUser.Id } },
Request
);
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.members.join",
Meta =
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(currentUser.Id) },
{ "is_community", Value.ForBool(realm.IsCommunity) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return Ok(member);
}
@@ -516,17 +598,25 @@ public class RealmController(
.FirstOrDefaultAsync();
if (member is null) return NotFound();
if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Moderator, member.Role))
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Moderator, member.Role))
return StatusCode(403, "You do not have permission to remove members from this realm.");
member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.ChatroomKick,
new Dictionary<string, object> { { "realm_id", realm.Id }, { "account_id", memberId } },
Request
);
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.members.kick",
Meta =
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(memberId.ToString()) },
{ "kicker_id", Value.ForString(currentUser.Id) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return NoContent();
}
@@ -545,23 +635,31 @@ public class RealmController(
var member = await db.RealmMembers
.Where(m => m.AccountId == memberId && m.RealmId == realm.Id)
.Include(m => m.Account)
.FirstOrDefaultAsync();
if (member is null) return NotFound();
if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Moderator, member.Role, newRole))
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Moderator, member.Role,
newRole))
return StatusCode(403, "You do not have permission to update member roles in this realm.");
member.Role = newRole;
db.RealmMembers.Update(member);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.RealmAdjustRole,
new Dictionary<string, object>
{ { "realm_id", realm.Id }, { "account_id", memberId }, { "new_role", newRole } },
Request
);
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.members.role_update",
Meta =
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(memberId.ToString()) },
{ "new_role", Value.ForNumber(newRole) },
{ "updater_id", Value.ForString(currentUser.Id) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return Ok(member);
}
@@ -574,26 +672,36 @@ public class RealmController(
var realm = await db.Realms
.Where(r => r.Slug == slug)
.Include(r => r.Picture)
.Include(r => r.Background)
.FirstOrDefaultAsync();
if (realm is null) return NotFound();
if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Owner))
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Owner))
return StatusCode(403, "Only the owner can delete this realm.");
db.Realms.Remove(realm);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.RealmDelete,
new Dictionary<string, object> { { "realm_id", realm.Id } }, Request
);
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.delete",
Meta =
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "realm_name", Value.ForString(realm.Name) },
{ "realm_slug", Value.ForString(realm.Slug) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
// Delete all file references for this realm
var realmResourceId = $"realm:{realm.Id}";
await fileRefService.DeleteResourceReferencesAsync(realmResourceId);
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
{
ResourceId = realmResourceId
});
return NoContent();
}
}
}

View File

@@ -0,0 +1,696 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Google.Protobuf.WellKnownTypes;
namespace DysonNetwork.Sphere.Realm;
[ApiController]
[Route("/api/realms")]
public class RealmController(
AppDatabase db,
RealmService rs,
FileReferenceService.FileReferenceServiceClient fileRefs,
ActionLogService.ActionLogServiceClient als,
AccountService.AccountServiceClient accounts
) : Controller
{
[HttpGet("{slug}")]
public async Task<ActionResult<Realm>> GetRealm(string slug)
{
var realm = await db.Realms
.Where(e => e.Slug == slug)
.FirstOrDefaultAsync();
if (realm is null) return NotFound();
return Ok(realm);
}
[HttpGet]
[Authorize]
public async Task<ActionResult<List<Realm>>> ListJoinedRealms()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var members = await db.RealmMembers
.Where(m => m.AccountId == accountId)
.Where(m => m.JoinedAt != null)
.Where(m => m.LeaveAt == null)
.Include(e => e.Realm)
.Select(m => m.Realm)
.ToListAsync();
return members.ToList();
}
[HttpGet("invites")]
[Authorize]
public async Task<ActionResult<List<RealmMember>>> ListInvites()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var members = await db.RealmMembers
.Where(m => m.AccountId == accountId)
.Where(m => m.JoinedAt == null)
.Include(e => e.Realm)
.ToListAsync();
return members.ToList();
}
public class RealmMemberRequest
{
[Required] public Guid RelatedUserId { get; set; }
[Required] public int Role { get; set; }
}
[HttpPost("invites/{slug}")]
[Authorize]
public async Task<ActionResult<RealmMember>> InviteMember(string slug,
[FromBody] RealmMemberRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var relatedUser =
await accounts.GetAccountAsync(new GetAccountRequest { Id = request.RelatedUserId.ToString() });
if (relatedUser == 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 invite a user that blocked you.");
var realm = await db.Realms
.Where(p => p.Slug == slug)
.FirstOrDefaultAsync();
if (realm is null) return NotFound();
if (!await rs.IsMemberWithRole(realm.Id, accountId, request.Role))
return StatusCode(403, "You cannot invite member has higher permission than yours.");
var hasExistingMember = await db.RealmMembers
.Where(m => m.AccountId == Guid.Parse(relatedUser.Id))
.Where(m => m.RealmId == realm.Id)
.Where(m => m.LeaveAt == null)
.AnyAsync();
if (hasExistingMember)
return BadRequest("This user has been joined the realm or leave cannot be invited again.");
var member = new RealmMember
{
AccountId = Guid.Parse(relatedUser.Id),
RealmId = realm.Id,
Role = request.Role,
};
db.RealmMembers.Add(member);
await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.members.invite",
Meta =
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(member.AccountId.ToString()) },
{ "role", Value.ForNumber(request.Role) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
member.Account = relatedUser;
member.Realm = realm;
await rs.SendInviteNotify(member);
return Ok(member);
}
[HttpPost("invites/{slug}/accept")]
[Authorize]
public async Task<ActionResult<Realm>> AcceptMemberInvite(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var member = await db.RealmMembers
.Where(m => m.AccountId == accountId)
.Where(m => m.Realm.Slug == slug)
.Where(m => m.JoinedAt == null)
.FirstOrDefaultAsync();
if (member is null) return NotFound();
member.JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow);
db.Update(member);
await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.members.join",
Meta =
{
{ "realm_id", Value.ForString(member.RealmId.ToString()) },
{ "account_id", Value.ForString(member.AccountId.ToString()) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return Ok(member);
}
[HttpPost("invites/{slug}/decline")]
[Authorize]
public async Task<ActionResult> DeclineMemberInvite(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var member = await db.RealmMembers
.Where(m => m.AccountId == accountId)
.Where(m => m.Realm.Slug == slug)
.Where(m => m.JoinedAt == null)
.FirstOrDefaultAsync();
if (member is null) return NotFound();
member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.RealmLeave,
new Dictionary<string, object> { { "realm_id", member.RealmId }, { "account_id", member.AccountId } },
Request
);
return NoContent();
}
[HttpGet("{slug}/members")]
public async Task<ActionResult<List<RealmMember>>> ListMembers(
string slug,
[FromQuery] int offset = 0,
[FromQuery] int take = 20,
[FromQuery] bool withStatus = false,
[FromQuery] string? status = null
)
{
var realm = await db.Realms
.Where(r => r.Slug == slug)
.FirstOrDefaultAsync();
if (realm is null) return NotFound();
if (!realm.IsPublic)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Normal))
return StatusCode(403, "You must be a member to view this realm's members.");
}
IQueryable<RealmMember> query = db.RealmMembers
.Where(m => m.RealmId == realm.Id)
.Where(m => m.LeaveAt == null)
.Include(m => m.Account)
.Include(m => m.Account.Profile);
if (withStatus)
{
var members = await query
.OrderBy(m => m.CreatedAt)
.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["X-Total"] = total.ToString();
var result = members.Skip(offset).Take(take).ToList();
return Ok(result);
}
else
{
var total = await query.CountAsync();
Response.Headers["X-Total"] = total.ToString();
var members = await query
.OrderBy(m => m.CreatedAt)
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(members);
}
}
[HttpGet("{slug}/members/me")]
[Authorize]
public async Task<ActionResult<RealmMember>> GetCurrentIdentity(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var member = await db.RealmMembers
.Where(m => m.AccountId == accountId)
.Where(m => m.Realm.Slug == slug)
.FirstOrDefaultAsync();
if (member is null) return NotFound();
return Ok(member);
}
[HttpDelete("{slug}/members/me")]
[Authorize]
public async Task<ActionResult> LeaveRealm(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var member = await db.RealmMembers
.Where(m => m.AccountId == accountId)
.Where(m => m.Realm.Slug == slug)
.Where(m => m.JoinedAt != null)
.FirstOrDefaultAsync();
if (member is null) return NotFound();
if (member.Role == RealmMemberRole.Owner)
return StatusCode(403, "Owner cannot leave their own realm.");
member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.RealmLeave,
new Dictionary<string, object> { { "realm_id", member.RealmId }, { "account_id", member.AccountId } },
Request
);
return NoContent();
}
public class RealmRequest
{
[MaxLength(1024)] public string? Slug { get; set; }
[MaxLength(1024)] public string? Name { get; set; }
[MaxLength(4096)] public string? Description { get; set; }
public string? PictureId { get; set; }
public string? BackgroundId { get; set; }
public bool? IsCommunity { get; set; }
public bool? IsPublic { get; set; }
}
[HttpPost]
[Authorize]
public async Task<ActionResult<Realm>> CreateRealm(RealmRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest("You cannot create a realm without a name.");
if (string.IsNullOrWhiteSpace(request.Slug)) return BadRequest("You cannot create a realm without a slug.");
var slugExists = await db.Realms.AnyAsync(r => r.Slug == request.Slug);
if (slugExists) return BadRequest("Realm with this slug already exists.");
var realm = new Realm
{
Name = request.Name!,
Slug = request.Slug!,
Description = request.Description!,
AccountId = currentUser.Id,
IsCommunity = request.IsCommunity ?? false,
IsPublic = request.IsPublic ?? false,
Members = new List<RealmMember>
{
new()
{
Role = RealmMemberRole.Owner,
AccountId = Guid.Parse(currentUser.Id),
JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow)
}
}
};
if (request.PictureId is not null)
{
var pictureResult = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId });
if (pictureResult is null) return BadRequest("Invalid picture id, unable to find the file on cloud.");
realm.Picture = CloudFileReferenceObject.FromProtoValue(pictureResult);
}
if (request.BackgroundId is not null)
{
var backgroundResult = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId });
if (backgroundResult is null) return BadRequest("Invalid background id, unable to find the file on cloud.");
realm.Background = CloudFileReferenceObject.FromProtoValue(backgroundResult);
}
db.Realms.Add(realm);
await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.create",
Meta =
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "name", Value.ForString(realm.Name) },
{ "slug", Value.ForString(realm.Slug) },
{ "is_community", Value.ForBool(realm.IsCommunity) },
{ "is_public", Value.ForBool(realm.IsPublic) }
},
UserId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
var realmResourceId = $"realm:{realm.Id}";
if (realm.Picture is not null)
{
await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
{
FileId = realm.Picture.Id,
Usage = "realm.picture",
ResourceId = realmResourceId
});
}
if (realm.Background is not null)
{
await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
{
FileId = realm.Background.Id,
Usage = "realm.background",
ResourceId = realmResourceId
});
}
return Ok(realm);
}
[HttpPatch("{slug}")]
[Authorize]
public async Task<ActionResult<Realm>> Update(string slug, [FromBody] RealmRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var realm = await db.Realms
.Where(r => r.Slug == slug)
.FirstOrDefaultAsync();
if (realm is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id);
var member = await db.RealmMembers
.Where(m => m.AccountId == accountId && m.RealmId == realm.Id && m.JoinedAt != null)
.FirstOrDefaultAsync();
if (member is null || member.Role < RealmMemberRole.Moderator)
return StatusCode(403, "You do not have permission to update this realm.");
if (request.Slug is not null && request.Slug != realm.Slug)
{
var slugExists = await db.Realms.AnyAsync(r => r.Slug == request.Slug);
if (slugExists) return BadRequest("Realm with this slug already exists.");
realm.Slug = request.Slug;
}
if (request.Name is not null)
realm.Name = request.Name;
if (request.Description is not null)
realm.Description = request.Description;
if (request.IsCommunity is not null)
realm.IsCommunity = request.IsCommunity.Value;
if (request.IsPublic is not null)
realm.IsPublic = request.IsPublic.Value;
if (request.PictureId is not null)
{
var pictureResult = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId });
if (pictureResult is null) return BadRequest("Invalid picture id, unable to find the file on cloud.");
// Remove old references for the realm picture
if (realm.Picture is not null)
{
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
{
ResourceId = realm.ResourceIdentifier
});
}
realm.Picture = CloudFileReferenceObject.FromProtoValue(pictureResult);
// Create a new reference
await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
{
FileId = realm.Picture.Id,
Usage = "realm.picture",
ResourceId = realm.ResourceIdentifier
});
}
if (request.BackgroundId is not null)
{
var backgroundResult = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId });
if (backgroundResult is null) return BadRequest("Invalid background id, unable to find the file on cloud.");
// Remove old references for the realm background
if (realm.Background is not null)
{
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
{
ResourceId = realm.ResourceIdentifier
});
}
realm.Background = CloudFileReferenceObject.FromProtoValue(backgroundResult);
// Create a new reference
await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
{
FileId = realm.Background.Id,
Usage = "realm.background",
ResourceId = realm.ResourceIdentifier
});
}
db.Realms.Update(realm);
await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.update",
Meta =
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "name_updated", Value.ForBool(request.Name != null) },
{ "slug_updated", Value.ForBool(request.Slug != null) },
{ "description_updated", Value.ForBool(request.Description != null) },
{ "picture_updated", Value.ForBool(request.PictureId != null) },
{ "background_updated", Value.ForBool(request.BackgroundId != null) },
{ "is_community_updated", Value.ForBool(request.IsCommunity != null) },
{ "is_public_updated", Value.ForBool(request.IsPublic != null) }
},
UserId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return Ok(realm);
}
[HttpPost("{slug}/members/me")]
[Authorize]
public async Task<ActionResult<RealmMember>> JoinRealm(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var realm = await db.Realms
.Where(r => r.Slug == slug)
.FirstOrDefaultAsync();
if (realm is null) return NotFound();
if (!realm.IsCommunity)
return StatusCode(403, "Only community realms can be joined without invitation.");
var existingMember = await db.RealmMembers
.Where(m => m.AccountId == Guid.Parse(currentUser.Id) && m.RealmId == realm.Id)
.FirstOrDefaultAsync();
if (existingMember is not null)
return BadRequest("You are already a member of this realm.");
var member = new RealmMember
{
AccountId = currentUser.Id,
RealmId = realm.Id,
Role = RealmMemberRole.Normal,
JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow)
};
db.RealmMembers.Add(member);
await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.members.join",
Meta =
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(currentUser.Id) },
{ "is_community", Value.ForBool(realm.IsCommunity) }
},
UserId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return Ok(member);
}
[HttpDelete("{slug}/members/{memberId:guid}")]
[Authorize]
public async Task<ActionResult> RemoveMember(string slug, Guid memberId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var realm = await db.Realms
.Where(r => r.Slug == slug)
.FirstOrDefaultAsync();
if (realm is null) return NotFound();
var member = await db.RealmMembers
.Where(m => m.AccountId == memberId && m.RealmId == realm.Id)
.FirstOrDefaultAsync();
if (member is null) return NotFound();
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Moderator, member.Role))
return StatusCode(403, "You do not have permission to remove members from this realm.");
member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.members.kick",
Meta =
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(memberId.ToString()) },
{ "kicker_id", Value.ForString(currentUser.Id) }
},
UserId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return NoContent();
}
[HttpPatch("{slug}/members/{memberId:guid}/role")]
[Authorize]
public async Task<ActionResult<RealmMember>> UpdateMemberRole(string slug, Guid memberId, [FromBody] int newRole)
{
if (newRole >= RealmMemberRole.Owner) return BadRequest("Unable to set realm member to owner or greater role.");
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var realm = await db.Realms
.Where(r => r.Slug == slug)
.FirstOrDefaultAsync();
if (realm is null) return NotFound();
var member = await db.RealmMembers
.Where(m => m.AccountId == memberId && m.RealmId == realm.Id)
.Include(m => m.Account)
.FirstOrDefaultAsync();
if (member is null) return NotFound();
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Moderator, member.Role,
newRole))
return StatusCode(403, "You do not have permission to update member roles in this realm.");
member.Role = newRole;
db.RealmMembers.Update(member);
await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.members.role_update",
Meta =
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(memberId.ToString()) },
{ "new_role", Value.ForNumber(newRole) },
{ "updater_id", Value.ForString(currentUser.Id) }
},
UserId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return Ok(member);
}
[HttpDelete("{slug}")]
[Authorize]
public async Task<ActionResult> Delete(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var realm = await db.Realms
.Where(r => r.Slug == slug)
.Include(r => r.Picture)
.Include(r => r.Background)
.FirstOrDefaultAsync();
if (realm is null) return NotFound();
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Owner))
return StatusCode(403, "Only the owner can delete this realm.");
db.Realms.Remove(realm);
await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.delete",
Meta =
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "realm_name", Value.ForString(realm.Name) },
{ "realm_slug", Value.ForString(realm.Slug) }
},
UserId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
// Delete all file references for this realm
var realmResourceId = $"realm:{realm.Id}";
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
{
ResourceId = realmResourceId
});
return NoContent();
}
}

View File

@@ -1,22 +1,36 @@
using DysonNetwork.Sphere.Account;
using DysonNetwork.Shared;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Localization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
namespace DysonNetwork.Sphere.Realm;
public class RealmService(AppDatabase db, NotificationService nty, IStringLocalizer<NotificationResource> localizer)
public class RealmService(
AppDatabase db,
PusherService.PusherServiceClient pusher,
AccountService.AccountServiceClient accounts,
IStringLocalizer<NotificationResource> localizer
)
{
public async Task SendInviteNotify(RealmMember member)
{
AccountService.SetCultureInfo(member.Account);
await nty.SendNotification(
member.Account,
"invites.realms",
localizer["RealmInviteTitle"],
null,
localizer["RealmInviteBody", member.Realm.Name],
actionUri: "/realms"
var account = await accounts.GetAccountAsync(new GetAccountRequest { Id = member.AccountId.ToString() });
CultureService.SetCultureInfo(account);
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id,
Notification = new PushNotification
{
Topic = "invites.realms",
Title = localizer["RealmInviteTitle"],
Body = localizer["RealmInviteBody", member.Realm.Name],
ActionUri = "/realms",
IsSavable = true
}
}
);
}

View File

@@ -1,154 +0,0 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Permission;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Safety;
[ApiController]
[Route("/api/safety/reports")]
public class AbuseReportController(
SafetyService safety
) : ControllerBase
{
public class CreateReportRequest
{
[Required] public string ResourceIdentifier { get; set; } = null!;
[Required] public AbuseReportType Type { get; set; }
[Required]
[MinLength(10)]
[MaxLength(1000)]
public string Reason { get; set; } = null!;
}
[HttpPost("")]
[Authorize]
[ProducesResponseType<AbuseReport>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<AbuseReport>> CreateReport([FromBody] CreateReportRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
try
{
var report = await safety.CreateReport(
request.ResourceIdentifier,
request.Type,
request.Reason,
currentUser.Id
);
return Ok(report);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("")]
[Authorize]
[RequiredPermission("safety", "reports.view")]
[ProducesResponseType<List<AbuseReport>>(StatusCodes.Status200OK)]
public async Task<ActionResult<List<AbuseReport>>> GetReports(
[FromQuery] int offset = 0,
[FromQuery] int take = 20,
[FromQuery] bool includeResolved = false
)
{
var totalCount = await safety.CountReports(includeResolved);
var reports = await safety.GetReports(offset, take, includeResolved);
Response.Headers["X-Total"] = totalCount.ToString();
return Ok(reports);
}
[HttpGet("me")]
[Authorize]
[ProducesResponseType<List<AbuseReport>>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<List<AbuseReport>>> GetMyReports(
[FromQuery] int offset = 0,
[FromQuery] int take = 20,
[FromQuery] bool includeResolved = false
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var totalCount = await safety.CountUserReports(currentUser.Id, includeResolved);
var reports = await safety.GetUserReports(currentUser.Id, offset, take, includeResolved);
Response.Headers["X-Total"] = totalCount.ToString();
return Ok(reports);
}
[HttpGet("{id}")]
[Authorize]
[RequiredPermission("safety", "reports.view")]
[ProducesResponseType<AbuseReport>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AbuseReport>> GetReportById(Guid id)
{
var report = await safety.GetReportById(id);
return report == null ? NotFound() : Ok(report);
}
[HttpGet("me/{id}")]
[Authorize]
[ProducesResponseType<AbuseReport>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<AbuseReport>> GetMyReportById(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var report = await safety.GetReportById(id);
if (report == null) return NotFound();
// Ensure the user only accesses their own reports
if (report.AccountId != currentUser.Id) return Forbid();
return Ok(report);
}
public class ResolveReportRequest
{
[Required]
[MinLength(5)]
[MaxLength(1000)]
public string Resolution { get; set; } = null!;
}
[HttpPost("{id}/resolve")]
[Authorize]
[RequiredPermission("safety", "reports.resolve")]
[ProducesResponseType<AbuseReport>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AbuseReport>> ResolveReport(Guid id, [FromBody] ResolveReportRequest request)
{
try
{
var report = await safety.ResolveReport(id, request.Resolution);
return Ok(report);
}
catch (KeyNotFoundException)
{
return NotFound();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("count")]
[Authorize]
[RequiredPermission("safety", "reports.view")]
[ProducesResponseType<object>(StatusCodes.Status200OK)]
public async Task<ActionResult<object>> GetReportsCount()
{
var count = await safety.GetPendingReportsCount();
return Ok(new { pendingCount = count });
}
}

View File

@@ -1,105 +0,0 @@
using DysonNetwork.Sphere.Account;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Safety;
public class SafetyService(AppDatabase db, ILogger<SafetyService> logger)
{
public async Task<AbuseReport> CreateReport(string resourceIdentifier, AbuseReportType type, string reason, Guid accountId)
{
// Check if a similar report already exists from this user
var existingReport = await db.AbuseReports
.Where(r => r.ResourceIdentifier == resourceIdentifier &&
r.AccountId == accountId &&
r.DeletedAt == null)
.FirstOrDefaultAsync();
if (existingReport != null)
{
throw new InvalidOperationException("You have already reported this content.");
}
var report = new AbuseReport
{
ResourceIdentifier = resourceIdentifier,
Type = type,
Reason = reason,
AccountId = accountId
};
db.AbuseReports.Add(report);
await db.SaveChangesAsync();
logger.LogInformation("New abuse report created: {ReportId} for resource {ResourceId}",
report.Id, resourceIdentifier);
return report;
}
public async Task<int> CountReports(bool includeResolved = false)
{
return await db.AbuseReports
.Where(r => includeResolved || r.ResolvedAt == null)
.CountAsync();
}
public async Task<int> CountUserReports(Guid accountId, bool includeResolved = false)
{
return await db.AbuseReports
.Where(r => r.AccountId == accountId)
.Where(r => includeResolved || r.ResolvedAt == null)
.CountAsync();
}
public async Task<List<AbuseReport>> GetReports(int skip = 0, int take = 20, bool includeResolved = false)
{
return await db.AbuseReports
.Where(r => includeResolved || r.ResolvedAt == null)
.OrderByDescending(r => r.CreatedAt)
.Skip(skip)
.Take(take)
.Include(r => r.Account)
.ToListAsync();
}
public async Task<List<AbuseReport>> GetUserReports(Guid accountId, int skip = 0, int take = 20, bool includeResolved = false)
{
return await db.AbuseReports
.Where(r => r.AccountId == accountId)
.Where(r => includeResolved || r.ResolvedAt == null)
.OrderByDescending(r => r.CreatedAt)
.Skip(skip)
.Take(take)
.ToListAsync();
}
public async Task<AbuseReport?> GetReportById(Guid id)
{
return await db.AbuseReports
.Include(r => r.Account)
.FirstOrDefaultAsync(r => r.Id == id);
}
public async Task<AbuseReport> ResolveReport(Guid id, string resolution)
{
var report = await db.AbuseReports.FindAsync(id);
if (report == null)
{
throw new KeyNotFoundException("Report not found");
}
report.ResolvedAt = SystemClock.Instance.GetCurrentInstant();
report.Resolution = resolution;
await db.SaveChangesAsync();
return report;
}
public async Task<int> GetPendingReportsCount()
{
return await db.AbuseReports
.Where(r => r.ResolvedAt == null)
.CountAsync();
}
}

View File

@@ -1,21 +1,17 @@
using System.Net;
using DysonNetwork.Sphere.Connection;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Shared.Auth;
using Microsoft.AspNetCore.HttpOverrides;
using Prometheus;
using tusdotnet;
using tusdotnet.Stores;
namespace DysonNetwork.Sphere.Startup;
public static class ApplicationConfiguration
{
public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration, TusDiskStore tusDiskStore)
public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration)
{
app.MapMetrics();
app.MapOpenApi();
app.UseMiddleware<ClientTypeMiddleware>();
app.UseSwagger();
app.UseSwaggerUI();
@@ -44,8 +40,6 @@ public static class ApplicationConfiguration
app.MapStaticAssets().RequireRateLimiting("fixed");
app.MapRazorPages().RequireRateLimiting("fixed");
app.MapTus("/files/tus", _ => Task.FromResult(TusService.BuildConfiguration(tusDiskStore)));
return app;
}

View File

@@ -1,7 +1,4 @@
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.WebReader;
using DysonNetwork.Sphere.Storage.Handlers;
using DysonNetwork.Sphere.Wallet;
using Quartz;
namespace DysonNetwork.Sphere.Startup;
@@ -19,63 +16,15 @@ public static class ScheduledJobsConfiguration
.WithIdentity("AppDatabaseRecyclingTrigger")
.WithCronSchedule("0 0 0 * * ?"));
var cloudFilesRecyclingJob = new JobKey("CloudFilesUnusedRecycling");
q.AddJob<CloudFileUnusedRecyclingJob>(opts => opts.WithIdentity(cloudFilesRecyclingJob));
q.AddTrigger(opts => opts
.ForJob(cloudFilesRecyclingJob)
.WithIdentity("CloudFilesUnusedRecyclingTrigger")
.WithSimpleSchedule(o => o.WithIntervalInHours(1).RepeatForever())
);
var actionLogFlushJob = new JobKey("ActionLogFlush");
q.AddJob<ActionLogFlushJob>(opts => opts.WithIdentity(actionLogFlushJob));
q.AddTrigger(opts => opts
.ForJob(actionLogFlushJob)
.WithIdentity("ActionLogFlushTrigger")
.WithSimpleSchedule(o => o
.WithIntervalInMinutes(5)
.RepeatForever())
);
var readReceiptFlushJob = new JobKey("ReadReceiptFlush");
q.AddJob<ReadReceiptFlushJob>(opts => opts.WithIdentity(readReceiptFlushJob));
q.AddTrigger(opts => opts
.ForJob(readReceiptFlushJob)
.WithIdentity("ReadReceiptFlushTrigger")
.WithSimpleSchedule(o => o
.WithIntervalInSeconds(60)
.RepeatForever())
);
var lastActiveFlushJob = new JobKey("LastActiveFlush");
q.AddJob<LastActiveFlushJob>(opts => opts.WithIdentity(lastActiveFlushJob));
q.AddTrigger(opts => opts
.ForJob(lastActiveFlushJob)
.WithIdentity("LastActiveFlushTrigger")
.WithSimpleSchedule(o => o
.WithIntervalInMinutes(5)
.RepeatForever())
);
var postViewFlushJob = new JobKey("PostViewFlush");
q.AddJob<PostViewFlushJob>(opts => opts.WithIdentity(postViewFlushJob));
q.AddTrigger(opts => opts
.ForJob(postViewFlushJob)
.WithIdentity("PostViewFlushTrigger")
.WithSimpleSchedule(o => o
.WithIntervalInMinutes(1)
.RepeatForever())
);
var subscriptionRenewalJob = new JobKey("SubscriptionRenewal");
q.AddJob<SubscriptionRenewalJob>(opts => opts.WithIdentity(subscriptionRenewalJob));
q.AddTrigger(opts => opts
.ForJob(subscriptionRenewalJob)
.WithIdentity("SubscriptionRenewalTrigger")
.WithSimpleSchedule(o => o
.WithIntervalInMinutes(30)
.RepeatForever())
);
// var postViewFlushJob = new JobKey("PostViewFlush");
// q.AddJob<PostViewFlushJob>(opts => opts.WithIdentity(postViewFlushJob));
// q.AddTrigger(opts => opts
// .ForJob(postViewFlushJob)
// .WithIdentity("PostViewFlushTrigger")
// .WithSimpleSchedule(o => o
// .WithIntervalInMinutes(1)
// .RepeatForever())
// );
var webFeedScraperJob = new JobKey("WebFeedScraper");
q.AddJob<WebFeedScraperJob>(opts => opts.WithIdentity(webFeedScraperJob));

View File

@@ -21,9 +21,7 @@ using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.WebReader;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Discovery;
using DysonNetwork.Sphere.Safety;
using tusdotnet.Stores;
using PermissionService = DysonNetwork.Sphere.Permission.PermissionService;
namespace DysonNetwork.Sphere.Startup;
@@ -90,12 +88,6 @@ public static class ServiceCollectionExtensions
{
services.AddCors();
services.AddAuthorization();
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = AuthConstants.SchemeName;
options.DefaultChallengeScheme = AuthConstants.SchemeName;
})
.AddScheme<DysonTokenAuthOptions, DysonTokenAuthHandler>(AuthConstants.SchemeName, _ => { });
return services;
}
@@ -146,17 +138,6 @@ public static class ServiceCollectionExtensions
return services;
}
public static IServiceCollection AddAppFileStorage(this IServiceCollection services, IConfiguration configuration)
{
var tusStorePath = configuration.GetSection("Tus").GetValue<string>("StorePath")!;
Directory.CreateDirectory(tusStorePath);
var tusDiskStore = new TusDiskStore(tusStorePath);
services.AddSingleton(tusDiskStore);
return services;
}
public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services)
{
services.AddSingleton<FlushBufferService>();
@@ -169,7 +150,6 @@ public static class ServiceCollectionExtensions
{
services.Configure<GeoIpOptions>(configuration.GetSection("GeoIP"));
services.AddScoped<GeoIpService>();
services.AddScoped<PermissionService>();
services.AddScoped<PublisherService>();
services.AddScoped<PublisherSubscriptionService>();
services.AddScoped<ActivityService>();
@@ -181,7 +161,6 @@ public static class ServiceCollectionExtensions
services.AddScoped<IRealtimeService, LiveKitRealtimeService>();
services.AddScoped<WebReaderService>();
services.AddScoped<WebFeedService>();
services.AddScoped<SafetyService>();
services.AddScoped<DiscoveryService>();
services.AddScoped<CustomAppService>();

View File

@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

View File

@@ -23,12 +23,12 @@ public class StickerService(
db.Stickers.Add(sticker);
await db.SaveChangesAsync();
var stickerResourceId = $"sticker:{sticker.Id}";
await fileRefService.CreateReferenceAsync(
sticker.Image.Id,
StickerFileUsageIdentifier,
stickerResourceId
);
await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
{
FileId = sticker.Image.Id,
Usage = StickerFileUsageIdentifier,
ResourceId = sticker.ResourceIdentifier
});
return sticker;
}
@@ -37,24 +37,17 @@ public class StickerService(
{
if (newImage is not null)
{
var stickerResourceId = $"sticker:{sticker.Id}";
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest { ResourceId = sticker.ResourceIdentifier });
// Delete old references
var oldRefs =
await fileRefService.GetResourceReferencesAsync(stickerResourceId, StickerFileUsageIdentifier);
foreach (var oldRef in oldRefs)
{
await fileRefService.DeleteReferenceAsync(oldRef.Id);
}
sticker.Image = newImage.ToReferenceObject();
sticker.Image = newImage;
// Create new reference
await fileRefService.CreateReferenceAsync(
newImage.Id,
StickerFileUsageIdentifier,
stickerResourceId
);
await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
{
FileId = newImage.Id,
Usage = StickerFileUsageIdentifier,
ResourceId = sticker.ResourceIdentifier
});
}
db.Stickers.Update(sticker);
@@ -71,7 +64,7 @@ public class StickerService(
var stickerResourceId = $"sticker:{sticker.Id}";
// Delete all file references for this sticker
await fileRefService.DeleteResourceReferencesAsync(stickerResourceId);
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest { ResourceId = stickerResourceId });
db.Stickers.Remove(sticker);
await db.SaveChangesAsync();
@@ -90,13 +83,11 @@ public class StickerService(
// Delete all file references for each sticker in the pack
foreach (var stickerResourceId in stickers.Select(sticker => $"sticker:{sticker.Id}"))
{
await fileRefService.DeleteResourceReferencesAsync(stickerResourceId);
}
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest { ResourceId = stickerResourceId });
// Delete any references for the pack itself
var packResourceId = $"stickerpack:{pack.Id}";
await fileRefService.DeleteResourceReferencesAsync(packResourceId);
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest { ResourceId = packResourceId });
db.Stickers.RemoveRange(stickers);
db.StickerPacks.Remove(pack);

View File

@@ -1,4 +1,4 @@
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Shared.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;

View File

@@ -13,84 +13,6 @@
"FastRetrieve": "localhost:6379",
"Etcd": "etcd.orb.local:2379"
},
"Authentication": {
"Schemes": {
"Bearer": {
"ValidAudiences": [
"http://localhost:5071",
"https://localhost:7099"
],
"ValidIssuer": "solar-network"
}
}
},
"AuthToken": {
"PublicKeyPath": "Keys/PublicKey.pem",
"PrivateKeyPath": "Keys/PrivateKey.pem"
},
"OidcProvider": {
"IssuerUri": "https://nt.solian.app",
"PublicKeyPath": "Keys/PublicKey.pem",
"PrivateKeyPath": "Keys/PrivateKey.pem",
"AccessTokenLifetime": "01:00:00",
"RefreshTokenLifetime": "30.00:00:00",
"AuthorizationCodeLifetime": "00:30:00",
"RequireHttpsMetadata": true
},
"Tus": {
"StorePath": "Uploads"
},
"Storage": {
"PreferredRemote": "minio",
"Remote": [
{
"Id": "minio",
"Label": "Minio",
"Region": "auto",
"Bucket": "solar-network-development",
"Endpoint": "localhost:9000",
"SecretId": "littlesheep",
"SecretKey": "password",
"EnabledSigned": true,
"EnableSsl": false
},
{
"Id": "cloudflare",
"Label": "Cloudflare R2",
"Region": "auto",
"Bucket": "solar-network",
"Endpoint": "0a70a6d1b7128888c823359d0008f4e1.r2.cloudflarestorage.com",
"SecretId": "8ff5d06c7b1639829d60bc6838a542e6",
"SecretKey": "fd58158c5201be16d1872c9209d9cf199421dae3c2f9972f94b2305976580d67",
"EnableSigned": true,
"EnableSsl": true
}
]
},
"Captcha": {
"Provider": "cloudflare",
"ApiKey": "0x4AAAAAABCDUdOujj4feOb_",
"ApiSecret": "0x4AAAAAABCDUWABiJQweqlB7tYq-IqIm8U"
},
"Notifications": {
"Topic": "dev.solsynth.solian",
"Endpoint": "http://localhost:8088"
},
"Email": {
"Server": "smtp4dev.orb.local",
"Port": 25,
"UseSsl": false,
"Username": "no-reply@mail.solsynth.dev",
"Password": "password",
"FromAddress": "no-reply@mail.solsynth.dev",
"FromName": "Alphabot",
"SubjectPrefix": "Solar Network"
},
"RealtimeChat": {
"Endpoint": "https://solar-network-im44o8gq.livekit.cloud",
"ApiKey": "APIs6TiL8wj3A4j",
"ApiSecret": "SffxRneIwTnlHPtEf3zicmmv3LUEl7xXael4PvWZrEhE"
},
"GeoIp": {
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
},
@@ -111,23 +33,17 @@
"DiscoveryEndpoint": "YOUR_MICROSOFT_DISCOVERY_ENDPOINT"
}
},
"Payment": {
"Auth": {
"Afdian": "<token here>"
},
"Subscriptions": {
"Afdian": {
"7d17aae23c9611f0b5705254001e7c00": "solian.stellar.primary",
"7dfae4743c9611f0b3a55254001e7c00": "solian.stellar.nova",
"141713ee3d6211f085b352540025c377": "solian.stellar.supernova"
}
}
},
"KnownProxies": [
"127.0.0.1",
"::1"
],
"Etcd": {
"Insecure": true
},
"Service": {
"Name": "DysonNetwork.Sphere",
"Url": "https://localhost:7099",
"ClientCert": "../Certificates/client.crt",
"ClientKey": "../Certificates/client.key"
}
}