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

@@ -0,0 +1,190 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DysonNetwork.Drive.Migrations
{
[DbContext(typeof(AppDatabase))]
[Migration("20250715080004_ReinitalMigration")]
partial class ReinitalMigration
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
{
b.Property<string>("Id")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<Dictionary<string, object>>("FileMeta")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("file_meta");
b.Property<bool>("HasCompression")
.HasColumnType("boolean")
.HasColumnName("has_compression");
b.Property<string>("Hash")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("hash");
b.Property<bool>("IsMarkedRecycle")
.HasColumnType("boolean")
.HasColumnName("is_marked_recycle");
b.Property<string>("MimeType")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("mime_type");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");
b.Property<long>("Size")
.HasColumnType("bigint")
.HasColumnName("size");
b.Property<string>("StorageId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("storage_id");
b.Property<string>("StorageUrl")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("storage_url");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<Instant?>("UploadedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("uploaded_at");
b.Property<string>("UploadedTo")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("uploaded_to");
b.Property<Dictionary<string, object>>("UserMeta")
.HasColumnType("jsonb")
.HasColumnName("user_meta");
b.HasKey("Id")
.HasName("pk_files");
b.ToTable("files", (string)null);
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("FileId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("file_id");
b.Property<string>("ResourceId")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("resource_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("Usage")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("usage");
b.HasKey("Id")
.HasName("pk_file_references");
b.HasIndex("FileId")
.HasDatabaseName("ix_file_references_file_id");
b.ToTable("file_references", (string)null);
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
{
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
.WithMany()
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_references_files_file_id");
b.Navigation("File");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,36 @@
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Drive.Migrations
{
/// <inheritdoc />
public partial class ReinitalMigration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<Dictionary<string, object>>(
name: "file_meta",
table: "files",
type: "jsonb",
nullable: false,
oldClrType: typeof(Dictionary<string, object>),
oldType: "jsonb",
oldNullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<Dictionary<string, object>>(
name: "file_meta",
table: "files",
type: "jsonb",
nullable: true,
oldClrType: typeof(Dictionary<string, object>),
oldType: "jsonb");
}
}
}

View File

@@ -2,7 +2,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using DysonNetwork.Drive; using DysonNetwork.Drive;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -52,6 +51,7 @@ namespace DysonNetwork.Drive.Migrations
.HasColumnName("description"); .HasColumnName("description");
b.Property<Dictionary<string, object>>("FileMeta") b.Property<Dictionary<string, object>>("FileMeta")
.IsRequired()
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("file_meta"); .HasColumnName("file_meta");

View File

@@ -17,7 +17,7 @@ builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppRateLimiting(); builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication(); builder.Services.AddAppAuthentication();
builder.Services.AddAppSwagger(); builder.Services.AddAppSwagger();
builder.Services.AddDysonAuth(builder.Configuration); builder.Services.AddDysonAuth();
builder.Services.AddAppFileStorage(builder.Configuration); builder.Services.AddAppFileStorage(builder.Configuration);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class ReinitalMigration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "background_id",
table: "account_profiles");
migrationBuilder.DropColumn(
name: "picture_id",
table: "account_profiles");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "background_id",
table: "account_profiles",
type: "character varying(32)",
maxLength: 32,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "picture_id",
table: "account_profiles",
type: "character varying(32)",
maxLength: 32,
nullable: true);
}
}
}

View File

@@ -392,11 +392,6 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("background"); .HasColumnName("background");
b.Property<string>("BackgroundId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("background_id");
b.Property<string>("Bio") b.Property<string>("Bio")
.HasMaxLength(4096) .HasMaxLength(4096)
.HasColumnType("character varying(4096)") .HasColumnType("character varying(4096)")
@@ -451,11 +446,6 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("picture"); .HasColumnName("picture");
b.Property<string>("PictureId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("picture_id");
b.Property<string>("Pronouns") b.Property<string>("Pronouns")
.HasMaxLength(1024) .HasMaxLength(1024)
.HasColumnType("character varying(1024)") .HasColumnType("character varying(1024)")

View File

@@ -1,5 +1,4 @@
using DysonNetwork.Pass; using DysonNetwork.Pass;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Startup; using DysonNetwork.Pass.Startup;
using DysonNetwork.Shared.Http; using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Registry; using DysonNetwork.Shared.Registry;

View File

@@ -1,10 +1,10 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Account; using DysonNetwork.Pass.Account;
using DysonNetwork.Sphere.Permission; using DysonNetwork.Pass.Permission;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Safety; namespace DysonNetwork.Pass.Safety;
[ApiController] [ApiController]
[Route("/api/safety/reports")] [Route("/api/safety/reports")]
@@ -30,7 +30,7 @@ public class AbuseReportController(
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<AbuseReport>> CreateReport([FromBody] CreateReportRequest request) public async Task<ActionResult<AbuseReport>> CreateReport([FromBody] CreateReportRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
try try
{ {
@@ -75,7 +75,7 @@ public class AbuseReportController(
[FromQuery] bool includeResolved = false [FromQuery] bool includeResolved = false
) )
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var totalCount = await safety.CountUserReports(currentUser.Id, includeResolved); var totalCount = await safety.CountUserReports(currentUser.Id, includeResolved);
var reports = await safety.GetUserReports(currentUser.Id, offset, take, includeResolved); var reports = await safety.GetUserReports(currentUser.Id, offset, take, includeResolved);
@@ -101,7 +101,7 @@ public class AbuseReportController(
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<AbuseReport>> GetMyReportById(Guid id) public async Task<ActionResult<AbuseReport>> GetMyReportById(Guid id)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var report = await safety.GetReportById(id); var report = await safety.GetReportById(id);
if (report == null) return NotFound(); if (report == null) return NotFound();

View File

@@ -1,8 +1,8 @@
using DysonNetwork.Sphere.Account; using DysonNetwork.Pass.Account;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Safety; namespace DysonNetwork.Pass.Safety;
public class SafetyService(AppDatabase db, ILogger<SafetyService> logger) public class SafetyService(AppDatabase db, ILogger<SafetyService> logger)
{ {

View File

@@ -217,7 +217,7 @@ public class PaymentService(
Title = localizer["OrderPaidTitle", $"#{readableOrderId}"], Title = localizer["OrderPaidTitle", $"#{readableOrderId}"],
Body = localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture), order.Currency, Body = localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture), order.Currency,
readableOrderRemark], readableOrderRemark],
IsSavable = false IsSavable = true
} }
} }
); );

View File

@@ -360,7 +360,7 @@ public class SubscriptionService(
Topic = "subscriptions.begun", Topic = "subscriptions.begun",
Title = localizer["SubscriptionAppliedTitle", humanReadableName], Title = localizer["SubscriptionAppliedTitle", humanReadableName],
Body = localizer["SubscriptionAppliedBody", duration, humanReadableName], Body = localizer["SubscriptionAppliedBody", duration, humanReadableName],
IsSavable = false, IsSavable = true
}; };
notification.Meta.Add("subscription_id", Value.ForString(subscription.Id.ToString())); notification.Meta.Add("subscription_id", Value.ForString(subscription.Id.ToString()));
await pusher.SendPushNotificationToUserAsync( await pusher.SendPushNotificationToUserAsync(

View File

@@ -15,7 +15,7 @@ builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppRateLimiting(); builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication(); builder.Services.AddAppAuthentication();
builder.Services.AddAppSwagger(); builder.Services.AddAppSwagger();
builder.Services.AddDysonAuth(builder.Configuration); builder.Services.AddDysonAuth();
// Add flush handlers and websocket handlers // Add flush handlers and websocket handlers
builder.Services.AddAppFlushHandlers(); builder.Services.AddAppFlushHandlers();

View File

@@ -8,8 +8,7 @@ namespace DysonNetwork.Shared.Auth;
public static class DysonAuthStartup public static class DysonAuthStartup
{ {
public static IServiceCollection AddDysonAuth( public static IServiceCollection AddDysonAuth(
this IServiceCollection services, this IServiceCollection services
IConfiguration configuration
) )
{ {
services.AddSingleton<AuthService.AuthServiceClient>(sp => services.AddSingleton<AuthService.AuthServiceClient>(sp =>

View File

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

View File

@@ -2,10 +2,10 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared; using DysonNetwork.Shared;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Localization; using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Realm; using DysonNetwork.Sphere.Realm;
using Grpc.Core; using Grpc.Core;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -962,7 +962,7 @@ public class ChatRoomController(
Topic = "invites.chats", Topic = "invites.chats",
Title = title, Title = title,
Body = body, 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) Body = !string.IsNullOrEmpty(message.Content)
? message.Content[..Math.Min(message.Content.Length, 100)] ? message.Content[..Math.Min(message.Content.Length, 100)]
: "<no content>", : "<no content>",
IsSavable = false
}; };
notification.Meta.Add(GrpcTypeHelper.ConvertToValueMap(metaDict)); notification.Meta.Add(GrpcTypeHelper.ConvertToValueMap(metaDict));

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere; using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Startup; using DysonNetwork.Sphere.Startup;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -12,13 +14,15 @@ builder.ConfigureAppKestrel();
builder.Services.AddAppMetrics(); builder.Services.AddAppMetrics();
// Add application services // Add application services
builder.Services.AddRegistryService(builder.Configuration);
builder.Services.AddAppServices(builder.Configuration); builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppRateLimiting(); builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication(); builder.Services.AddAppAuthentication();
builder.Services.AddAppSwagger(); builder.Services.AddAppSwagger();
builder.Services.AddDysonAuth();
// Add file storage builder.Services.AddAccountService();
builder.Services.AddAppFileStorage(builder.Configuration); builder.Services.AddPusherService();
builder.Services.AddDriveService();
// Add flush handlers and websocket handlers // Add flush handlers and websocket handlers
builder.Services.AddAppFlushHandlers(); builder.Services.AddAppFlushHandlers();
@@ -38,10 +42,7 @@ using (var scope = app.Services.CreateScope())
await db.Database.MigrateAsync(); await db.Database.MigrateAsync();
} }
// Get the TusDiskStore instance
var tusDiskStore = app.Services.GetRequiredService<TusDiskStore>();
// Configure application middleware pipeline // Configure application middleware pipeline
app.ConfigureAppMiddleware(builder.Configuration, tusDiskStore); app.ConfigureAppMiddleware(builder.Configuration);
app.Run(); 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.Localization;
using DysonNetwork.Sphere.Post; using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
@@ -9,10 +10,11 @@ namespace DysonNetwork.Sphere.Publisher;
public class PublisherSubscriptionService( public class PublisherSubscriptionService(
AppDatabase db, AppDatabase db,
NotificationService nty,
PostService ps, PostService ps,
IStringLocalizer<NotificationResource> localizer, IStringLocalizer<NotificationResource> localizer,
ICacheService cache ICacheService cache,
PusherService.PusherServiceClient pusher,
AccountService.AccountServiceClient accounts
) )
{ {
/// <summary> /// <summary>
@@ -50,7 +52,6 @@ public class PublisherSubscriptionService(
public async Task<int> NotifySubscriberPost(Post.Post post) public async Task<int> NotifySubscriberPost(Post.Post post)
{ {
var subscribers = await db.PublisherSubscriptions var subscribers = await db.PublisherSubscriptions
.Include(p => p.Account)
.Where(p => p.PublisherId == post.PublisherId && .Where(p => p.PublisherId == post.PublisherId &&
p.Status == PublisherSubscriptionStatus.Active) p.Status == PublisherSubscriptionStatus.Active)
.ToListAsync(); .ToListAsync();
@@ -67,23 +68,35 @@ public class PublisherSubscriptionService(
{ "publisher_id", post.Publisher.Id.ToString() } { "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 // Notify each subscriber
var notifiedCount = 0; var notifiedCount = 0;
foreach (var subscription in subscribers.DistinctBy(s => s.AccountId)) foreach (var target in queryResponse.Accounts)
{ {
try try
{ {
AccountService.SetCultureInfo(subscription.Account); CultureService.SetCultureInfo(target);
await nty.SendNotification( await pusher.SendPushNotificationToUserAsync(
subscription.Account, new SendPushNotificationToUserRequest
"posts.new", {
localizer["PostSubscriptionTitle", post.Publisher.Name, title], UserId = target.Id,
null, Notification = notification
message, }
data,
actionUri: $"/posts/{post.Id}"
); );
notifiedCount++; notifiedCount++;
} }
catch (Exception) catch (Exception)
@@ -117,7 +130,6 @@ public class PublisherSubscriptionService(
public async Task<List<PublisherSubscription>> GetPublisherSubscribersAsync(Guid publisherId) public async Task<List<PublisherSubscription>> GetPublisherSubscribersAsync(Guid publisherId)
{ {
return await db.PublisherSubscriptions return await db.PublisherSubscriptions
.Include(ps => ps.Account)
.Where(ps => ps.PublisherId == publisherId && ps.Status == PublisherSubscriptionStatus.Active) .Where(ps => ps.PublisherId == publisherId && ps.Status == PublisherSubscriptionStatus.Active)
.ToListAsync(); .ToListAsync();
} }

View File

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

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Chat; using DysonNetwork.Sphere.Chat;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; 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) public async Task<ActionResult<List<ChatRoom>>> ListRealmChat(string slug)
{ {
var currentUser = HttpContext.Items["CurrentUser"] as Account; var currentUser = HttpContext.Items["CurrentUser"] as Account;
var accountId = currentUser is null ? Guid.Empty : Guid.Parse(currentUser.Id);
var realm = await db.Realms var realm = await db.Realms
.Where(r => r.Slug == slug) .Where(r => r.Slug == slug)
@@ -22,7 +24,7 @@ public class RealmChatController(AppDatabase db, RealmService rs) : ControllerBa
if (!realm.IsPublic) if (!realm.IsPublic)
{ {
if (currentUser is null) return Unauthorized(); 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."); 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 System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc; using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using Google.Protobuf.WellKnownTypes;
namespace DysonNetwork.Sphere.Realm; namespace DysonNetwork.Sphere.Realm;
@@ -11,10 +14,10 @@ namespace DysonNetwork.Sphere.Realm;
public class RealmController( public class RealmController(
AppDatabase db, AppDatabase db,
RealmService rs, RealmService rs,
FileReferenceService fileRefService, FileService.FileServiceClient files,
RelationshipService rels, FileReferenceService.FileReferenceServiceClient fileRefs,
ActionLogService als, ActionLogService.ActionLogServiceClient als,
AccountEventService aes AccountService.AccountServiceClient accounts
) : Controller ) : Controller
{ {
[HttpGet("{slug}")] [HttpGet("{slug}")]
@@ -33,10 +36,10 @@ public class RealmController(
public async Task<ActionResult<List<Realm>>> ListJoinedRealms() public async Task<ActionResult<List<Realm>>> ListJoinedRealms()
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); 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 var members = await db.RealmMembers
.Where(m => m.AccountId == userId) .Where(m => m.AccountId == accountId)
.Where(m => m.JoinedAt != null) .Where(m => m.JoinedAt != null)
.Where(m => m.LeaveAt == null) .Where(m => m.LeaveAt == null)
.Include(e => e.Realm) .Include(e => e.Realm)
@@ -51,10 +54,10 @@ public class RealmController(
public async Task<ActionResult<List<RealmMember>>> ListInvites() public async Task<ActionResult<List<RealmMember>>> ListInvites()
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); 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 var members = await db.RealmMembers
.Where(m => m.AccountId == userId) .Where(m => m.AccountId == accountId)
.Where(m => m.JoinedAt == null) .Where(m => m.JoinedAt == null)
.Include(e => e.Realm) .Include(e => e.Realm)
.ToListAsync(); .ToListAsync();
@@ -74,12 +77,19 @@ public class RealmController(
[FromBody] RealmMemberRequest request) [FromBody] RealmMemberRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); 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); var relatedUser =
if (relatedUser is null) return BadRequest("Related user was not found"); 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."); return StatusCode(403, "You cannot invite a user that blocked you.");
var realm = await db.Realms var realm = await db.Realms
@@ -87,11 +97,11 @@ public class RealmController(
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (realm is null) return NotFound(); 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."); return StatusCode(403, "You cannot invite member has higher permission than yours.");
var hasExistingMember = await db.RealmMembers 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.RealmId == realm.Id)
.Where(m => m.LeaveAt == null) .Where(m => m.LeaveAt == null)
.AnyAsync(); .AnyAsync();
@@ -100,7 +110,7 @@ public class RealmController(
var member = new RealmMember var member = new RealmMember
{ {
AccountId = relatedUser.Id, AccountId = Guid.Parse(relatedUser.Id),
RealmId = realm.Id, RealmId = realm.Id,
Role = request.Role, Role = request.Role,
}; };
@@ -108,12 +118,21 @@ public class RealmController(
db.RealmMembers.Add(member); db.RealmMembers.Add(member);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest( _ = als.CreateActionLogAsync(new CreateActionLogRequest
ActionLogType.RealmInvite, {
new Dictionary<string, object> { { "realm_id", realm.Id }, { "account_id", member.AccountId } }, Request 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; member.Realm = realm;
await rs.SendInviteNotify(member); await rs.SendInviteNotify(member);
@@ -125,10 +144,10 @@ public class RealmController(
public async Task<ActionResult<Realm>> AcceptMemberInvite(string slug) public async Task<ActionResult<Realm>> AcceptMemberInvite(string slug)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); 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 var member = await db.RealmMembers
.Where(m => m.AccountId == userId) .Where(m => m.AccountId == accountId)
.Where(m => m.Realm.Slug == slug) .Where(m => m.Realm.Slug == slug)
.Where(m => m.JoinedAt == null) .Where(m => m.JoinedAt == null)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
@@ -138,11 +157,18 @@ public class RealmController(
db.Update(member); db.Update(member);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest( _ = als.CreateActionLogAsync(new CreateActionLogRequest
ActionLogType.RealmJoin, {
new Dictionary<string, object> { { "realm_id", member.RealmId }, { "account_id", member.AccountId } }, Action = "realms.members.join",
Request 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); return Ok(member);
} }
@@ -152,10 +178,10 @@ public class RealmController(
public async Task<ActionResult> DeclineMemberInvite(string slug) public async Task<ActionResult> DeclineMemberInvite(string slug)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); 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 var member = await db.RealmMembers
.Where(m => m.AccountId == userId) .Where(m => m.AccountId == accountId)
.Where(m => m.Realm.Slug == slug) .Where(m => m.Realm.Slug == slug)
.Where(m => m.JoinedAt == null) .Where(m => m.JoinedAt == null)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
@@ -164,11 +190,19 @@ public class RealmController(
member.LeaveAt = SystemClock.Instance.GetCurrentInstant(); member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest( _ = als.CreateActionLogAsync(new CreateActionLogRequest
ActionLogType.RealmLeave, {
new Dictionary<string, object> { { "realm_id", member.RealmId }, { "account_id", member.AccountId } }, Action = "realms.members.decline_invite",
Request 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(); return NoContent();
} }
@@ -191,43 +225,41 @@ public class RealmController(
if (!realm.IsPublic) if (!realm.IsPublic)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); 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."); 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.RealmId == realm.Id)
.Where(m => m.LeaveAt == null) .Where(m => m.LeaveAt == null);
.Include(m => m.Account)
.Include(m => m.Account.Profile);
if (withStatus) // if (withStatus)
{ // {
var members = await query // var members = await query
.OrderBy(m => m.CreatedAt) // .OrderBy(m => m.CreatedAt)
.ToListAsync(); // .ToListAsync();
//
var memberStatuses = await aes.GetStatuses(members.Select(m => m.AccountId).ToList()); // var memberStatuses = await aes.GetStatuses(members.Select(m => m.AccountId).ToList());
//
if (!string.IsNullOrEmpty(status)) // if (!string.IsNullOrEmpty(status))
{ // {
members = members.Where(m => // members = members.Where(m =>
memberStatuses.TryGetValue(m.AccountId, out var s) && s.Label != null && // memberStatuses.TryGetValue(m.AccountId, out var s) && s.Label != null &&
s.Label.Equals(status, StringComparison.OrdinalIgnoreCase)).ToList(); // s.Label.Equals(status, StringComparison.OrdinalIgnoreCase)).ToList();
} // }
//
members = members.OrderByDescending(m => memberStatuses.TryGetValue(m.AccountId, out var s) && s.IsOnline) // members = members.OrderByDescending(m => memberStatuses.TryGetValue(m.AccountId, out var s) && s.IsOnline)
.ToList(); // .ToList();
//
var total = members.Count; // var total = members.Count;
Response.Headers["X-Total"] = total.ToString(); // Response.Headers["X-Total"] = total.ToString();
//
var result = members.Skip(offset).Take(take).ToList(); // var result = members.Skip(offset).Take(take).ToList();
//
return Ok(result); // return Ok(result);
} // }
else // else
{ // {
var total = await query.CountAsync(); var total = await query.CountAsync();
Response.Headers["X-Total"] = total.ToString(); Response.Headers["X-Total"] = total.ToString();
@@ -238,23 +270,20 @@ public class RealmController(
.ToListAsync(); .ToListAsync();
return Ok(members); return Ok(members);
} // }
} }
[HttpGet("{slug}/members/me")] [HttpGet("{slug}/members/me")]
[Authorize] [Authorize]
public async Task<ActionResult<RealmMember>> GetCurrentIdentity(string slug) public async Task<ActionResult<RealmMember>> GetCurrentIdentity(string slug)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); 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 var member = await db.RealmMembers
.Where(m => m.AccountId == userId) .Where(m => m.AccountId == accountId)
.Where(m => m.Realm.Slug == slug) .Where(m => m.Realm.Slug == slug)
.Include(m => m.Account)
.Include(m => m.Account.Profile)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member is null) return NotFound(); if (member is null) return NotFound();
@@ -266,10 +295,10 @@ public class RealmController(
public async Task<ActionResult> LeaveRealm(string slug) public async Task<ActionResult> LeaveRealm(string slug)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); 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 var member = await db.RealmMembers
.Where(m => m.AccountId == userId) .Where(m => m.AccountId == accountId)
.Where(m => m.Realm.Slug == slug) .Where(m => m.Realm.Slug == slug)
.Where(m => m.JoinedAt != null) .Where(m => m.JoinedAt != null)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
@@ -281,11 +310,19 @@ public class RealmController(
member.LeaveAt = SystemClock.Instance.GetCurrentInstant(); member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest( _ = als.CreateActionLogAsync(new CreateActionLogRequest
ActionLogType.RealmLeave, {
new Dictionary<string, object> { { "realm_id", member.RealmId }, { "account_id", member.AccountId } }, Action = "realms.members.leave",
Request 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(); return NoContent();
} }
@@ -317,7 +354,7 @@ public class RealmController(
Name = request.Name!, Name = request.Name!,
Slug = request.Slug!, Slug = request.Slug!,
Description = request.Description!, Description = request.Description!,
AccountId = currentUser.Id, AccountId = Guid.Parse(currentUser.Id),
IsCommunity = request.IsCommunity ?? false, IsCommunity = request.IsCommunity ?? false,
IsPublic = request.IsPublic ?? false, IsPublic = request.IsPublic ?? false,
Members = new List<RealmMember> Members = new List<RealmMember>
@@ -325,7 +362,7 @@ public class RealmController(
new() new()
{ {
Role = RealmMemberRole.Owner, Role = RealmMemberRole.Owner,
AccountId = currentUser.Id, AccountId = Guid.Parse(currentUser.Id),
JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow) JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow)
} }
} }
@@ -333,42 +370,56 @@ public class RealmController(
if (request.PictureId is not null) if (request.PictureId is not null)
{ {
realm.Picture = (await db.Files.FindAsync(request.PictureId))?.ToReferenceObject(); var pictureResult = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId });
if (realm.Picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud."); 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) if (request.BackgroundId is not null)
{ {
realm.Background = (await db.Files.FindAsync(request.BackgroundId))?.ToReferenceObject(); var backgroundResult = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId });
if (realm.Background is null) return BadRequest("Invalid background id, unable to find the file on cloud."); 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); db.Realms.Add(realm);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest( _ = als.CreateActionLogAsync(new CreateActionLogRequest
ActionLogType.RealmCreate, {
new Dictionary<string, object> { { "realm_id", realm.Id } }, Request 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}"; var realmResourceId = $"realm:{realm.Id}";
if (realm.Picture is not null) if (realm.Picture is not null)
{ {
await fileRefService.CreateReferenceAsync( await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
realm.Picture.Id, {
"realm.picture", FileId = realm.Picture.Id,
realmResourceId Usage = "realm.picture",
); ResourceId = realmResourceId
});
} }
if (realm.Background is not null) if (realm.Background is not null)
{ {
await fileRefService.CreateReferenceAsync( await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
realm.Background.Id, {
"realm.background", FileId = realm.Background.Id,
realmResourceId Usage = "realm.background",
); ResourceId = realmResourceId
});
} }
return Ok(realm); return Ok(realm);
@@ -385,8 +436,9 @@ public class RealmController(
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (realm is null) return NotFound(); if (realm is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id);
var member = await db.RealmMembers 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(); .FirstOrDefaultAsync();
if (member is null || member.Role < RealmMemberRole.Moderator) if (member is null || member.Role < RealmMemberRole.Moderator)
return StatusCode(403, "You do not have permission to update this realm."); 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) if (request.PictureId is not null)
{ {
var picture = await db.Files.FindAsync(request.PictureId); var pictureResult = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId });
if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud."); if (pictureResult is null) return BadRequest("Invalid picture id, unable to find the file on cloud.");
// Remove old references for the realm picture // Remove old references for the realm picture
if (realm.Picture is not null) 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 // Create a new reference
await fileRefService.CreateReferenceAsync( await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
picture.Id, {
"realm.picture", FileId = realm.Picture.Id,
realm.ResourceIdentifier Usage = "realm.picture",
); ResourceId = realm.ResourceIdentifier
});
} }
if (request.BackgroundId is not null) if (request.BackgroundId is not null)
{ {
var background = await db.Files.FindAsync(request.BackgroundId); var backgroundResult = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId });
if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud."); if (backgroundResult is null) return BadRequest("Invalid background id, unable to find the file on cloud.");
// Remove old references for the realm background // Remove old references for the realm background
if (realm.Background is not null) 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 // Create a new reference
await fileRefService.CreateReferenceAsync( await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
background.Id, {
"realm.background", FileId = realm.Background.Id,
realm.ResourceIdentifier Usage = "realm.background",
); ResourceId = realm.ResourceIdentifier
});
} }
db.Realms.Update(realm); db.Realms.Update(realm);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest( _ = als.CreateActionLogAsync(new CreateActionLogRequest
ActionLogType.RealmUpdate, {
new Dictionary<string, object> { { "realm_id", realm.Id } }, Request 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); return Ok(realm);
} }
@@ -475,14 +549,14 @@ public class RealmController(
return StatusCode(403, "Only community realms can be joined without invitation."); return StatusCode(403, "Only community realms can be joined without invitation.");
var existingMember = await db.RealmMembers 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(); .FirstOrDefaultAsync();
if (existingMember is not null) if (existingMember is not null)
return BadRequest("You are already a member of this realm."); return BadRequest("You are already a member of this realm.");
var member = new RealmMember var member = new RealmMember
{ {
AccountId = currentUser.Id, AccountId = Guid.Parse(currentUser.Id),
RealmId = realm.Id, RealmId = realm.Id,
Role = RealmMemberRole.Normal, Role = RealmMemberRole.Normal,
JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow) JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow)
@@ -491,11 +565,19 @@ public class RealmController(
db.RealmMembers.Add(member); db.RealmMembers.Add(member);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest( _ = als.CreateActionLogAsync(new CreateActionLogRequest
ActionLogType.RealmJoin, {
new Dictionary<string, object> { { "realm_id", realm.Id }, { "account_id", currentUser.Id } }, Action = "realms.members.join",
Request 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); return Ok(member);
} }
@@ -516,17 +598,25 @@ public class RealmController(
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member is null) return NotFound(); 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."); return StatusCode(403, "You do not have permission to remove members from this realm.");
member.LeaveAt = SystemClock.Instance.GetCurrentInstant(); member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest( _ = als.CreateActionLogAsync(new CreateActionLogRequest
ActionLogType.ChatroomKick, {
new Dictionary<string, object> { { "realm_id", realm.Id }, { "account_id", memberId } }, Action = "realms.members.kick",
Request 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(); return NoContent();
} }
@@ -545,23 +635,31 @@ public class RealmController(
var member = await db.RealmMembers var member = await db.RealmMembers
.Where(m => m.AccountId == memberId && m.RealmId == realm.Id) .Where(m => m.AccountId == memberId && m.RealmId == realm.Id)
.Include(m => m.Account)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member is null) return NotFound(); 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."); return StatusCode(403, "You do not have permission to update member roles in this realm.");
member.Role = newRole; member.Role = newRole;
db.RealmMembers.Update(member); db.RealmMembers.Update(member);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest( _ = als.CreateActionLogAsync(new CreateActionLogRequest
ActionLogType.RealmAdjustRole, {
new Dictionary<string, object> Action = "realms.members.role_update",
{ { "realm_id", realm.Id }, { "account_id", memberId }, { "new_role", newRole } }, Meta =
Request {
); { "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); return Ok(member);
} }
@@ -574,25 +672,35 @@ public class RealmController(
var realm = await db.Realms var realm = await db.Realms
.Where(r => r.Slug == slug) .Where(r => r.Slug == slug)
.Include(r => r.Picture)
.Include(r => r.Background)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (realm is null) return NotFound(); 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."); return StatusCode(403, "Only the owner can delete this realm.");
db.Realms.Remove(realm); db.Realms.Remove(realm);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest( _ = als.CreateActionLogAsync(new CreateActionLogRequest
ActionLogType.RealmDelete, {
new Dictionary<string, object> { { "realm_id", realm.Id } }, Request 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 // Delete all file references for this realm
var realmResourceId = $"realm:{realm.Id}"; var realmResourceId = $"realm:{realm.Id}";
await fileRefService.DeleteResourceReferencesAsync(realmResourceId); await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
{
ResourceId = realmResourceId
});
return NoContent(); 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 DysonNetwork.Sphere.Localization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
namespace DysonNetwork.Sphere.Realm; 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) public async Task SendInviteNotify(RealmMember member)
{ {
AccountService.SetCultureInfo(member.Account); var account = await accounts.GetAccountAsync(new GetAccountRequest { Id = member.AccountId.ToString() });
await nty.SendNotification( CultureService.SetCultureInfo(account);
member.Account,
"invites.realms", await pusher.SendPushNotificationToUserAsync(
localizer["RealmInviteTitle"], new SendPushNotificationToUserRequest
null, {
localizer["RealmInviteBody", member.Realm.Name], UserId = account.Id,
actionUri: "/realms" Notification = new PushNotification
{
Topic = "invites.realms",
Title = localizer["RealmInviteTitle"],
Body = localizer["RealmInviteBody", member.Realm.Name],
ActionUri = "/realms",
IsSavable = true
}
}
); );
} }

View File

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

View File

@@ -1,7 +1,4 @@
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.WebReader; using DysonNetwork.Sphere.WebReader;
using DysonNetwork.Sphere.Storage.Handlers;
using DysonNetwork.Sphere.Wallet;
using Quartz; using Quartz;
namespace DysonNetwork.Sphere.Startup; namespace DysonNetwork.Sphere.Startup;
@@ -19,63 +16,15 @@ public static class ScheduledJobsConfiguration
.WithIdentity("AppDatabaseRecyclingTrigger") .WithIdentity("AppDatabaseRecyclingTrigger")
.WithCronSchedule("0 0 0 * * ?")); .WithCronSchedule("0 0 0 * * ?"));
var cloudFilesRecyclingJob = new JobKey("CloudFilesUnusedRecycling"); // var postViewFlushJob = new JobKey("PostViewFlush");
q.AddJob<CloudFileUnusedRecyclingJob>(opts => opts.WithIdentity(cloudFilesRecyclingJob)); // q.AddJob<PostViewFlushJob>(opts => opts.WithIdentity(postViewFlushJob));
q.AddTrigger(opts => opts // q.AddTrigger(opts => opts
.ForJob(cloudFilesRecyclingJob) // .ForJob(postViewFlushJob)
.WithIdentity("CloudFilesUnusedRecyclingTrigger") // .WithIdentity("PostViewFlushTrigger")
.WithSimpleSchedule(o => o.WithIntervalInHours(1).RepeatForever()) // .WithSimpleSchedule(o => o
); // .WithIntervalInMinutes(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 webFeedScraperJob = new JobKey("WebFeedScraper"); var webFeedScraperJob = new JobKey("WebFeedScraper");
q.AddJob<WebFeedScraperJob>(opts => opts.WithIdentity(webFeedScraperJob)); q.AddJob<WebFeedScraperJob>(opts => opts.WithIdentity(webFeedScraperJob));

View File

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

View File

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

View File

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

View File

@@ -13,84 +13,6 @@
"FastRetrieve": "localhost:6379", "FastRetrieve": "localhost:6379",
"Etcd": "etcd.orb.local:2379" "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": { "GeoIp": {
"DatabasePath": "./Keys/GeoLite2-City.mmdb" "DatabasePath": "./Keys/GeoLite2-City.mmdb"
}, },
@@ -111,23 +33,17 @@
"DiscoveryEndpoint": "YOUR_MICROSOFT_DISCOVERY_ENDPOINT" "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": [ "KnownProxies": [
"127.0.0.1", "127.0.0.1",
"::1" "::1"
], ],
"Etcd": { "Etcd": {
"Insecure": true "Insecure": true
},
"Service": {
"Name": "DysonNetwork.Sphere",
"Url": "https://localhost:7099",
"ClientCert": "../Certificates/client.crt",
"ClientKey": "../Certificates/client.key"
} }
} }