Compare commits

...

71 Commits

Author SHA1 Message Date
LittleSheep
fb6721cb1b 💄 Optimize punishment reason display 2025-08-26 20:32:07 +08:00
LittleSheep
9fcb169c94 🐛 Fix chat room invites 2025-08-26 19:08:23 +08:00
LittleSheep
572874431d 🐛 Fix sticker perm check 2025-08-26 14:48:30 +08:00
LittleSheep
f595ac8001 🐛 Fix uploading file didn't uploaded 2025-08-26 13:02:51 +08:00
LittleSheep
18674e0e1d Remove /cgi directly handled by gateway 2025-08-26 02:59:51 +08:00
LittleSheep
da4c4d3a84 🐛 Fix bugs 2025-08-26 02:48:16 +08:00
LittleSheep
aec01b117d 🐛 Fix chat service duplicate notifying 2025-08-26 00:15:39 +08:00
LittleSheep
d299c32e35 ♻️ Clean up OIDC provider 2025-08-25 23:53:04 +08:00
LittleSheep
344007af66 🔊 Logging more ip address 2025-08-25 23:42:41 +08:00
LittleSheep
d4de5aeac2 🐛 Fix api key exists cause regular login 500 2025-08-25 23:30:41 +08:00
LittleSheep
8ce5ba50f4 🐛 Fix api key cause 401 in other serivces 2025-08-25 23:20:27 +08:00
LittleSheep
5a44952b27 🐛 Fix oidc token aud 2025-08-25 23:17:40 +08:00
LittleSheep
c30946daf6 🐛 Still bug fixes in auth service 2025-08-25 23:01:17 +08:00
LittleSheep
0221d7b294 🐛 Fix compress GIF wrongly 2025-08-25 22:42:14 +08:00
LittleSheep
c44b0b64c3 🐛 Fix api key auth issue 2025-08-25 22:39:35 +08:00
LittleSheep
442ee3bcfd 🐛 Fixes in auth service 2025-08-25 22:24:18 +08:00
LittleSheep
081815c512 Trying to optimize pusher serivce 2025-08-25 21:48:07 +08:00
LittleSheep
eab2a388ae 🐛 Fixes in authorize 2025-08-25 21:22:04 +08:00
LittleSheep
5f7ab49abb 🛂 Add permission check in post pin / unpin 2025-08-25 20:04:21 +08:00
LittleSheep
4ff89173b2 ♻️ Some optimzations for sync message endpoint 2025-08-25 19:24:42 +08:00
LittleSheep
f2052410c7 Filtered realm posts 2025-08-25 17:47:30 +08:00
LittleSheep
83a49be725 🐛 Fix websocket missing in notification 2025-08-25 17:43:37 +08:00
LittleSheep
9b205a73fd 💄 Optimize post controller 2025-08-25 17:06:21 +08:00
LittleSheep
d5157eb7e3 Post category tags subscriptions 2025-08-25 14:18:14 +08:00
LittleSheep
75c92c51db 🐛 Dozens of bug fixes 2025-08-25 13:43:40 +08:00
LittleSheep
915054fce0 Pinned post 2025-08-25 13:37:25 +08:00
LittleSheep
63653680ba 👔 Update the algorithm to pick featured post 2025-08-25 13:06:09 +08:00
LittleSheep
84c4df6620 👔 Prevent from creating duplicate featured record 2025-08-25 13:05:34 +08:00
LittleSheep
8c748fd57a Bring OIDC back 2025-08-25 02:44:44 +08:00
LittleSheep
4684550ebf App custom secret management 2025-08-24 23:50:57 +08:00
LittleSheep
51db08f374 🐛 Fix develop API permission check 2025-08-24 21:53:41 +08:00
LittleSheep
9f38a288b9 🐛 Fix save notification again.. 2025-08-24 18:05:42 +08:00
LittleSheep
75a975049c 🐛 Fix get subscribed feed 2025-08-24 17:37:30 +08:00
LittleSheep
f8c35c0350 🐛 Fix queue background service in pusher didn't save notification now 2025-08-24 16:59:27 +08:00
LittleSheep
d9a5fed77f 🐛 Fix wrong queue name 2025-08-24 13:19:39 +08:00
LittleSheep
7cb14940d9 🐛 Fix rotate key 2025-08-24 01:49:48 +08:00
LittleSheep
953bf5d4de Bot controller has keys endpoints 2025-08-23 19:52:05 +08:00
LittleSheep
d9620fd6a4 Bot transparency API 2025-08-23 17:55:42 +08:00
LittleSheep
541e2dd14c 🐛 Fix bots errors 2025-08-23 17:06:52 +08:00
LittleSheep
c7925d98c8 🐛 Fix bot account missing created / updated at 2025-08-23 14:25:46 +08:00
LittleSheep
f759b19bcb 🐛 Fixes in bot 2025-08-23 14:20:21 +08:00
LittleSheep
5d7429a416 ♻️ Refind bot account 2025-08-23 13:00:30 +08:00
LittleSheep
fb7e52d6f3 Sticker pack includes preview stickers 2025-08-22 23:02:16 +08:00
LittleSheep
50e888b075 🐛 Fix mark all read will reset the viewed at 2025-08-22 22:42:32 +08:00
LittleSheep
76c8bbf307 🐛 Fix social credit cache didn't have base value 2025-08-22 22:41:38 +08:00
LittleSheep
8f3825e92c Cache user social credits on profile 2025-08-22 22:28:48 +08:00
LittleSheep
d1c3610ec8 🐛 Dozens of bug fixes 2025-08-22 19:55:16 +08:00
LittleSheep
4b958a3c31 🗑️ Remove the old search API 2025-08-22 17:07:22 +08:00
LittleSheep
1f9021d459 🎨 Disassmeble the activity service parts 2025-08-22 16:56:21 +08:00
LittleSheep
7ad9deaf70 🎨 Adjust post shuffle query 2025-08-22 16:50:06 +08:00
LittleSheep
c1c17b5f4e Optimize post categories, tags usage counting 2025-08-21 23:22:59 +08:00
LittleSheep
d92220b4bc ♻️ Refactor NATS message handling 2025-08-21 18:47:23 +08:00
LittleSheep
4d1972bc99 ♻️ Refactored the queue 2025-08-21 17:41:48 +08:00
LittleSheep
83c052ec4e ♻️ Replace check in with recorded experience source 2025-08-21 02:30:59 +08:00
LittleSheep
57a75fe9e6 Done with social credits 2025-08-21 02:28:39 +08:00
LittleSheep
379bc37aff Social credit, leveling service 2025-08-21 01:30:39 +08:00
LittleSheep
0217fbb13b Sorting post categories, tags with order 2025-08-20 19:06:18 +08:00
LittleSheep
4e9943e6a2 🍱 Update database migrations 2025-08-20 18:50:23 +08:00
LittleSheep
b3cc623168 Web feed subscription APIs 2025-08-20 18:41:11 +08:00
LittleSheep
3ee5e5367d Web feed subcription 2025-08-20 14:21:25 +08:00
LittleSheep
85fef30c7f Search with sticker packs 2025-08-20 14:02:34 +08:00
LittleSheep
e8d8dcbb2d 💄 Better sticker marketplace listing 2025-08-20 14:00:15 +08:00
LittleSheep
3b679d6134 API Keys 2025-08-20 13:41:06 +08:00
LittleSheep
ec44b51ab6 Reply and forward gone indicator 2025-08-20 02:14:18 +08:00
LittleSheep
2e52a13c30 🍱 Update migrations 2025-08-20 01:41:37 +08:00
LittleSheep
1e8e2e9ea7 🐛 Fixes DI and lifetimes 2025-08-20 01:41:27 +08:00
LittleSheep
9e8363c004 Drive resource recycler, delete files in batch 2025-08-20 00:11:52 +08:00
LittleSheep
56c40ee001 File references deletion batch 2025-08-19 22:47:20 +08:00
LittleSheep
e3dfccfee3 Account service account deleted broadcast message & sphere service clean up 2025-08-19 22:39:12 +08:00
LittleSheep
d555fcaf17 🐛 Fix org publisher creation missing validation as well 2025-08-19 21:34:27 +08:00
LittleSheep
2fdefae718 🐛 Fix publiser has no validate 2025-08-19 21:24:30 +08:00
122 changed files with 24169 additions and 890 deletions

View File

@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Data;
using NodaTime;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Develop.Identity;
@@ -16,6 +16,14 @@ public class BotAccount : ModelBase
public Guid ProjectId { get; set; }
public DevProject Project { get; set; } = null!;
[NotMapped] public AccountReference? Account { get; set; }
/// <summary>
/// This developer field is to serve the transparent info for user to know which developer
/// published this robot. Not for relationships usage.
/// </summary>
[NotMapped] public Developer? Developer { get; set; }
public Shared.Proto.BotAccount ToProtoValue()
{
var proto = new Shared.Proto.BotAccount

View File

@@ -1,8 +1,13 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using Grpc.Core;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NodaTime;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Develop.Identity;
@@ -13,18 +18,62 @@ public class BotAccountController(
BotAccountService botService,
DeveloperService developerService,
DevProjectService projectService,
ILogger<BotAccountController> logger
ILogger<BotAccountController> logger,
AccountClientHelper accounts,
BotAccountReceiverService.BotAccountReceiverServiceClient accountsReceiver
)
: ControllerBase
{
public record BotRequest(
[Required] [MaxLength(1024)] string? Slug
);
public class CommonBotRequest
{
[MaxLength(256)] public string? FirstName { get; set; }
[MaxLength(256)] public string? MiddleName { get; set; }
[MaxLength(256)] public string? LastName { get; set; }
[MaxLength(1024)] public string? Gender { get; set; }
[MaxLength(1024)] public string? Pronouns { get; set; }
[MaxLength(1024)] public string? TimeZone { get; set; }
[MaxLength(1024)] public string? Location { get; set; }
[MaxLength(4096)] public string? Bio { get; set; }
public Instant? Birthday { get; set; }
public record UpdateBotRequest(
[MaxLength(1024)] string? Slug,
bool? IsActive
) : BotRequest(Slug);
[MaxLength(32)] public string? PictureId { get; set; }
[MaxLength(32)] public string? BackgroundId { get; set; }
}
public class BotCreateRequest : CommonBotRequest
{
[Required]
[MinLength(2)]
[MaxLength(256)]
[RegularExpression(@"^[A-Za-z0-9_-]+$",
ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.")
]
public string Name { get; set; } = string.Empty;
[Required] [MaxLength(256)] public string Nick { get; set; } = string.Empty;
[Required] [MaxLength(1024)] public string Slug { get; set; } = string.Empty;
[MaxLength(128)] public string Language { get; set; } = "en-us";
}
public class UpdateBotRequest : CommonBotRequest
{
[MinLength(2)]
[MaxLength(256)]
[RegularExpression(@"^[A-Za-z0-9_-]+$",
ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.")
]
public string? Name { get; set; } = string.Empty;
[MaxLength(256)] public string? Nick { get; set; } = string.Empty;
[Required] [MaxLength(1024)] public string? Slug { get; set; } = string.Empty;
[MaxLength(128)] public string? Language { get; set; }
public bool? IsActive { get; set; }
}
[HttpGet]
public async Task<IActionResult> ListBots(
@@ -39,15 +88,15 @@ public class BotAccountController(
return NotFound("Developer not found");
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to list bots");
PublisherMemberRole.Viewer))
return StatusCode(403, "You must be an viewer of the developer to list bots");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null)
return NotFound("Project not found or you don't have access");
var bots = await botService.GetBotsByProjectAsync(projectId);
return Ok(bots);
return Ok(await botService.LoadBotsAccountAsync(bots));
}
[HttpGet("{botId:guid}")]
@@ -64,8 +113,8 @@ public class BotAccountController(
return NotFound("Developer not found");
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to view bot details");
PublisherMemberRole.Viewer))
return StatusCode(403, "You must be an viewer of the developer to view bot details");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null)
@@ -75,18 +124,16 @@ public class BotAccountController(
if (bot is null || bot.ProjectId != projectId)
return NotFound("Bot not found");
return Ok(bot);
return Ok(await botService.LoadBotAccountAsync(bot));
}
[HttpPost]
public async Task<IActionResult> CreateBot(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromBody] BotRequest request
[FromBody] BotCreateRequest createRequest
)
{
if (string.IsNullOrWhiteSpace(request.Slug))
return BadRequest("Name is required");
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
@@ -102,9 +149,43 @@ public class BotAccountController(
if (project is null)
return NotFound("Project not found or you don't have access");
var now = SystemClock.Instance.GetCurrentInstant();
var accountId = Guid.NewGuid();
var account = new Account()
{
Id = accountId.ToString(),
Name = createRequest.Name,
Nick = createRequest.Nick,
Language = createRequest.Language,
Profile = new AccountProfile()
{
Id = Guid.NewGuid().ToString(),
Bio = createRequest.Bio,
Gender = createRequest.Gender,
FirstName = createRequest.FirstName,
MiddleName = createRequest.MiddleName,
LastName = createRequest.LastName,
TimeZone = createRequest.TimeZone,
Pronouns = createRequest.Pronouns,
Location = createRequest.Location,
Birthday = createRequest.Birthday?.ToTimestamp(),
AccountId = accountId.ToString(),
CreatedAt = now.ToTimestamp(),
UpdatedAt = now.ToTimestamp()
},
CreatedAt = now.ToTimestamp(),
UpdatedAt = now.ToTimestamp()
};
try
{
var bot = await botService.CreateBotAsync(project, request.Slug);
var bot = await botService.CreateBotAsync(
project,
createRequest.Slug,
account,
createRequest.PictureId,
createRequest.BackgroundId
);
return Ok(bot);
}
catch (Exception ex)
@@ -114,7 +195,7 @@ public class BotAccountController(
}
}
[HttpPut("{botId:guid}")]
[HttpPatch("{botId:guid}")]
public async Task<IActionResult> UpdateBot(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
@@ -141,12 +222,31 @@ public class BotAccountController(
if (bot is null || bot.ProjectId != projectId)
return NotFound("Bot not found");
var botAccount = await accounts.GetBotAccount(bot.Id);
if (request.Name is not null) botAccount.Name = request.Name;
if (request.Nick is not null) botAccount.Nick = request.Nick;
if (request.Language is not null) botAccount.Language = request.Language;
if (request.Bio is not null) botAccount.Profile.Bio = request.Bio;
if (request.Gender is not null) botAccount.Profile.Gender = request.Gender;
if (request.FirstName is not null) botAccount.Profile.FirstName = request.FirstName;
if (request.MiddleName is not null) botAccount.Profile.MiddleName = request.MiddleName;
if (request.LastName is not null) botAccount.Profile.LastName = request.LastName;
if (request.TimeZone is not null) botAccount.Profile.TimeZone = request.TimeZone;
if (request.Pronouns is not null) botAccount.Profile.Pronouns = request.Pronouns;
if (request.Location is not null) botAccount.Profile.Location = request.Location;
if (request.Birthday is not null) botAccount.Profile.Birthday = request.Birthday?.ToTimestamp();
if (request.Slug is not null) bot.Slug = request.Slug;
if (request.IsActive is not null) bot.IsActive = request.IsActive.Value;
try
{
var updatedBot = await botService.UpdateBotAsync(
bot,
request.Slug,
request.IsActive
botAccount,
request.PictureId,
request.BackgroundId
);
return Ok(updatedBot);
@@ -194,4 +294,167 @@ public class BotAccountController(
return StatusCode(500, "An error occurred while deleting the bot account");
}
}
[HttpGet("{botId:guid}/keys")]
public async Task<ActionResult<List<ApiKeyReference>>> ListBotKeys(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid botId
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Viewer);
if (developer == null) return NotFound("Developer not found");
if (project == null) return NotFound("Project not found or you don't have access");
if (bot == null) return NotFound("Bot not found");
var keys = await accountsReceiver.ListApiKeyAsync(new ListApiKeyRequest
{
AutomatedId = bot.Id.ToString()
});
var data = keys.Data.Select(ApiKeyReference.FromProtoValue).ToList();
return Ok(data);
}
[HttpGet("{botId:guid}/keys/{keyId:guid}")]
public async Task<ActionResult<ApiKeyReference>> GetBotKey(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid botId,
[FromRoute] Guid keyId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Viewer);
if (developer == null) return NotFound("Developer not found");
if (project == null) return NotFound("Project not found or you don't have access");
if (bot == null) return NotFound("Bot not found");
try
{
var key = await accountsReceiver.GetApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() });
if (key == null) return NotFound("API key not found");
return Ok(ApiKeyReference.FromProtoValue(key));
}
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
{
return NotFound("API key not found");
}
}
public class CreateApiKeyRequest
{
[Required, MaxLength(1024)]
public string Label { get; set; } = null!;
}
[HttpPost("{botId:guid}/keys")]
public async Task<ActionResult<ApiKeyReference>> CreateBotKey(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid botId,
[FromBody] CreateApiKeyRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor);
if (developer == null) return NotFound("Developer not found");
if (project == null) return NotFound("Project not found or you don't have access");
if (bot == null) return NotFound("Bot not found");
try
{
var newKey = new ApiKey
{
AccountId = bot.Id.ToString(),
Label = request.Label
};
var createdKey = await accountsReceiver.CreateApiKeyAsync(newKey);
return Ok(ApiKeyReference.FromProtoValue(createdKey));
}
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument)
{
return BadRequest(ex.Status.Detail);
}
}
[HttpPost("{botId:guid}/keys/{keyId:guid}/rotate")]
public async Task<ActionResult<ApiKeyReference>> RotateBotKey(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid botId,
[FromRoute] Guid keyId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor);
if (developer == null) return NotFound("Developer not found");
if (project == null) return NotFound("Project not found or you don't have access");
if (bot == null) return NotFound("Bot not found");
try
{
var rotatedKey = await accountsReceiver.RotateApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() });
return Ok(ApiKeyReference.FromProtoValue(rotatedKey));
}
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
{
return NotFound("API key not found");
}
}
[HttpDelete("{botId:guid}/keys/{keyId:guid}")]
public async Task<IActionResult> DeleteBotKey(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid botId,
[FromRoute] Guid keyId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor);
if (developer == null) return NotFound("Developer not found");
if (project == null) return NotFound("Project not found or you don't have access");
if (bot == null) return NotFound("Bot not found");
try
{
await accountsReceiver.DeleteApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() });
return NoContent();
}
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
{
return NotFound("API key not found");
}
}
private async Task<(Developer?, DevProject?, BotAccount?)> ValidateBotAccess(
string pubName,
Guid projectId,
Guid botId,
Account currentUser,
PublisherMemberRole requiredRole)
{
var developer = await developerService.GetDeveloperByName(pubName);
if (developer == null) return (null, null, null);
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), requiredRole))
return (null, null, null);
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project == null) return (developer, null, null);
var bot = await botService.GetBotByIdAsync(botId);
if (bot == null || bot.ProjectId != projectId) return (developer, project, null);
return (developer, project, bot);
}
}

View File

@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Develop.Identity;
[ApiController]
[Route("api/bots")]
public class BotAccountPublicController(BotAccountService botService, DeveloperService developerService) : ControllerBase
{
[HttpGet("{botId:guid}")]
public async Task<ActionResult<BotAccount>> GetBotTransparentInfo([FromRoute] Guid botId)
{
var bot = await botService.GetBotByIdAsync(botId);
if (bot is null) return NotFound("Bot not found");
bot = await botService.LoadBotAccountAsync(bot);
var developer = await developerService.GetDeveloperById(bot!.Project.DeveloperId);
if (developer is null) return NotFound("Developer not found");
bot.Developer = await developerService.LoadDeveloperPublisher(developer);
return Ok(bot);
}
[HttpGet("{botId:guid}/developer")]
public async Task<ActionResult<Developer>> GetBotDeveloper([FromRoute] Guid botId)
{
var bot = await botService.GetBotByIdAsync(botId);
if (bot is null) return NotFound("Bot not found");
var developer = await developerService.GetDeveloperById(bot!.Project.DeveloperId);
if (developer is null) return NotFound("Developer not found");
developer = await developerService.LoadDeveloperPublisher(developer);
return Ok(developer);
}
}

View File

@@ -1,13 +1,18 @@
using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Develop.Identity;
public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAccountReceiverServiceClient accountReceiver)
public class BotAccountService(
AppDatabase db,
BotAccountReceiverService.BotAccountReceiverServiceClient accountReceiver,
AccountClientHelper accounts
)
{
public async Task<BotAccount?> GetBotByIdAsync(Guid id)
{
@@ -23,39 +28,30 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
.ToListAsync();
}
public async Task<BotAccount> CreateBotAsync(DevProject project, string slug)
public async Task<BotAccount> CreateBotAsync(
DevProject project,
string slug,
Account account,
string? pictureId,
string? backgroundId
)
{
// First, check if a bot with this slug already exists in this project
var existingBot = await db.BotAccounts
.FirstOrDefaultAsync(b => b.ProjectId == project.Id && b.Slug == slug);
if (existingBot != null)
{
throw new InvalidOperationException("A bot with this slug already exists in this project.");
}
var now = SystemClock.Instance.GetCurrentInstant();
try
{
// First create the bot account in the Pass service
var automatedId = Guid.NewGuid();
var createRequest = new CreateBotAccountRequest
{
AutomatedId = Guid.NewGuid().ToString(),
Account = new Account
{
Name = slug,
Nick = $"Bot {slug}",
Language = "en",
Profile = new AccountProfile
{
Id = Guid.NewGuid().ToString(),
CreatedAt = now.ToTimestamp(),
UpdatedAt = now.ToTimestamp()
},
CreatedAt = now.ToTimestamp(),
UpdatedAt = now.ToTimestamp()
}
AutomatedId = automatedId.ToString(),
Account = account,
PictureId = pictureId,
BackgroundId = backgroundId
};
var createResponse = await accountReceiver.CreateBotAccountAsync(createRequest);
@@ -64,7 +60,7 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
// Then create the local bot account
var bot = new BotAccount
{
Id = Guid.Parse(botAccount.AutomatedId),
Id = automatedId,
Slug = slug,
ProjectId = project.Id,
Project = project,
@@ -80,7 +76,8 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.AlreadyExists)
{
throw new InvalidOperationException("A bot account with this ID already exists in the authentication service.", ex);
throw new InvalidOperationException(
"A bot account with this ID already exists in the authentication service.", ex);
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.InvalidArgument)
{
@@ -92,22 +89,15 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
}
}
public async Task<BotAccount> UpdateBotAsync(BotAccount bot, string? slug = null, bool? isActive = null)
public async Task<BotAccount> UpdateBotAsync(
BotAccount bot,
Account account,
string? pictureId,
string? backgroundId
)
{
var updated = false;
if (slug != null && bot.Slug != slug)
{
bot.Slug = slug;
updated = true;
}
if (isActive.HasValue && bot.IsActive != isActive.Value)
{
bot.IsActive = isActive.Value;
updated = true;
}
if (!updated) return bot;
db.Update(bot);
await db.SaveChangesAsync();
try
{
@@ -115,12 +105,9 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
var updateRequest = new UpdateBotAccountRequest
{
AutomatedId = bot.Id.ToString(),
Account = new Shared.Proto.Account
{
Name = $"bot-{bot.Slug}",
Nick = $"Bot {bot.Slug}",
UpdatedAt = SystemClock.Instance.GetCurrentInstant().ToTimestamp()
}
Account = account,
PictureId = pictureId,
BackgroundId = backgroundId
};
var updateResponse = await accountReceiver.UpdateBotAccountAsync(updateRequest);
@@ -165,4 +152,23 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
db.BotAccounts.Remove(bot);
await db.SaveChangesAsync();
}
public async Task<BotAccount?> LoadBotAccountAsync(BotAccount bot) =>
(await LoadBotsAccountAsync([bot])).FirstOrDefault();
public async Task<List<BotAccount>> LoadBotsAccountAsync(IEnumerable<BotAccount> bots)
{
bots = bots.ToList();
var automatedIds = bots.Select(b => b.Id).ToList();
var data = await accounts.GetBotAccountBatch(automatedIds);
foreach (var bot in bots)
{
bot.Account = data
.Select(AccountReference.FromProtoValue)
.FirstOrDefault(e => e.AutomatedId == bot.Id);
}
return bots as List<BotAccount> ?? [];
}
}

View File

@@ -32,7 +32,7 @@ public class CustomApp : ModelBase, IIdentifiedResource
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
[Column(TypeName = "jsonb")] public DysonNetwork.Shared.Data.VerificationMark? Verification { get; set; }
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
[Column(TypeName = "jsonb")] public CustomAppOauthConfig? OauthConfig { get; set; }
[Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; }
@@ -62,17 +62,22 @@ public class CustomApp : ModelBase, IIdentifiedResource
CustomAppStatus.Suspended => Shared.Proto.CustomAppStatus.Suspended,
_ => Shared.Proto.CustomAppStatus.Unspecified
},
Picture = Picture is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Picture)),
Background = Background is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Background)),
Verification = Verification is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Verification)),
Links = Links is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Links)),
Picture = Picture?.ToProtoValue(),
Background = Background?.ToProtoValue(),
Verification = Verification?.ToProtoValue(),
Links = Links is null ? null : new DysonNetwork.Shared.Proto.CustomAppLinks
{
HomePage = Links.HomePage ?? string.Empty,
PrivacyPolicy = Links.PrivacyPolicy ?? string.Empty,
TermsOfService = Links.TermsOfService ?? string.Empty
},
OauthConfig = OauthConfig is null ? null : new DysonNetwork.Shared.Proto.CustomAppOauthConfig
{
ClientUri = OauthConfig.ClientUri ?? string.Empty,
RedirectUris = { OauthConfig.RedirectUris ?? Array.Empty<string>() },
PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? Array.Empty<string>() },
AllowedScopes = { OauthConfig.AllowedScopes ?? Array.Empty<string>() },
AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? Array.Empty<string>() },
RedirectUris = { OauthConfig.RedirectUris ?? [] },
PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? [] },
AllowedScopes = { OauthConfig.AllowedScopes ?? [] },
AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? [] },
RequirePkce = OauthConfig.RequirePkce,
AllowOfflineAccess = OauthConfig.AllowOfflineAccess
},
@@ -99,10 +104,18 @@ public class CustomApp : ModelBase, IIdentifiedResource
ProjectId = string.IsNullOrEmpty(p.ProjectId) ? Guid.Empty : Guid.Parse(p.ProjectId);
CreatedAt = p.CreatedAt.ToInstant();
UpdatedAt = p.UpdatedAt.ToInstant();
if (p.Picture.Length > 0) Picture = System.Text.Json.JsonSerializer.Deserialize<CloudFileReferenceObject>(p.Picture.ToStringUtf8());
if (p.Background.Length > 0) Background = System.Text.Json.JsonSerializer.Deserialize<CloudFileReferenceObject>(p.Background.ToStringUtf8());
if (p.Verification.Length > 0) Verification = System.Text.Json.JsonSerializer.Deserialize<DysonNetwork.Shared.Data.VerificationMark>(p.Verification.ToStringUtf8());
if (p.Links.Length > 0) Links = System.Text.Json.JsonSerializer.Deserialize<CustomAppLinks>(p.Links.ToStringUtf8());
if (p.Picture is not null) Picture = CloudFileReferenceObject.FromProtoValue(p.Picture);
if (p.Background is not null) Background = CloudFileReferenceObject.FromProtoValue(p.Background);
if (p.Verification is not null) Verification = VerificationMark.FromProtoValue(p.Verification);
if (p.Links is not null)
{
Links = new CustomAppLinks
{
HomePage = string.IsNullOrEmpty(p.Links.HomePage) ? null : p.Links.HomePage,
PrivacyPolicy = string.IsNullOrEmpty(p.Links.PrivacyPolicy) ? null : p.Links.PrivacyPolicy,
TermsOfService = string.IsNullOrEmpty(p.Links.TermsOfService) ? null : p.Links.TermsOfService
};
}
return this;
}
}

View File

@@ -3,12 +3,14 @@ using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NodaTime;
namespace DysonNetwork.Develop.Identity;
[ApiController]
[Route("/api/developers/{pubName}/projects/{projectId:guid}/apps")]
public class CustomAppController(CustomAppService customApps, DeveloperService ds, DevProjectService projectService) : ControllerBase
public class CustomAppController(CustomAppService customApps, DeveloperService ds, DevProjectService projectService)
: ControllerBase
{
public record CustomAppRequest(
[MaxLength(1024)] string? Slug,
@@ -21,12 +23,36 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
CustomAppOauthConfig? OauthConfig
);
public record CreateSecretRequest(
[MaxLength(4096)] string? Description,
TimeSpan? ExpiresIn = null,
bool IsOidc = false
);
public record SecretResponse(
string Id,
string? Secret,
string? Description,
Instant? ExpiresAt,
bool IsOidc,
Instant CreatedAt,
Instant UpdatedAt
);
[HttpGet]
[Authorize]
public async Task<IActionResult> ListApps([FromRoute] string pubName, [FromRoute] Guid projectId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id);
if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer))
return StatusCode(403, "You must be a viewer of the developer to list custom apps");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null) return NotFound();
@@ -35,11 +61,20 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
}
[HttpGet("{appId:guid}")]
public async Task<IActionResult> GetApp([FromRoute] string pubName, [FromRoute] Guid projectId, [FromRoute] Guid appId)
[Authorize]
public async Task<IActionResult> GetApp([FromRoute] string pubName, [FromRoute] Guid projectId,
[FromRoute] Guid appId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id);
if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer))
return StatusCode(403, "You must be a viewer of the developer to list custom apps");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null) return NotFound();
@@ -61,9 +96,11 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName);
var accountId = Guid.Parse(currentUser.Id);
if (developer is null || developer.Id != accountId)
return Forbid();
if (developer is null)
return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to create a custom app");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null)
@@ -72,9 +109,6 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Slug))
return BadRequest("Name and slug are required");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to create a custom app");
try
{
var app = await customApps.CreateAppAsync(projectId, request);
@@ -163,4 +197,235 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
return NoContent();
}
[HttpGet("{appId:guid}/secrets")]
[Authorize]
public async Task<IActionResult> ListSecrets(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid appId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to view app secrets");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null)
return NotFound("Project not found or you don't have access");
var app = await customApps.GetAppAsync(appId, projectId);
if (app == null)
return NotFound("App not found");
var secrets = await customApps.GetAppSecretsAsync(appId);
return Ok(secrets.Select(s => new SecretResponse(
s.Id.ToString(),
null,
s.Description,
s.ExpiredAt,
s.IsOidc,
s.CreatedAt,
s.UpdatedAt
)));
}
[HttpPost("{appId:guid}/secrets")]
[Authorize]
public async Task<IActionResult> CreateSecret(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid appId,
[FromBody] CreateSecretRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to create app secrets");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null)
return NotFound("Project not found or you don't have access");
var app = await customApps.GetAppAsync(appId, projectId);
if (app == null)
return NotFound("App not found");
try
{
var secret = await customApps.CreateAppSecretAsync(new CustomAppSecret
{
AppId = appId,
Description = request.Description,
ExpiredAt = request.ExpiresIn.HasValue
? NodaTime.SystemClock.Instance.GetCurrentInstant()
.Plus(Duration.FromTimeSpan(request.ExpiresIn.Value))
: (NodaTime.Instant?)null,
IsOidc = request.IsOidc
});
return CreatedAtAction(
nameof(GetSecret),
new { pubName, projectId, appId, secretId = secret.Id },
new SecretResponse(
secret.Id.ToString(),
secret.Secret,
secret.Description,
secret.ExpiredAt,
secret.IsOidc,
secret.CreatedAt,
secret.UpdatedAt
)
);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("{appId:guid}/secrets/{secretId:guid}")]
[Authorize]
public async Task<IActionResult> GetSecret(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid appId,
[FromRoute] Guid secretId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to view app secrets");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null)
return NotFound("Project not found or you don't have access");
var app = await customApps.GetAppAsync(appId, projectId);
if (app == null)
return NotFound("App not found");
var secret = await customApps.GetAppSecretAsync(secretId, appId);
if (secret == null)
return NotFound("Secret not found");
return Ok(new SecretResponse(
secret.Id.ToString(),
null,
secret.Description,
secret.ExpiredAt,
secret.IsOidc,
secret.CreatedAt,
secret.UpdatedAt
));
}
[HttpDelete("{appId:guid}/secrets/{secretId:guid}")]
[Authorize]
public async Task<IActionResult> DeleteSecret(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid appId,
[FromRoute] Guid secretId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to delete app secrets");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null)
return NotFound("Project not found or you don't have access");
var app = await customApps.GetAppAsync(appId, projectId);
if (app == null)
return NotFound("App not found");
var secret = await customApps.GetAppSecretAsync(secretId, appId);
if (secret == null)
return NotFound("Secret not found");
var result = await customApps.DeleteAppSecretAsync(secretId, appId);
if (!result)
return NotFound("Failed to delete secret");
return NoContent();
}
[HttpPost("{appId:guid}/secrets/{secretId:guid}/rotate")]
[Authorize]
public async Task<IActionResult> RotateSecret(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid appId,
[FromRoute] Guid secretId,
[FromBody] CreateSecretRequest? request = null)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to rotate app secrets");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null)
return NotFound("Project not found or you don't have access");
var app = await customApps.GetAppAsync(appId, projectId);
if (app == null)
return NotFound("App not found");
try
{
var secret = await customApps.RotateAppSecretAsync(new CustomAppSecret
{
Id = secretId,
AppId = appId,
Description = request?.Description,
ExpiredAt = request?.ExpiresIn.HasValue == true
? NodaTime.SystemClock.Instance.GetCurrentInstant()
.Plus(Duration.FromTimeSpan(request.ExpiresIn.Value))
: (NodaTime.Instant?)null,
IsOidc = request?.IsOidc ?? false
});
return Ok(new SecretResponse(
secret.Id.ToString(),
secret.Secret,
secret.Description,
secret.ExpiredAt,
secret.IsOidc,
secret.CreatedAt,
secret.UpdatedAt
));
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
}

View File

@@ -2,6 +2,8 @@ using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;
namespace DysonNetwork.Develop.Identity;
@@ -94,6 +96,87 @@ public class CustomAppService(
return await query.FirstOrDefaultAsync(a => a.Id == id);
}
public async Task<List<CustomAppSecret>> GetAppSecretsAsync(Guid appId)
{
return await db.CustomAppSecrets
.Where(s => s.AppId == appId)
.OrderByDescending(s => s.CreatedAt)
.ToListAsync();
}
public async Task<CustomAppSecret?> GetAppSecretAsync(Guid secretId, Guid appId)
{
return await db.CustomAppSecrets
.FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId);
}
public async Task<CustomAppSecret> CreateAppSecretAsync(CustomAppSecret secret)
{
if (string.IsNullOrWhiteSpace(secret.Secret))
{
// Generate a new random secret if not provided
secret.Secret = GenerateRandomSecret();
}
secret.Id = Guid.NewGuid();
secret.CreatedAt = NodaTime.SystemClock.Instance.GetCurrentInstant();
secret.UpdatedAt = secret.CreatedAt;
db.CustomAppSecrets.Add(secret);
await db.SaveChangesAsync();
return secret;
}
public async Task<bool> DeleteAppSecretAsync(Guid secretId, Guid appId)
{
var secret = await db.CustomAppSecrets
.FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId);
if (secret == null)
return false;
db.CustomAppSecrets.Remove(secret);
await db.SaveChangesAsync();
return true;
}
public async Task<CustomAppSecret> RotateAppSecretAsync(CustomAppSecret secretUpdate)
{
var existingSecret = await db.CustomAppSecrets
.FirstOrDefaultAsync(s => s.Id == secretUpdate.Id && s.AppId == secretUpdate.AppId);
if (existingSecret == null)
throw new InvalidOperationException("Secret not found");
// Update the existing secret with new values
existingSecret.Secret = GenerateRandomSecret();
existingSecret.Description = secretUpdate.Description ?? existingSecret.Description;
existingSecret.ExpiredAt = secretUpdate.ExpiredAt ?? existingSecret.ExpiredAt;
existingSecret.IsOidc = secretUpdate.IsOidc;
existingSecret.UpdatedAt = NodaTime.SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
return existingSecret;
}
private static string GenerateRandomSecret(int length = 64)
{
const string valid = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-._~+";
var res = new StringBuilder();
using (var rng = RandomNumberGenerator.Create())
{
var uintBuffer = new byte[sizeof(uint)];
while (length-- > 0)
{
rng.GetBytes(uintBuffer);
var num = BitConverter.ToUInt32(uintBuffer, 0);
res.Append(valid[(int)(num % (uint)valid.Length)]);
}
}
return res.ToString();
}
public async Task<List<CustomApp>> GetAppsByProjectAsync(Guid projectId)
{
return await db.CustomApps

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Data;
@@ -11,7 +12,7 @@ public class Developer
public Guid Id { get; set; } = Guid.NewGuid();
public Guid PublisherId { get; set; }
public List<DevProject> Projects { get; set; } = [];
[JsonIgnore] public List<DevProject> Projects { get; set; } = [];
[NotMapped] public PublisherInfo? Publisher { get; set; }
}

View File

@@ -4,7 +4,10 @@ using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Develop.Identity;
public class DeveloperService(AppDatabase db, PublisherService.PublisherServiceClient ps, ILogger<DeveloperService> logger)
public class DeveloperService(
AppDatabase db,
PublisherService.PublisherServiceClient ps,
ILogger<DeveloperService> logger)
{
public async Task<Developer> LoadDeveloperPublisher(Developer developer)
{
@@ -47,6 +50,11 @@ public class DeveloperService(AppDatabase db, PublisherService.PublisherServiceC
}
}
public async Task<Developer?> GetDeveloperById(Guid id)
{
return await db.Developers.FirstOrDefaultAsync(d => d.Id == id);
}
public async Task<bool> IsMemberWithRole(Guid pubId, Guid accountId, PublisherMemberRole role)
{
try

View File

@@ -0,0 +1,324 @@
// <auto-generated />
using System;
using DysonNetwork.Develop;
using DysonNetwork.Develop.Identity;
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.Develop.Migrations
{
[DbContext(typeof(AppDatabase))]
[Migration("20250819163227_AddBotAccount")]
partial class AddBotAccount
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", 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<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid")
.HasColumnName("project_id");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("slug");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_bot_accounts");
b.HasIndex("ProjectId")
.HasDatabaseName("ix_bot_accounts_project_id");
b.ToTable("bot_accounts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<CloudFileReferenceObject>("Background")
.HasColumnType("jsonb")
.HasColumnName("background");
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<CustomAppLinks>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<CustomAppOauthConfig>("OauthConfig")
.HasColumnType("jsonb")
.HasColumnName("oauth_config");
b.Property<CloudFileReferenceObject>("Picture")
.HasColumnType("jsonb")
.HasColumnName("picture");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid")
.HasColumnName("project_id");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("slug");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<VerificationMark>("Verification")
.HasColumnType("jsonb")
.HasColumnName("verification");
b.HasKey("Id")
.HasName("pk_custom_apps");
b.HasIndex("ProjectId")
.HasDatabaseName("ix_custom_apps_project_id");
b.ToTable("custom_apps", (string)null);
});
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AppId")
.HasColumnType("uuid")
.HasColumnName("app_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<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<bool>("IsOidc")
.HasColumnType("boolean")
.HasColumnName("is_oidc");
b.Property<string>("Secret")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("secret");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_custom_app_secrets");
b.HasIndex("AppId")
.HasDatabaseName("ix_custom_app_secrets_app_id");
b.ToTable("custom_app_secrets", (string)null);
});
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("PublisherId")
.HasColumnType("uuid")
.HasColumnName("publisher_id");
b.HasKey("Id")
.HasName("pk_developers");
b.ToTable("developers", (string)null);
});
modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", 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<string>("Description")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<Guid>("DeveloperId")
.HasColumnType("uuid")
.HasColumnName("developer_id");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("slug");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_dev_projects");
b.HasIndex("DeveloperId")
.HasDatabaseName("ix_dev_projects_developer_id");
b.ToTable("dev_projects", (string)null);
});
modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b =>
{
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_bot_accounts_dev_projects_project_id");
b.Navigation("Project");
});
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
{
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_custom_apps_dev_projects_project_id");
b.Navigation("Project");
});
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
{
b.HasOne("DysonNetwork.Develop.Identity.CustomApp", "App")
.WithMany("Secrets")
.HasForeignKey("AppId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_custom_app_secrets_custom_apps_app_id");
b.Navigation("App");
});
modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
{
b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer")
.WithMany("Projects")
.HasForeignKey("DeveloperId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_dev_projects_developers_developer_id");
b.Navigation("Developer");
});
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
{
b.Navigation("Secrets");
});
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
{
b.Navigation("Projects");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,51 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Develop.Migrations
{
/// <inheritdoc />
public partial class AddBotAccount : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "bot_accounts",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
is_active = table.Column<bool>(type: "boolean", nullable: false),
project_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_bot_accounts", x => x.id);
table.ForeignKey(
name: "fk_bot_accounts_dev_projects_project_id",
column: x => x.project_id,
principalTable: "dev_projects",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_bot_accounts_project_id",
table: "bot_accounts",
column: "project_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "bot_accounts");
}
}
}

View File

@@ -25,6 +25,48 @@ namespace DysonNetwork.Develop.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", 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<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid")
.HasColumnName("project_id");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("slug");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_bot_accounts");
b.HasIndex("ProjectId")
.HasDatabaseName("ix_bot_accounts_project_id");
b.ToTable("bot_accounts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
{
b.Property<Guid>("Id")
@@ -216,6 +258,18 @@ namespace DysonNetwork.Develop.Migrations
b.ToTable("dev_projects", (string)null);
});
modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b =>
{
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_bot_accounts_dev_projects_project_id");
b.Navigation("Project");
});
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
{
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")

View File

@@ -3,6 +3,7 @@ using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Develop.Startup;
using DysonNetwork.Shared.Stream;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
@@ -10,6 +11,7 @@ var builder = WebApplication.CreateBuilder(args);
builder.ConfigureAppKestrel(builder.Configuration);
builder.Services.AddRegistryService(builder.Configuration);
builder.Services.AddStreamConnection(builder.Configuration);
builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppAuthentication();
builder.Services.AddAppSwagger();

View File

@@ -24,7 +24,7 @@
},
"Service": {
"Name": "DysonNetwork.Develop",
"Url": "https://localhost:7099",
"Url": "https://localhost:7192",
"ClientCert": "../Certificates/client.crt",
"ClientKey": "../Certificates/client.key"
}

View File

@@ -0,0 +1,404 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Drive.Storage;
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("20250819164302_RemoveUploadedTo")]
partial class RemoveUploadedTo
{
/// <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.Billing.QuotaRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.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")
.IsRequired()
.HasColumnType("text")
.HasColumnName("description");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<long>("Quota")
.HasColumnType("bigint")
.HasColumnName("quota");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_quota_records");
b.ToTable("quota_records", (string)null);
});
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<Guid?>("BundleId")
.HasColumnType("uuid")
.HasColumnName("bundle_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<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Dictionary<string, object>>("FileMeta")
.HasColumnType("jsonb")
.HasColumnName("file_meta");
b.Property<bool>("HasCompression")
.HasColumnType("boolean")
.HasColumnName("has_compression");
b.Property<bool>("HasThumbnail")
.HasColumnType("boolean")
.HasColumnName("has_thumbnail");
b.Property<string>("Hash")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("hash");
b.Property<bool>("IsEncrypted")
.HasColumnType("boolean")
.HasColumnName("is_encrypted");
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<Guid?>("PoolId")
.HasColumnType("uuid")
.HasColumnName("pool_id");
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<Dictionary<string, object>>("UserMeta")
.HasColumnType("jsonb")
.HasColumnName("user_meta");
b.HasKey("Id")
.HasName("pk_files");
b.HasIndex("BundleId")
.HasDatabaseName("ix_files_bundle_id");
b.HasIndex("PoolId")
.HasDatabaseName("ix_files_pool_id");
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.FileBundle", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.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(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("description");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<string>("Passcode")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("passcode");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("slug");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_bundles");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_bundles_slug");
b.ToTable("bundles", (string)null);
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid?>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<BillingConfig>("BillingConfig")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("billing_config");
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")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("description");
b.Property<bool>("IsHidden")
.HasColumnType("boolean")
.HasColumnName("is_hidden");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<PolicyConfig>("PolicyConfig")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("policy_config");
b.Property<RemoteStorageConfig>("StorageConfig")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("storage_config");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_pools");
b.ToTable("pools", (string)null);
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
{
b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle")
.WithMany("Files")
.HasForeignKey("BundleId")
.HasConstraintName("fk_files_bundles_bundle_id");
b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool")
.WithMany()
.HasForeignKey("PoolId")
.HasConstraintName("fk_files_pools_pool_id");
b.Navigation("Bundle");
b.Navigation("Pool");
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
{
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
.WithMany("References")
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_references_files_file_id");
b.Navigation("File");
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
{
b.Navigation("References");
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
{
b.Navigation("Files");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Drive.Migrations
{
/// <inheritdoc />
public partial class RemoveUploadedTo : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "uploaded_to",
table: "files");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "uploaded_to",
table: "files",
type: "character varying(128)",
maxLength: 128,
nullable: true);
}
}
}

View File

@@ -172,11 +172,6 @@ namespace DysonNetwork.Drive.Migrations
.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");
@@ -382,7 +377,7 @@ namespace DysonNetwork.Drive.Migrations
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
{
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
.WithMany()
.WithMany("References")
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
@@ -391,6 +386,11 @@ namespace DysonNetwork.Drive.Migrations
b.Navigation("File");
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
{
b.Navigation("References");
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
{
b.Navigation("Files");

View File

@@ -5,6 +5,7 @@ using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.PageData;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Shared.Stream;
using Microsoft.EntityFrameworkCore;
using tusdotnet.Stores;
@@ -15,6 +16,7 @@ builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxV
// Add application services
builder.Services.AddRegistryService(builder.Configuration);
builder.Services.AddStreamConnection(builder.Configuration);
builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication();

View File

@@ -0,0 +1,56 @@
using System.Text.Json;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Stream;
using Microsoft.EntityFrameworkCore;
using NATS.Client.Core;
namespace DysonNetwork.Drive.Startup;
public class BroadcastEventHandler(
INatsConnection nats,
ILogger<BroadcastEventHandler> logger,
IServiceProvider serviceProvider
) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var msg in nats.SubscribeAsync<byte[]>("accounts.deleted", cancellationToken: stoppingToken))
{
try
{
var evt = JsonSerializer.Deserialize<AccountDeletedEvent>(msg.Data);
if (evt == null) continue;
logger.LogInformation("Account deleted: {AccountId}", evt.AccountId);
using var scope = serviceProvider.CreateScope();
var fs = scope.ServiceProvider.GetRequiredService<FileService>();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken: stoppingToken);
try
{
var files = await db.Files
.Where(p => p.AccountId == evt.AccountId)
.ToListAsync(cancellationToken: stoppingToken);
await fs.DeleteFileDataBatchAsync(files);
await db.Files
.Where(p => p.AccountId == evt.AccountId)
.ExecuteDeleteAsync(cancellationToken: stoppingToken);
await transaction.CommitAsync(cancellationToken: stoppingToken);
}
catch (Exception)
{
await transaction.RollbackAsync(cancellationToken: stoppingToken);
throw;
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing AccountDeleted");
}
}
}
}

View File

@@ -141,6 +141,8 @@ public static class ServiceCollectionExtensions
services.AddScoped<Billing.UsageService>();
services.AddScoped<Billing.QuotaService>();
services.AddHostedService<BroadcastEventHandler>();
return services;
}
}

View File

@@ -33,10 +33,6 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
[JsonIgnore] public FileBundle? Bundle { get; set; }
public Guid? BundleId { get; set; }
[Obsolete("Deprecated, use PoolId instead. For database migration only.")]
[MaxLength(128)]
public string? UploadedTo { get; set; }
/// <summary>
/// The field is set to true if the recycling job plans to delete the file.
/// Due to the unstable of the recycling job, this doesn't really delete the file until a human verifies it.
@@ -61,6 +57,8 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? FastUploadLink { get; set; }
public ICollection<CloudFileReference> References { get; set; } = new List<CloudFileReference>();
public Guid AccountId { get; set; }
public CloudFileReferenceObject ToReferenceObject()

View File

@@ -190,10 +190,8 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
.Where(r => r.ResourceId == resourceId && r.Usage == usage)
.ToListAsync();
if (!references.Any())
{
if (references.Count == 0)
return 0;
}
var fileIds = references.Select(r => r.FileId).Distinct().ToList();
@@ -208,6 +206,28 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
return deletedCount;
}
public async Task<int> DeleteResourceReferencesBatchAsync(IEnumerable<string> resourceIds, string? usage = null)
{
var references = await db.FileReferences
.Where(r => resourceIds.Contains(r.ResourceId))
.If(usage != null, q => q.Where(q => q.Usage == usage))
.ToListAsync();
if (references.Count == 0)
return 0;
var fileIds = references.Select(r => r.FileId).Distinct().ToList();
db.FileReferences.RemoveRange(references);
var deletedCount = await db.SaveChangesAsync();
// Purge caches
var tasks = fileIds.Select(fileService._PurgeCacheAsync).ToList();
await Task.WhenAll(tasks);
return deletedCount;
}
/// <summary>
/// Deletes a specific file reference
/// </summary>

View File

@@ -85,7 +85,7 @@ namespace DysonNetwork.Drive.Storage
public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferences(
DeleteResourceReferencesRequest request, ServerCallContext context)
{
var deletedCount = 0;
int deletedCount;
if (request.Usage is null)
deletedCount = await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId);
else
@@ -94,6 +94,18 @@ namespace DysonNetwork.Drive.Storage
return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
}
public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferencesBatch(DeleteResourceReferencesBatchRequest request, ServerCallContext context)
{
var resourceIds = request.ResourceIds.ToList();
int deletedCount;
if (request.Usage is null)
deletedCount = await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds);
else
deletedCount =
await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds, request.Usage!);
return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
}
public override async Task<DeleteReferenceResponse> DeleteReference(DeleteReferenceRequest request,
ServerCallContext context)
{

View File

@@ -102,6 +102,7 @@ public class FileService(
private static readonly string[] AnimatedImageTypes =
["image/gif", "image/apng", "image/avif"];
private static readonly string[] AnimatedImageExtensions =
[".gif", ".apng", ".avif"];
@@ -336,7 +337,14 @@ public class FileService(
if (!pool.PolicyConfig.NoOptimization)
switch (contentType.Split('/')[0])
{
case "image" when !AnimatedImageTypes.Contains(contentType) && !AnimatedImageExtensions.Contains(fileExtension):
case "image":
if (AnimatedImageTypes.Contains(contentType) || AnimatedImageExtensions.Contains(fileExtension))
{
logger.LogInformation("Skip optimize file {FileId} due to it is animated...", fileId);
uploads.Add((originalFilePath, string.Empty, contentType, false));
break;
}
newMimeType = "image/webp";
using (var vipsImage = Image.NewFromFile(originalFilePath))
{
@@ -643,7 +651,44 @@ public class FileService(
}
}
public async Task<FileBundle?> GetBundleAsync(Guid id, Guid accountId)
/// <summary>
/// The most efficent way to delete file data (stored files) in batch.
/// But this DO NOT check the storage id, so use with caution!
/// </summary>
/// <param name="files">Files to delete</param>
/// <exception cref="InvalidOperationException">Something went wrong</exception>
public async Task DeleteFileDataBatchAsync(List<CloudFile> files)
{
files = files.Where(f => f.PoolId.HasValue).ToList();
foreach (var fileGroup in files.GroupBy(f => f.PoolId!.Value))
{
// If any other file with the same storage ID is referenced, don't delete the actual file data
var dest = await GetRemoteStorageConfig(fileGroup.Key);
if (dest is null)
throw new InvalidOperationException($"No remote storage configured for pool {fileGroup.Key}");
var client = CreateMinioClient(dest);
if (client is null)
throw new InvalidOperationException(
$"Failed to configure client for remote destination '{fileGroup.Key}'"
);
List<string> objectsToDelete = [];
foreach (var file in fileGroup)
{
objectsToDelete.Add(file.StorageId ?? file.Id);
if (file.HasCompression) objectsToDelete.Add(file.StorageId ?? file.Id + ".compressed");
if (file.HasThumbnail) objectsToDelete.Add(file.StorageId ?? file.Id + ".thumbnail");
}
await client.RemoveObjectsAsync(
new RemoveObjectsArgs().WithBucket(dest.Bucket).WithObjects(objectsToDelete)
);
}
}
private async Task<FileBundle?> GetBundleAsync(Guid id, Guid accountId)
{
var bundle = await db.Bundles
.Where(e => e.Id == id)

View File

@@ -76,26 +76,6 @@ public class RegistryProxyConfigProvider : IProxyConfigProvider, IDisposable
var gatewayServiceName = _configuration["Service:Name"];
// Add direct route for /cgi to Gateway
var gatewayCluster = new ClusterConfig
{
ClusterId = "gateway-self",
Destinations = new Dictionary<string, DestinationConfig>
{
{ "self", new DestinationConfig { Address = _configuration["Kestrel:Endpoints:Http:Url"] ?? "http://localhost:5000" } }
}
};
clusters.Add(gatewayCluster);
var cgiRoute = new RouteConfig
{
RouteId = "gateway-cgi-route",
ClusterId = "gateway-self",
Match = new RouteMatch { Path = "/cgi/{**catch-all}" }
};
routes.Add(cgiRoute);
_logger.LogInformation(" Added CGI Route: /cgi/** -> Gateway");
// Add direct routes
foreach (var directRoute in directRoutes)
{

View File

@@ -51,7 +51,8 @@ public class Account : ModelBase
Profile = Profile.ToProtoValue(),
PerkSubscription = PerkSubscription?.ToProtoValue(),
CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp()
UpdatedAt = UpdatedAt.ToTimestamp(),
AutomatedId = AutomatedId?.ToString()
};
// Add contacts
@@ -81,10 +82,10 @@ public class Account : ModelBase
: null,
CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant(),
AutomatedId = proto.AutomatedId is not null ? Guid.Parse(proto.AutomatedId) : null,
Profile = AccountProfile.FromProtoValue(proto.Profile)
};
account.Profile = AccountProfile.FromProtoValue(proto.Profile);
foreach (var contactProto in proto.Contacts)
account.Contacts.Add(AccountContact.FromProtoValue(contactProto));
@@ -119,7 +120,7 @@ public abstract class Leveling
public class AccountProfile : ModelBase, IIdentifiedResource
{
public Guid Id { get; set; }
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(256)] public string? FirstName { get; set; }
[MaxLength(256)] public string? MiddleName { get; set; }
[MaxLength(256)] public string? LastName { get; set; }
@@ -135,9 +136,20 @@ public class AccountProfile : ModelBase, IIdentifiedResource
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
[Column(TypeName = "jsonb")] public BadgeReferenceObject? ActiveBadge { get; set; }
public int Experience { get; set; } = 0;
public int Experience { get; set; }
[NotMapped] public int Level => Leveling.ExperiencePerLevel.Count(xp => Experience >= xp) - 1;
public double SocialCredits { get; set; } = 100;
[NotMapped]
public int SocialCreditsLevel => SocialCredits switch
{
< 100 => -1,
> 100 and < 200 => 0,
< 200 => 1,
_ => 2
};
[NotMapped]
public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1
? 100
@@ -168,6 +180,8 @@ public class AccountProfile : ModelBase, IIdentifiedResource
Experience = Experience,
Level = Level,
LevelingProgress = LevelingProgress,
SocialCredits = SocialCredits,
SocialCreditsLevel = SocialCreditsLevel,
Picture = Picture?.ToProtoValue(),
Background = Background?.ToProtoValue(),
AccountId = AccountId.ToString(),
@@ -198,6 +212,7 @@ public class AccountProfile : ModelBase, IIdentifiedResource
Verification = proto.Verification is null ? null : VerificationMark.FromProtoValue(proto.Verification),
ActiveBadge = proto.ActiveBadge is null ? null : BadgeReferenceObject.FromProtoValue(proto.ActiveBadge),
Experience = proto.Experience,
SocialCredits = proto.SocialCredits,
Picture = proto.Picture is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Picture),
Background = proto.Background is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Background),
AccountId = Guid.Parse(proto.AccountId),

View File

@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Error;
using Microsoft.AspNetCore.Mvc;
@@ -15,7 +16,8 @@ public class AccountController(
AuthService auth,
AccountService accounts,
SubscriptionService subscriptions,
AccountEventService events
AccountEventService events,
SocialCreditService socialCreditService
) : ControllerBase
{
[HttpGet("{name}")]
@@ -49,6 +51,25 @@ public class AccountController(
return account is null ? NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier)) : account.Badges.ToList();
}
[HttpGet("{name}/credits")]
[ProducesResponseType<double>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<double>> GetSocialCredits(string name)
{
var account = await db.Accounts
.Where(a => a.Name == name)
.Select(a => new { a.Id })
.FirstOrDefaultAsync();
if (account is null)
{
return NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier));
}
var credits = await socialCreditService.GetSocialCredit(account.Id);
return credits;
}
public class AccountCreateRequest
{
[Required]

View File

@@ -24,11 +24,13 @@ public class AccountCurrentController(
AccountEventService events,
AuthService auth,
FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs
FileReferenceService.FileReferenceServiceClient fileRefs,
Credit.SocialCreditService creditService
) : ControllerBase
{
[HttpGet]
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
[ProducesResponseType<ApiError>(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<Account>> GetCurrentIdentity()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
@@ -268,7 +270,9 @@ public class AccountCurrentController(
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync();
return result is null ? NotFound(ApiError.NotFound("check-in", traceId: HttpContext.TraceIdentifier)) : Ok(result);
return result is null
? NotFound(ApiError.NotFound("check-in", traceId: HttpContext.TraceIdentifier))
: Ok(result);
}
[HttpPost("check-in")]
@@ -323,7 +327,8 @@ public class AccountCurrentController(
TraceId = HttpContext.TraceIdentifier
}
),
true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest(ApiError.Validation(new Dictionary<string, string[]>
true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest(ApiError.Validation(
new Dictionary<string, string[]>
{
["captchaToken"] = new[] { "Invalid captcha token." }
}, traceId: HttpContext.TraceIdentifier)),
@@ -823,4 +828,60 @@ public class AccountCurrentController(
return BadRequest(ex.Message);
}
}
[HttpGet("leveling")]
[Authorize]
public async Task<ActionResult<ExperienceRecord>> GetLevelingHistory(
[FromQuery] int take = 20,
[FromQuery] int offset = 0
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var queryable = db.ExperienceRecords
.Where(r => r.AccountId == currentUser.Id)
.OrderByDescending(r => r.CreatedAt)
.AsQueryable();
var totalCount = await queryable.CountAsync();
Response.Headers["X-Total"] = totalCount.ToString();
var records = await queryable
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(records);
}
[HttpGet("credits")]
public async Task<ActionResult<bool>> GetSocialCredit()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var credit = await creditService.GetSocialCredit(currentUser.Id);
return Ok(credit);
}
[HttpGet("credits/history")]
public async Task<ActionResult<SocialCreditRecord>> GetCreditHistory(
[FromQuery] int take = 20,
[FromQuery] int offset = 0
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var queryable = db.SocialCreditRecords
.Where(r => r.AccountId == currentUser.Id)
.OrderByDescending(r => r.CreatedAt)
.AsQueryable();
var totalCount = await queryable.CountAsync();
Response.Headers["X-Total"] = totalCount.ToString();
var records = await queryable
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(records);
}
}

View File

@@ -15,7 +15,8 @@ public class AccountEventService(
ICacheService cache,
IStringLocalizer<Localization.AccountEventResource> localizer,
PusherService.PusherServiceClient pusher,
SubscriptionService subscriptions
SubscriptionService subscriptions,
Pass.Leveling.ExperienceService experienceService
)
{
private static readonly Random Random = new();
@@ -327,13 +328,15 @@ public class AccountEventService(
result.RewardPoints = null;
}
await db.AccountProfiles
.Where(p => p.AccountId == user.Id)
.ExecuteUpdateAsync(s =>
s.SetProperty(b => b.Experience, b => b.Experience + result.RewardExperience)
);
db.AccountCheckInResults.Add(result);
await db.SaveChangesAsync(); // Don't forget to save changes to the database
await db.SaveChangesAsync(); // Remember to save changes to the database
if (result.RewardExperience is not null)
await experienceService.AddRecord(
"check-in",
$"Check-in reward on {now:yyyy/MM/dd}",
result.RewardExperience.Value,
user.Id
);
// The lock will be automatically released by the await using statement
return result;

View File

@@ -1,14 +1,18 @@
using System.Globalization;
using System.Text.Json;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Auth.OpenId;
using DysonNetwork.Pass.Email;
using DysonNetwork.Pass.Localization;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Stream;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NATS.Client.Core;
using NodaTime;
using OtpNet;
using AuthService = DysonNetwork.Pass.Auth.AuthService;
@@ -18,12 +22,15 @@ namespace DysonNetwork.Pass.Account;
public class AccountService(
AppDatabase db,
MagicSpellService spells,
FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs,
AccountUsernameService uname,
EmailService mailer,
PusherService.PusherServiceClient pusher,
IStringLocalizer<NotificationResource> localizer,
ICacheService cache,
ILogger<AccountService> logger
ILogger<AccountService> logger,
INatsConnection nats
)
{
public static void SetCultureInfo(Account account)
@@ -178,7 +185,7 @@ public class AccountService(
);
}
public async Task<Account> CreateBotAccount(Account account, Guid automatedId)
public async Task<Account> CreateBotAccount(Account account, Guid automatedId, string? pictureId, string? backgroundId)
{
var dupeAutomateCount = await db.Accounts.Where(a => a.AutomatedId == automatedId).CountAsync();
if (dupeAutomateCount > 0)
@@ -191,8 +198,38 @@ public class AccountService(
account.AutomatedId = automatedId;
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
account.IsSuperuser = false;
if (!string.IsNullOrEmpty(pictureId))
{
var file = await files.GetFileAsync(new GetFileRequest { Id = pictureId });
await fileRefs.CreateReferenceAsync(
new CreateReferenceRequest
{
ResourceId = account.Profile.ResourceIdentifier,
FileId = pictureId,
Usage = "profile.picture"
}
);
account.Profile.Picture = CloudFileReferenceObject.FromProtoValue(file);
}
if (!string.IsNullOrEmpty(backgroundId))
{
var file = await files.GetFileAsync(new GetFileRequest { Id = backgroundId });
await fileRefs.CreateReferenceAsync(
new CreateReferenceRequest
{
ResourceId = account.Profile.ResourceIdentifier,
FileId = backgroundId,
Usage = "profile.background"
}
);
account.Profile.Background = CloudFileReferenceObject.FromProtoValue(file);
}
db.Accounts.Add(account);
await db.SaveChangesAsync();
return account;
}
@@ -696,5 +733,11 @@ public class AccountService(
db.Accounts.Remove(account);
await db.SaveChangesAsync();
await nats.PublishAsync(AccountDeletedEvent.Type, JsonSerializer.SerializeToUtf8Bytes(new AccountDeletedEvent
{
AccountId = account.Id,
DeletedAt = SystemClock.Instance.GetCurrentInstant()
}));
}
}

View File

@@ -42,6 +42,26 @@ public class AccountServiceGrpc(
return account.ToProtoValue();
}
public override async Task<Shared.Proto.Account> GetBotAccount(GetBotAccountRequest request,
ServerCallContext context)
{
if (!Guid.TryParse(request.AutomatedId, out var automatedId))
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid automated ID format"));
var account = await _db.Accounts
.AsNoTracking()
.Include(a => a.Profile)
.FirstOrDefaultAsync(a => a.AutomatedId == automatedId);
if (account == null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account with automated ID {request.AutomatedId} not found"));
var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id);
account.PerkSubscription = perk?.ToReference();
return account.ToProtoValue();
}
public override async Task<GetAccountBatchResponse> GetAccountBatch(GetAccountBatchRequest request,
ServerCallContext context)
{
@@ -69,6 +89,34 @@ public class AccountServiceGrpc(
return response;
}
public override async Task<GetAccountBatchResponse> GetBotAccountBatch(GetBotAccountBatchRequest request,
ServerCallContext context)
{
var automatedIds = request.AutomatedId
.Select(id => Guid.TryParse(id, out var automatedId) ? automatedId : (Guid?)null)
.Where(id => id.HasValue)
.Select(id => id!.Value)
.ToList();
var accounts = await _db.Accounts
.AsNoTracking()
.Where(a => a.AutomatedId != null && automatedIds.Contains(a.AutomatedId.Value))
.Include(a => a.Profile)
.ToListAsync();
var perks = await subscriptions.GetPerkSubscriptionsAsync(
accounts.Select(x => x.Id).ToList()
);
foreach (var account in accounts)
if (perks.TryGetValue(account.Id, out var perk))
account.PerkSubscription = perk?.ToReference();
var response = new GetAccountBatchResponse();
response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
return response;
}
public override async Task<AccountStatus> GetAccountStatus(GetAccountRequest request, ServerCallContext context)
{
var accountId = Guid.Parse(request.Id);
@@ -76,7 +124,8 @@ public class AccountServiceGrpc(
return status.ToProtoValue();
}
public override async Task<GetAccountStatusBatchResponse> GetAccountStatusBatch(GetAccountBatchRequest request, ServerCallContext context)
public override async Task<GetAccountStatusBatchResponse> GetAccountStatusBatch(GetAccountBatchRequest request,
ServerCallContext context)
{
var accountIds = request.Id
.Select(id => Guid.TryParse(id, out var accountId) ? accountId : (Guid?)null)

View File

@@ -1,11 +1,20 @@
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto;
using Grpc.Core;
using NodaTime;
using Microsoft.EntityFrameworkCore;
using NodaTime.Serialization.Protobuf;
using ApiKey = DysonNetwork.Shared.Proto.ApiKey;
using AuthService = DysonNetwork.Pass.Auth.AuthService;
namespace DysonNetwork.Pass.Account;
public class BotAccountReceiverGrpc(AppDatabase db, AccountService accounts)
public class BotAccountReceiverGrpc(
AppDatabase db,
AccountService accounts,
FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs,
AuthService authService
)
: BotAccountReceiverService.BotAccountReceiverServiceBase
{
public override async Task<CreateBotAccountResponse> CreateBotAccount(
@@ -14,7 +23,12 @@ public class BotAccountReceiverGrpc(AppDatabase db, AccountService accounts)
)
{
var account = Account.FromProtoValue(request.Account);
account = await accounts.CreateBotAccount(account, Guid.Parse(request.AutomatedId));
account = await accounts.CreateBotAccount(
account,
Guid.Parse(request.AutomatedId),
request.PictureId,
request.BackgroundId
);
return new CreateBotAccountResponse
{
@@ -34,15 +48,43 @@ public class BotAccountReceiverGrpc(AppDatabase db, AccountService accounts)
ServerCallContext context
)
{
var automatedId = Guid.Parse(request.AutomatedId);
var account = await accounts.GetBotAccount(automatedId);
if (account is null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found"));
var account = Account.FromProtoValue(request.Account);
account.Name = request.Account.Name;
account.Nick = request.Account.Nick;
account.Profile = AccountProfile.FromProtoValue(request.Account.Profile);
account.Language = request.Account.Language;
if (request.PictureId is not null)
{
var file = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId });
if (account.Profile.Picture is not null)
await fileRefs.DeleteResourceReferencesAsync(
new DeleteResourceReferencesRequest { ResourceId = account.Profile.ResourceIdentifier }
);
await fileRefs.CreateReferenceAsync(
new CreateReferenceRequest
{
ResourceId = account.Profile.ResourceIdentifier,
FileId = request.PictureId,
Usage = "profile.picture"
}
);
account.Profile.Picture = CloudFileReferenceObject.FromProtoValue(file);
}
if (request.BackgroundId is not null)
{
var file = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId });
if (account.Profile.Background is not null)
await fileRefs.DeleteResourceReferencesAsync(
new DeleteResourceReferencesRequest { ResourceId = account.Profile.ResourceIdentifier }
);
await fileRefs.CreateReferenceAsync(
new CreateReferenceRequest
{
ResourceId = account.Profile.ResourceIdentifier,
FileId = request.BackgroundId,
Usage = "profile.background"
}
);
account.Profile.Background = CloudFileReferenceObject.FromProtoValue(file);
}
db.Accounts.Update(account);
await db.SaveChangesAsync();
@@ -68,10 +110,109 @@ public class BotAccountReceiverGrpc(AppDatabase db, AccountService accounts)
var automatedId = Guid.Parse(request.AutomatedId);
var account = await accounts.GetBotAccount(automatedId);
if (account is null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found"));
throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.NotFound, "Account not found"));
await accounts.DeleteAccount(account);
return new DeleteBotAccountResponse();
}
public override async Task<ApiKey> GetApiKey(GetApiKeyRequest request, ServerCallContext context)
{
var keyId = Guid.Parse(request.Id);
var key = await db.ApiKeys
.Include(k => k.Account)
.FirstOrDefaultAsync(k => k.Id == keyId);
if (key == null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
return key.ToProtoValue();
}
public override async Task<GetApiKeyBatchResponse> ListApiKey(ListApiKeyRequest request, ServerCallContext context)
{
var automatedId = Guid.Parse(request.AutomatedId);
var account = await accounts.GetBotAccount(automatedId);
if (account == null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found"));
var keys = await db.ApiKeys
.Where(k => k.AccountId == account.Id)
.Select(k => k.ToProtoValue())
.ToListAsync();
var response = new GetApiKeyBatchResponse();
response.Data.AddRange(keys);
return response;
}
public override async Task<ApiKey> CreateApiKey(ApiKey request, ServerCallContext context)
{
var accountId = Guid.Parse(request.AccountId);
var account = await accounts.GetBotAccount(accountId);
if (account == null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found"));
if (string.IsNullOrWhiteSpace(request.Label))
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Label is required"));
var key = await authService.CreateApiKey(account.Id, request.Label, null);
key.Key = await authService.IssueApiKeyToken(key);
return key.ToProtoValue();
}
public override async Task<ApiKey> UpdateApiKey(ApiKey request, ServerCallContext context)
{
var keyId = Guid.Parse(request.Id);
var accountId = Guid.Parse(request.AccountId);
var key = await db.ApiKeys
.Include(k => k.Session)
.Where(k => k.Id == keyId && k.AccountId == accountId)
.FirstOrDefaultAsync();
if (key == null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
// Only update the label if provided
if (string.IsNullOrWhiteSpace(request.Label)) return key.ToProtoValue();
key.Label = request.Label;
db.ApiKeys.Update(key);
await db.SaveChangesAsync();
return key.ToProtoValue();
}
public override async Task<ApiKey> RotateApiKey(GetApiKeyRequest request, ServerCallContext context)
{
var keyId = Guid.Parse(request.Id);
var key = await db.ApiKeys
.Include(k => k.Session)
.FirstOrDefaultAsync(k => k.Id == keyId);
if (key == null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
key = await authService.RotateApiKeyToken(key);
key.Key = await authService.IssueApiKeyToken(key);
return key.ToProtoValue();
}
public override async Task<DeleteApiKeyResponse> DeleteApiKey(GetApiKeyRequest request, ServerCallContext context)
{
var keyId = Guid.Parse(request.Id);
var key = await db.ApiKeys
.Include(k => k.Session)
.FirstOrDefaultAsync(k => k.Id == keyId);
if (key == null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
await authService.RevokeApiKeyToken(key);
return new DeleteApiKeyResponse { Success = true };
}
}

View File

@@ -2,6 +2,8 @@ using System.Linq.Expressions;
using System.Reflection;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Leveling;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Data;
@@ -38,6 +40,7 @@ public class AppDatabase(
public DbSet<AuthSession> AuthSessions { get; set; } = null!;
public DbSet<AuthChallenge> AuthChallenges { get; set; } = null!;
public DbSet<AuthClient> AuthClients { get; set; } = null!;
public DbSet<ApiKey> ApiKeys { get; set; } = null!;
public DbSet<Wallet.Wallet> Wallets { get; set; } = null!;
public DbSet<WalletPocket> WalletPockets { get; set; } = null!;
@@ -48,6 +51,9 @@ public class AppDatabase(
public DbSet<Punishment> Punishments { get; set; } = null!;
public DbSet<SocialCreditRecord> SocialCreditRecords { get; set; } = null!;
public DbSet<ExperienceRecord> ExperienceRecords { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseNpgsql(

View File

@@ -0,0 +1,50 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Pass.Auth;
public class ApiKey : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Label { get; set; } = null!;
public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!;
public Guid SessionId { get; set; }
public AuthSession Session { get; set; } = null!;
[NotMapped]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Key { get; set; }
public DysonNetwork.Shared.Proto.ApiKey ToProtoValue()
{
return new DysonNetwork.Shared.Proto.ApiKey
{
Id = Id.ToString(),
Label = Label,
AccountId = AccountId.ToString(),
SessionId = SessionId.ToString(),
Key = Key,
CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp()
};
}
public static ApiKey FromProtoValue(DysonNetwork.Shared.Proto.ApiKey proto)
{
return new ApiKey
{
Id = Guid.Parse(proto.Id),
AccountId = Guid.Parse(proto.AccountId),
SessionId = Guid.Parse(proto.SessionId),
Label = proto.Label,
Key = proto.Key,
CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant()
};
}
}

View File

@@ -0,0 +1,90 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Auth;
[ApiController]
[Route("/api/auth/keys")]
public class ApiKeyController(AppDatabase db, AuthService auth) : ControllerBase
{
[HttpGet]
[Authorize]
public async Task<IActionResult> GetKeys([FromQuery] int offset = 0, [FromQuery] int take = 20)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var query = db.ApiKeys
.Where(e => e.AccountId == currentUser.Id)
.AsQueryable();
var totalCount = await query.CountAsync();
Response.Headers["X-Total"] = totalCount.ToString();
var keys = await query
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(keys);
}
[HttpGet("{id:guid}")]
[Authorize]
public async Task<IActionResult> GetKey(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var key = await db.ApiKeys
.Where(e => e.AccountId == currentUser.Id)
.Where(e => e.Id == id)
.FirstOrDefaultAsync();
if (key == null) return NotFound();
return Ok(key);
}
public class ApiKeyRequest
{
[MaxLength(1024)] public string? Label { get; set; }
public Instant? ExpiredAt { get; set; }
}
[HttpPost]
[Authorize]
public async Task<IActionResult> CreateKey([FromBody] ApiKeyRequest request)
{
if (string.IsNullOrWhiteSpace(request.Label))
return BadRequest("Label is required");
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var key = await auth.CreateApiKey(currentUser.Id, request.Label, request.ExpiredAt);
key.Key = await auth.IssueApiKeyToken(key);
return Ok(key);
}
[HttpPost("{id:guid}/rotate")]
[Authorize]
public async Task<IActionResult> RotateKey(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var key = await auth.GetApiKey(id, currentUser.Id);
if(key is null) return NotFound();
key = await auth.RotateApiKeyToken(key);
key.Key = await auth.IssueApiKeyToken(key);
return Ok(key);
}
[HttpDelete("{id:guid}")]
[Authorize]
public async Task<IActionResult> DeleteKey(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var key = await auth.GetApiKey(id, currentUser.Id);
if(key is null) return NotFound();
await auth.RevokeApiKeyToken(key);
return NoContent();
}
}

View File

@@ -49,7 +49,10 @@ public class DysonTokenAuthHandler(
try
{
var (valid, session, message) = await token.AuthenticateTokenAsync(tokenInfo.Token);
// Get client IP address
var ipAddress = Context.Connection.RemoteIpAddress?.ToString();
var (valid, session, message) = await token.AuthenticateTokenAsync(tokenInfo.Token, ipAddress);
if (!valid || session is null)
return AuthenticateResult.Fail(message ?? "Authentication failed.");
@@ -67,7 +70,7 @@ public class DysonTokenAuthHandler(
};
// Add scopes as claims
session.Challenge.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
session.Challenge?.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
// Add superuser claim if applicable
if (session.Account.IsSuperuser)

View File

@@ -51,7 +51,11 @@ public class AuthController(
.Where(e => e.Type == PunishmentType.BlockLogin || e.Type == PunishmentType.DisableAccount)
.Where(e => e.ExpiredAt == null || now < e.ExpiredAt)
.FirstOrDefaultAsync();
if (punishment is not null) return StatusCode(423, punishment);
if (punishment is not null)
return StatusCode(
423,
$"Your account has been suspended. Reason: {punishment.Reason}. Expired at: {punishment.ExpiredAt?.ToString() ?? "never"}"
);
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();

View File

@@ -52,7 +52,7 @@ public class AuthService(
riskScore += 1;
else
{
if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge.IpAddress) &&
if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge?.IpAddress) &&
!lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase))
riskScore += 1;
}
@@ -318,6 +318,87 @@ public class AuthService(
return factor.VerifyPassword(pinCode);
}
public async Task<ApiKey?> GetApiKey(Guid id, Guid? accountId = null)
{
var key = await db.ApiKeys
.Include(e => e.Session)
.Where(e => e.Id == id)
.If(accountId.HasValue, q => q.Where(e => e.AccountId == accountId!.Value))
.FirstOrDefaultAsync();
return key;
}
public async Task<ApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null)
{
var key = new ApiKey
{
AccountId = accountId,
Label = label,
Session = new AuthSession
{
AccountId = accountId,
ExpiredAt = expiredAt
},
};
db.ApiKeys.Add(key);
await db.SaveChangesAsync();
return key;
}
public async Task<string> IssueApiKeyToken(ApiKey key)
{
key.Session.LastGrantedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(key.Session);
await db.SaveChangesAsync();
var tk = CreateToken(key.Session);
return tk;
}
public async Task RevokeApiKeyToken(ApiKey key)
{
db.Remove(key);
db.Remove(key.Session);
await db.SaveChangesAsync();
}
public async Task<ApiKey> RotateApiKeyToken(ApiKey key)
{
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
var oldSessionId = key.SessionId;
// Create new session
var newSession = new AuthSession
{
AccountId = key.AccountId,
ExpiredAt = key.Session?.ExpiredAt
};
db.AuthSessions.Add(newSession);
await db.SaveChangesAsync();
// Update ApiKey to point to new session
key.SessionId = newSession.Id;
key.Session = newSession;
db.ApiKeys.Update(key);
await db.SaveChangesAsync();
// Delete old session
await db.AuthSessions.Where(s => s.Id == oldSessionId).ExecuteDeleteAsync();
await transaction.CommitAsync();
return key;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
// Helper methods for Base64Url encoding/decoding
private static string Base64UrlEncode(byte[] data)
{
@@ -329,7 +410,7 @@ public class AuthService(
private static byte[] Base64UrlDecode(string base64Url)
{
string padded = base64Url
var padded = base64Url
.Replace('-', '+')
.Replace('_', '/');

View File

@@ -1,9 +1,5 @@
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Auth;
@@ -18,7 +14,7 @@ public class AuthServiceGrpc(
ServerCallContext context
)
{
var (valid, session, message) = await token.AuthenticateTokenAsync(request.Token);
var (valid, session, message) = await token.AuthenticateTokenAsync(request.Token, request.IpAddress);
if (!valid || session is null)
return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." };

View File

@@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using NodaTime.Serialization.Protobuf;
using Point = NetTopologySuite.Geometries.Point;
@@ -12,26 +11,28 @@ namespace DysonNetwork.Pass.Auth;
public class AuthSession : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string? Label { get; set; }
public Instant? LastGrantedAt { get; set; }
public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
public Guid ChallengeId { get; set; }
public AuthChallenge Challenge { get; set; } = null!;
// When the challenge is null, indicates the session is for an API Key
public Guid? ChallengeId { get; set; }
public AuthChallenge? Challenge { get; set; } = null!;
// Indicates the session is for an OIDC connection
public Guid? AppId { get; set; }
public Shared.Proto.AuthSession ToProtoValue() => new()
{
Id = Id.ToString(),
Label = Label,
LastGrantedAt = LastGrantedAt?.ToTimestamp(),
ExpiredAt = ExpiredAt?.ToTimestamp(),
AccountId = AccountId.ToString(),
Account = Account.ToProtoValue(),
ChallengeId = ChallengeId.ToString(),
Challenge = Challenge.ToProtoValue(),
Challenge = Challenge?.ToProtoValue(),
AppId = AppId?.ToString()
};
}

View File

@@ -5,8 +5,10 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using System.Text.Json.Serialization;
using System.Web;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Auth.OidcProvider.Options;
using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
@@ -19,10 +21,199 @@ public class OidcProviderController(
AppDatabase db,
OidcProviderService oidcService,
IConfiguration configuration,
IOptions<OidcProviderOptions> options
)
: ControllerBase
IOptions<OidcProviderOptions> options,
ILogger<OidcProviderController> logger
) : ControllerBase
{
[HttpGet("authorize")]
[Produces("application/json")]
public async Task<IActionResult> Authorize(
[FromQuery(Name = "client_id")] string clientId,
[FromQuery(Name = "response_type")] string responseType,
[FromQuery(Name = "redirect_uri")] string? redirectUri = null,
[FromQuery] string? scope = null,
[FromQuery] string? state = null,
[FromQuery(Name = "response_mode")] string? responseMode = null,
[FromQuery] string? nonce = null,
[FromQuery] string? display = null,
[FromQuery] string? prompt = null,
[FromQuery(Name = "code_challenge")] string? codeChallenge = null,
[FromQuery(Name = "code_challenge_method")]
string? codeChallengeMethod = null)
{
if (string.IsNullOrEmpty(clientId))
{
return BadRequest(new ErrorResponse
{
Error = "invalid_request",
ErrorDescription = "client_id is required"
});
}
var client = await oidcService.FindClientBySlugAsync(clientId);
if (client == null)
{
return BadRequest(new ErrorResponse
{
Error = "unauthorized_client",
ErrorDescription = "Client not found"
});
}
// Validate response_type
if (string.IsNullOrEmpty(responseType))
{
return BadRequest(new ErrorResponse
{
Error = "invalid_request",
ErrorDescription = "response_type is required"
});
}
// Check if the client is allowed to use the requested response type
var allowedResponseTypes = new[] { "code", "token", "id_token" };
var requestedResponseTypes = responseType.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (requestedResponseTypes.Any(rt => !allowedResponseTypes.Contains(rt)))
{
return BadRequest(new ErrorResponse
{
Error = "unsupported_response_type",
ErrorDescription = "The requested response type is not supported"
});
}
// Validate redirect_uri if provided
if (!string.IsNullOrEmpty(redirectUri) &&
!await oidcService.ValidateRedirectUriAsync(Guid.Parse(client.Id), redirectUri))
{
return BadRequest(new ErrorResponse
{
Error = "invalid_request",
ErrorDescription = "Invalid redirect_uri"
});
}
// Return client information
var clientInfo = new ClientInfoResponse
{
ClientId = Guid.Parse(client.Id),
Picture = client.Picture is not null ? CloudFileReferenceObject.FromProtoValue(client.Picture) : null,
Background = client.Background is not null
? CloudFileReferenceObject.FromProtoValue(client.Background)
: null,
ClientName = client.Name,
HomeUri = client.Links.HomePage,
PolicyUri = client.Links.PrivacyPolicy,
TermsOfServiceUri = client.Links.TermsOfService,
ResponseTypes = responseType,
Scopes = scope?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? [],
State = state,
Nonce = nonce,
CodeChallenge = codeChallenge,
CodeChallengeMethod = codeChallengeMethod
};
return Ok(clientInfo);
}
[HttpPost("authorize")]
[Consumes("application/x-www-form-urlencoded")]
[Authorize]
public async Task<IActionResult> HandleAuthorizationResponse(
[FromForm(Name = "authorize")] string? authorize,
[FromForm(Name = "client_id")] string clientId,
[FromForm(Name = "redirect_uri")] string? redirectUri = null,
[FromForm] string? scope = null,
[FromForm] string? state = null,
[FromForm] string? nonce = null,
[FromForm(Name = "code_challenge")] string? codeChallenge = null,
[FromForm(Name = "code_challenge_method")]
string? codeChallengeMethod = null)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account account)
return Unauthorized();
// Find the client
var client = await oidcService.FindClientBySlugAsync(clientId);
if (client == null)
{
return BadRequest(new ErrorResponse
{
Error = "unauthorized_client",
ErrorDescription = "Client not found"
});
}
// If user denied the request
if (string.IsNullOrEmpty(authorize) || !bool.TryParse(authorize, out var isAuthorized) || !isAuthorized)
{
var errorUri = new UriBuilder(redirectUri ?? client.Links?.HomePage ?? "https://example.com");
var queryParams = HttpUtility.ParseQueryString(errorUri.Query);
queryParams["error"] = "access_denied";
queryParams["error_description"] = "The user denied the authorization request";
if (!string.IsNullOrEmpty(state)) queryParams["state"] = state;
errorUri.Query = queryParams.ToString();
return Ok(new { redirectUri = errorUri.Uri.ToString() });
}
// Validate redirect_uri if provided
if (!string.IsNullOrEmpty(redirectUri) &&
!await oidcService.ValidateRedirectUriAsync(Guid.Parse(client!.Id), redirectUri))
{
return BadRequest(new ErrorResponse
{
Error = "invalid_request",
ErrorDescription = "Invalid redirect_uri"
});
}
// Default to client's first redirect URI if not provided
redirectUri ??= client.OauthConfig?.RedirectUris?.FirstOrDefault();
if (string.IsNullOrEmpty(redirectUri))
{
return BadRequest(new ErrorResponse
{
Error = "invalid_request",
ErrorDescription = "No valid redirect_uri available"
});
}
try
{
// Generate authorization code and create session
var authorizationCode = await oidcService.GenerateAuthorizationCodeAsync(
Guid.Parse(client.Id),
account.Id,
redirectUri,
scope?.Split(' ') ?? [],
codeChallenge,
codeChallengeMethod,
nonce
);
// Build the redirect URI with the authorization code
var redirectBuilder = new UriBuilder(redirectUri);
var queryParams = HttpUtility.ParseQueryString(redirectBuilder.Query);
queryParams["code"] = authorizationCode;
if (!string.IsNullOrEmpty(state)) queryParams["state"] = state;
redirectBuilder.Query = queryParams.ToString();
return Ok(new { redirectUri = redirectBuilder.Uri.ToString() });
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing authorization request");
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse
{
Error = "server_error",
ErrorDescription = "An error occurred while processing your request"
});
}
}
[HttpPost("token")]
[Consumes("application/x-www-form-urlencoded")]
public async Task<IActionResult> Token([FromForm] TokenRequest request)
@@ -36,15 +227,15 @@ public class OidcProviderController(
return BadRequest("Authorization code is required");
case "authorization_code":
{
var client = await oidcService.FindClientByIdAsync(request.ClientId.Value);
var client = await oidcService.FindClientBySlugAsync(request.ClientId);
if (client == null ||
!await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret))
!await oidcService.ValidateClientCredentialsAsync(Guid.Parse(client.Id), request.ClientSecret))
return BadRequest(new ErrorResponse
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
// Generate tokens
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
clientId: request.ClientId.Value,
clientId: Guid.Parse(client.Id),
authorizationCode: request.Code!,
redirectUri: request.RedirectUri,
codeVerifier: request.CodeVerifier
@@ -116,7 +307,7 @@ public class OidcProviderController(
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
// Get requested scopes from the token
var scopes = currentSession.Challenge.Scopes;
var scopes = currentSession.Challenge?.Scopes ?? [];
var userInfo = new Dictionary<string, object>
{
@@ -150,10 +341,10 @@ public class OidcProviderController(
return Ok(new
{
issuer = issuer,
issuer,
authorization_endpoint = $"{baseUrl}/auth/authorize",
token_endpoint = $"{baseUrl}/auth/open/token",
userinfo_endpoint = $"{baseUrl}/auth/open/userinfo",
token_endpoint = $"{baseUrl}/api/auth/open/token",
userinfo_endpoint = $"{baseUrl}/api/auth/open/userinfo",
jwks_uri = $"{baseUrl}/.well-known/jwks",
scopes_supported = new[] { "openid", "profile", "email" },
response_types_supported = new[]
@@ -220,7 +411,7 @@ public class TokenRequest
[JsonPropertyName("client_id")]
[FromForm(Name = "client_id")]
public Guid? ClientId { get; set; }
public string? ClientId { get; set; }
[JsonPropertyName("client_secret")]
[FromForm(Name = "client_secret")]

View File

@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
namespace DysonNetwork.Pass.Auth.OidcProvider.Responses;
public class ClientInfoResponse
{
public Guid ClientId { get; set; }
public CloudFileReferenceObject? Picture { get; set; }
public CloudFileReferenceObject? Background { get; set; }
public string? ClientName { get; set; }
public string? HomeUri { get; set; }
public string? PolicyUri { get; set; }
public string? TermsOfServiceUri { get; set; }
public string? ResponseTypes { get; set; }
public string[]? Scopes { get; set; }
public string? State { get; set; }
public string? Nonce { get; set; }
public string? CodeChallenge { get; set; }
public string? CodeChallengeMethod { get; set; }
}

View File

@@ -20,7 +20,6 @@ public class TokenResponse
[JsonPropertyName("scope")]
public string? Scope { get; set; }
[JsonPropertyName("id_token")]
public string? IdToken { get; set; }
}

View File

@@ -11,6 +11,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
using AccountContactType = DysonNetwork.Pass.Account.AccountContactType;
namespace DysonNetwork.Pass.Auth.OidcProvider.Services;
@@ -31,15 +32,31 @@ public class OidcProviderService(
return resp.App ?? null;
}
public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId)
public async Task<CustomApp?> FindClientBySlugAsync(string slug)
{
var resp = await customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = slug });
return resp.App ?? null;
}
public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId, bool withAccount = false)
{
var now = SystemClock.Instance.GetCurrentInstant();
return await db.AuthSessions
var queryable = db.AuthSessions
.Include(s => s.Challenge)
.AsQueryable();
if (withAccount)
queryable = queryable
.Include(s => s.Account)
.ThenInclude(a => a.Profile)
.Include(a => a.Account.Contacts)
.AsQueryable();
return await queryable
.Where(s => s.AccountId == accountId &&
s.AppId == clientId &&
(s.ExpiredAt == null || s.ExpiredAt > now) &&
s.Challenge != null &&
s.Challenge.Type == ChallengeType.OAuth)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
@@ -56,6 +73,149 @@ public class OidcProviderService(
return resp.Valid;
}
public async Task<bool> ValidateRedirectUriAsync(Guid clientId, string redirectUri)
{
if (string.IsNullOrEmpty(redirectUri))
return false;
var client = await FindClientByIdAsync(clientId);
if (client?.Status != CustomAppStatus.Production)
return true;
if (client?.OauthConfig?.RedirectUris == null)
return false;
// Check if the redirect URI matches any of the allowed URIs
// For exact match
if (client.OauthConfig.RedirectUris.Contains(redirectUri))
return true;
// Check for wildcard matches (e.g., https://*.example.com/*)
foreach (var allowedUri in client.OauthConfig.RedirectUris)
{
if (string.IsNullOrEmpty(allowedUri))
continue;
// Handle wildcard in domain
if (allowedUri.Contains("*.") && allowedUri.StartsWith("http"))
{
try
{
var allowedUriObj = new Uri(allowedUri);
var redirectUriObj = new Uri(redirectUri);
if (allowedUriObj.Scheme != redirectUriObj.Scheme ||
allowedUriObj.Port != redirectUriObj.Port)
{
continue;
}
// Check if the domain matches the wildcard pattern
var allowedDomain = allowedUriObj.Host;
var redirectDomain = redirectUriObj.Host;
if (allowedDomain.StartsWith("*."))
{
var baseDomain = allowedDomain[2..]; // Remove the "*." prefix
if (redirectDomain == baseDomain || redirectDomain.EndsWith($".{baseDomain}"))
{
// Check path
var allowedPath = allowedUriObj.AbsolutePath.TrimEnd('/');
var redirectPath = redirectUriObj.AbsolutePath.TrimEnd('/');
if (string.IsNullOrEmpty(allowedPath) ||
redirectPath.StartsWith(allowedPath, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
}
catch (UriFormatException)
{
// Invalid URI format in allowed URIs, skip
continue;
}
}
}
return false;
}
private string GenerateIdToken(
CustomApp client,
AuthSession session,
string? nonce = null,
IEnumerable<string>? scopes = null
)
{
var tokenHandler = new JwtSecurityTokenHandler();
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Iss, _options.IssuerUri),
new(JwtRegisteredClaimNames.Sub, session.AccountId.ToString()),
new(JwtRegisteredClaimNames.Aud, client.Slug),
new(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
new(JwtRegisteredClaimNames.Exp,
now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToUnixTimeSeconds()
.ToString(), ClaimValueTypes.Integer64),
new(JwtRegisteredClaimNames.AuthTime, session.CreatedAt.ToUnixTimeSeconds().ToString(),
ClaimValueTypes.Integer64),
};
// Add nonce if provided (required for implicit and hybrid flows)
if (!string.IsNullOrEmpty(nonce))
{
claims.Add(new Claim("nonce", nonce));
}
// Add email claim if email scope is requested
var scopesList = scopes?.ToList() ?? [];
if (scopesList.Contains("email"))
{
var contact = session.Account.Contacts.FirstOrDefault(c => c.Type == AccountContactType.Email);
if (contact is not null)
{
claims.Add(new Claim(JwtRegisteredClaimNames.Email, contact.Content));
claims.Add(new Claim("email_verified", contact.VerifiedAt is not null ? "true" : "false",
ClaimValueTypes.Boolean));
}
}
// Add profile claims if profile scope is requested
if (scopes != null && scopesList.Contains("profile"))
{
if (!string.IsNullOrEmpty(session.Account.Name))
claims.Add(new Claim("preferred_username", session.Account.Name));
if (!string.IsNullOrEmpty(session.Account.Nick))
claims.Add(new Claim("name", session.Account.Nick));
if (!string.IsNullOrEmpty(session.Account.Profile.FirstName))
claims.Add(new Claim("given_name", session.Account.Profile.FirstName));
if (!string.IsNullOrEmpty(session.Account.Profile.LastName))
claims.Add(new Claim("family_name", session.Account.Profile.LastName));
}
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Issuer = _options.IssuerUri,
Audience = client.Id.ToString(),
Expires = now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToDateTimeUtc(),
NotBefore = now.ToDateTimeUtc(),
SigningCredentials = new SigningCredentials(
new RsaSecurityKey(_options.GetRsaPrivateKey()),
SecurityAlgorithms.RsaSha256
)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
public async Task<TokenResponse> GenerateTokenResponseAsync(
Guid clientId,
string? authorizationCode = null,
@@ -71,24 +231,43 @@ public class OidcProviderService(
AuthSession session;
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
string? nonce = null;
List<string>? scopes = null;
if (authorizationCode != null)
{
// Authorization code flow
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
if (authCode is null) throw new InvalidOperationException("Invalid authorization code");
var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync();
if (account is null) throw new InvalidOperationException("Account was not found");
if (authCode == null)
throw new InvalidOperationException("Invalid authorization code");
// Load the session for the user
var existingSession = await FindValidSessionAsync(authCode.AccountId, clientId, withAccount: true);
if (existingSession is null)
{
var account = await db.Accounts
.Where(a => a.Id == authCode.AccountId)
.Include(a => a.Profile)
.Include(a => a.Contacts)
.FirstOrDefaultAsync();
if (account is null) throw new InvalidOperationException("Account not found");
session = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant(), clientId);
session.Account = account;
}
else
{
session = existingSession;
}
session = await auth.CreateSessionForOidcAsync(account, now, clientId);
scopes = authCode.Scopes;
nonce = authCode.Nonce;
}
else if (sessionId.HasValue)
{
// Refresh token flow
session = await FindSessionByIdAsync(sessionId.Value) ??
throw new InvalidOperationException("Invalid session");
throw new InvalidOperationException("Session not found");
// Verify the session is still valid
if (session.ExpiredAt < now)
@@ -102,13 +281,15 @@ public class OidcProviderService(
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
// Generate an access token
// Generate tokens
var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
var idToken = GenerateIdToken(client, session, nonce, scopes);
var refreshToken = GenerateRefreshToken(session);
return new TokenResponse
{
AccessToken = accessToken,
IdToken = idToken,
ExpiresIn = expiresIn,
TokenType = "Bearer",
RefreshToken = refreshToken,
@@ -134,11 +315,10 @@ public class OidcProviderService(
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
ClaimValueTypes.Integer64),
new Claim("client_id", client.Id)
]),
Expires = expiresAt.ToDateTimeUtc(),
Issuer = _options.IssuerUri,
Audience = client.Id
Audience = client.Slug
};
// Try to use RSA signing if keys are available, fall back to HMAC
@@ -204,51 +384,6 @@ public class OidcProviderService(
return Convert.ToBase64String(session.Id.ToByteArray());
}
private static bool VerifyHashedSecret(string secret, string hashedSecret)
{
// In a real implementation, you'd use a proper password hashing algorithm like PBKDF2, bcrypt, or Argon2
// For now, we'll do a simple comparison, but you should replace this with proper hashing
return string.Equals(secret, hashedSecret, StringComparison.Ordinal);
}
public async Task<string> GenerateAuthorizationCodeForReuseSessionAsync(
AuthSession session,
Guid clientId,
string redirectUri,
IEnumerable<string> scopes,
string? codeChallenge = null,
string? codeChallengeMethod = null,
string? nonce = null)
{
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
var code = Guid.NewGuid().ToString("N");
// Update the session's last activity time
await db.AuthSessions.Where(s => s.Id == session.Id)
.ExecuteUpdateAsync(s => s.SetProperty(s => s.LastGrantedAt, now));
// Create the authorization code info
var authCodeInfo = new AuthorizationCodeInfo
{
ClientId = clientId,
AccountId = session.AccountId,
RedirectUri = redirectUri,
Scopes = scopes.ToList(),
CodeChallenge = codeChallenge,
CodeChallengeMethod = codeChallengeMethod,
Nonce = nonce,
CreatedAt = now
};
// Store the code with its metadata in the cache
var cacheKey = $"auth:code:{code}";
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, session.AccountId);
return code;
}
public async Task<string> GenerateAuthorizationCodeAsync(
Guid clientId,
Guid userId,
@@ -278,7 +413,7 @@ public class OidcProviderService(
};
// Store the code with its metadata in the cache
var cacheKey = $"auth:code:{code}";
var cacheKey = $"auth:oidc-code:{code}";
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
@@ -292,7 +427,7 @@ public class OidcProviderService(
string? codeVerifier = null
)
{
var cacheKey = $"auth:code:{code}";
var cacheKey = $"auth:oidc-code:{code}";
var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey);
if (!found || authCode == null)

View File

@@ -1,3 +1,4 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Text;
using DysonNetwork.Pass.Wallet;
@@ -22,8 +23,9 @@ public class TokenAuthService(
/// then cache and return.
/// </summary>
/// <param name="token">Incoming token string</param>
/// <param name="ipAddress">Client IP address, for logging purposes</param>
/// <returns>(Valid, Session, Message)</returns>
public async Task<(bool Valid, AuthSession? Session, string? Message)> AuthenticateTokenAsync(string token)
public async Task<(bool Valid, AuthSession? Session, string? Message)> AuthenticateTokenAsync(string token, string? ipAddress = null)
{
try
{
@@ -33,6 +35,11 @@ public class TokenAuthService(
return (false, null, "No token provided.");
}
if (!string.IsNullOrEmpty(ipAddress))
{
logger.LogDebug("AuthenticateTokenAsync: client IP: {IpAddress}", ipAddress);
}
// token fingerprint for correlation
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
var tokenFp = tokenHash[..8];
@@ -70,7 +77,7 @@ public class TokenAuthService(
"AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})",
sessionId,
session.AccountId,
session.Challenge.Scopes.Count,
session.Challenge?.Scopes.Count,
session.ExpiredAt
);
return (true, session, null);
@@ -103,11 +110,11 @@ public class TokenAuthService(
"AuthenticateTokenAsync: DB session loaded (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId}, appId={AppId}, scopes={ScopeCount}, ip={Ip}, uaLen={UaLen})",
sessionId,
session.AccountId,
session.Challenge.ClientId,
session.Challenge?.ClientId,
session.AppId,
session.Challenge.Scopes.Count,
session.Challenge.IpAddress,
(session.Challenge.UserAgent ?? string.Empty).Length
session.Challenge?.Scopes.Count,
session.Challenge?.IpAddress,
(session.Challenge?.UserAgent ?? string.Empty).Length
);
logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId);
@@ -136,7 +143,7 @@ public class TokenAuthService(
"AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})",
sessionId,
session.AccountId,
session.Challenge.ClientId
session.Challenge?.ClientId
);
return (true, session, null);
}

View File

@@ -60,6 +60,12 @@ const router = createRouter({
name: 'authCallback',
component: () => import('../views/callback.vue'),
},
{
path: '/auth/authorize',
name: 'authAuthorize',
component: () => import('../views/authorize.vue'),
meta: { requiresAuth: true },
},
{
path: '/:notFound(.*)',
name: 'errorNotFound',

View File

@@ -0,0 +1,191 @@
<template>
<div class="flex items-center justify-center h-full p-4">
<n-card class="w-full max-w-md" title="Authorize Application">
<n-spin :show="isLoading">
<div v-if="error" class="mb-4">
<n-alert type="error" :title="error" closable @close="error = null" />
</div>
<!-- App Info Section -->
<div v-if="clientInfo" class="mb-6">
<div class="flex items-center">
<n-avatar
v-if="clientInfo.picture"
:src="clientInfo.picture.url"
:alt="clientInfo.client_name"
size="large"
class="mr-3"
/>
<div>
<h2 class="text-xl font-semibold">
{{ clientInfo.client_name || 'Unknown Application' }}
</h2>
<span v-if="isNewApp">wants to access your Solar Network account</span>
<span v-else>wants to access your account</span>
</div>
</div>
<!-- Requested Permissions -->
<n-card size="small" class="mt-4">
<h3 class="font-medium mb-2">
This will allow {{ clientInfo.client_name || 'the app' }} to:
</h3>
<ul class="space-y-1">
<li v-for="scope in requestedScopes" :key="scope" class="flex items-start">
<n-icon :component="CheckBoxFilled" class="mt-1 mr-2" />
<span>{{ scope }}</span>
</li>
</ul>
</n-card>
<!-- Buttons -->
<div class="flex gap-3 mt-4">
<n-button
type="primary"
:loading="isAuthorizing"
@click="handleAuthorize"
class="flex-grow-1 w-1/2"
>
Authorize
</n-button>
<n-button
type="tertiary"
:disabled="isAuthorizing"
@click="handleDeny"
class="flex-grow-1 w-1/2"
>
Deny
</n-button>
</div>
<div class="mt-4 text-xs text-gray-500 text-center">
By authorizing, you agree to the
<n-button text type="primary" size="tiny" @click="openTerms" class="px-1">
Terms of Service
</n-button>
and
<n-button text type="primary" size="tiny" @click="openPrivacy" class="px-1">
Privacy Policy
</n-button>
</div>
</div>
</n-spin>
</n-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { NCard, NButton, NSpin, NAlert, NAvatar, NIcon } from 'naive-ui'
import { CheckBoxFilled } from '@vicons/material'
const route = useRoute()
// State
const isLoading = ref(true)
const isAuthorizing = ref(false)
const error = ref<string | null>(null)
const clientInfo = ref<{
client_name?: string
home_uri?: string
picture?: { url: string }
terms_of_service_uri?: string
privacy_policy_uri?: string
scopes?: string[]
} | null>(null)
const isNewApp = ref(false)
// Computed properties
const requestedScopes = computed(() => {
return clientInfo.value?.scopes || []
})
// Methods
async function fetchClientInfo() {
try {
const response = await fetch(`/api/auth/open/authorize?${window.location.search.slice(1)}`)
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error_description || 'Failed to load authorization request')
}
clientInfo.value = await response.json()
checkIfNewApp()
} catch (err: any) {
error.value = err.message || 'An error occurred while loading the authorization request'
} finally {
isLoading.value = false
}
}
function checkIfNewApp() {
// In a real app, you might want to check if this is the first time authorizing this app
// For now, we'll just set it to false
isNewApp.value = false
}
async function handleAuthorize() {
isAuthorizing.value = true
try {
// In a real implementation, you would submit the authorization
const response = await fetch('/api/auth/open/authorize', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
...route.query,
authorize: 'true',
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error_description || 'Authorization failed')
}
const data = await response.json()
if (data.redirect_uri) {
window.open(data.redirect_uri, '_self')
}
} catch (err: any) {
error.value = err.message || 'An error occurred during authorization'
} finally {
isAuthorizing.value = false
}
}
function handleDeny() {
// Redirect back to the client with an error
// Ensure redirect_uri is always a string (not an array)
const redirectUriStr = Array.isArray(route.query.redirect_uri)
? route.query.redirect_uri[0] || clientInfo.value?.home_uri || '/'
: route.query.redirect_uri || clientInfo.value?.home_uri || '/'
const redirectUri = new URL(redirectUriStr)
// Ensure state is always a string (not an array)
const state = Array.isArray(route.query.state)
? route.query.state[0] || ''
: route.query.state || ''
const params = new URLSearchParams({
error: 'access_denied',
error_description: 'The user denied the authorization request',
state: state,
})
window.open(`${redirectUri}?${params}`, "_self")
}
function openTerms() {
window.open(clientInfo.value?.terms_of_service_uri || '#', "_blank")
}
function openPrivacy() {
window.open(clientInfo.value?.privacy_policy_uri || '#', "_blank")
}
// Lifecycle
onMounted(() => {
fetchClientInfo()
})
</script>
<style scoped>
/* Add any custom styles here */
</style>

View File

@@ -0,0 +1,34 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Data;
using NodaTime;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Pass.Credit;
public class SocialCreditRecord : ModelBase
{
public Guid Id { get; set; }
[MaxLength(1024)] public string ReasonType { get; set; } = string.Empty;
[MaxLength(1024)] public string Reason { get; set; } = string.Empty;
public double Delta { get; set; }
public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!;
public Shared.Proto.SocialCreditRecord ToProto()
{
var proto = new Shared.Proto.SocialCreditRecord
{
Id = Id.ToString(),
ReasonType = ReasonType,
Reason = Reason,
Delta = Delta,
AccountId = AccountId.ToString(),
CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp()
};
return proto;
}
}

View File

@@ -0,0 +1,46 @@
using DysonNetwork.Shared.Cache;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Pass.Credit;
public class SocialCreditService(AppDatabase db, ICacheService cache)
{
private const string CacheKeyPrefix = "account:credits:";
public async Task<SocialCreditRecord> AddRecord(string reasonType, string reason, double delta, Guid accountId)
{
var record = new SocialCreditRecord
{
ReasonType = reasonType,
Reason = reason,
Delta = delta,
AccountId = accountId,
};
db.SocialCreditRecords.Add(record);
await db.SaveChangesAsync();
await db.AccountProfiles
.Where(p => p.AccountId == accountId)
.ExecuteUpdateAsync(p => p.SetProperty(v => v.SocialCredits, v => v.SocialCredits + record.Delta));
await cache.RemoveAsync($"{CacheKeyPrefix}{accountId}");
return record;
}
private const double BaseSocialCredit = 100;
public async Task<double> GetSocialCredit(Guid accountId)
{
var cached = await cache.GetAsync<double?>($"{CacheKeyPrefix}{accountId}");
if (cached.HasValue) return cached.Value;
var records = await db.SocialCreditRecords
.Where(x => x.AccountId == accountId)
.SumAsync(x => x.Delta);
records += BaseSocialCredit;
await cache.SetAsync($"{CacheKeyPrefix}{accountId}", records);
return records;
}
}

View File

@@ -0,0 +1,27 @@
using DysonNetwork.Shared.Proto;
using Grpc.Core;
namespace DysonNetwork.Pass.Credit;
public class SocialCreditServiceGrpc(SocialCreditService creditService) : Shared.Proto.SocialCreditService.SocialCreditServiceBase
{
public override async Task<Shared.Proto.SocialCreditRecord> AddRecord(AddSocialCreditRecordRequest request, ServerCallContext context)
{
var accountId = Guid.Parse(request.AccountId);
var record = await creditService.AddRecord(
request.ReasonType,
request.Reason,
request.Delta,
accountId);
return record.ToProto();
}
public override async Task<SocialCreditResponse> GetSocialCredit(GetSocialCreditRequest request, ServerCallContext context)
{
var accountId = Guid.Parse(request.AccountId);
var amount = await creditService.GetSocialCredit(accountId);
return new SocialCreditResponse { Amount = amount };
}
}

View File

@@ -0,0 +1,34 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Data;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Pass.Leveling;
public class ExperienceRecord : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string ReasonType { get; set; } = string.Empty;
[MaxLength(1024)] public string Reason { get; set; } = string.Empty;
public long Delta { get; set; }
public double BonusMultiplier { get; set; }
public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!;
public Shared.Proto.ExperienceRecord ToProto()
{
var proto = new Shared.Proto.ExperienceRecord
{
Id = Id.ToString(),
ReasonType = ReasonType,
Reason = Reason,
Delta = Delta,
BonusMultiplier = BonusMultiplier,
AccountId = AccountId.ToString(),
CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp()
};
return proto;
}
}

View File

@@ -0,0 +1,42 @@
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Cache;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Pass.Leveling;
public class ExperienceService(AppDatabase db, SubscriptionService subscriptions, ICacheService cache)
{
public async Task<ExperienceRecord> AddRecord(string reasonType, string reason, long delta, Guid accountId)
{
var record = new ExperienceRecord
{
ReasonType = reasonType,
Reason = reason,
Delta = delta,
AccountId = accountId,
};
var perkSubscription = await subscriptions.GetPerkSubscriptionAsync(accountId);
if (perkSubscription is not null)
{
record.BonusMultiplier = perkSubscription.Identifier switch
{
SubscriptionType.Stellar => 1.5,
SubscriptionType.Nova => 2,
SubscriptionType.Supernova => 2,
_ => 1
};
if (record.Delta >= 0)
record.Delta = (long)Math.Floor(record.Delta * record.BonusMultiplier);
}
db.ExperienceRecords.Add(record);
await db.SaveChangesAsync();
await db.AccountProfiles
.Where(p => p.AccountId == accountId)
.ExecuteUpdateAsync(p => p.SetProperty(v => v.Experience, v => v.Experience + record.Delta));
return record;
}
}

View File

@@ -0,0 +1,19 @@
using DysonNetwork.Shared.Proto;
using Grpc.Core;
namespace DysonNetwork.Pass.Leveling;
public class ExperienceServiceGrpc(ExperienceService experienceService) : Shared.Proto.ExperienceService.ExperienceServiceBase
{
public override async Task<Shared.Proto.ExperienceRecord> AddRecord(AddExperienceRecordRequest request, ServerCallContext context)
{
var accountId = Guid.Parse(request.AccountId);
var record = await experienceService.AddRecord(
request.ReasonType,
request.Reason,
request.Delta,
accountId);
return record.ToProto();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddBotAccount : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "automated_id",
table: "accounts",
type: "uuid",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "automated_id",
table: "accounts");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,114 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddApiKeys : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_auth_sessions_auth_challenges_challenge_id",
table: "auth_sessions");
migrationBuilder.DropColumn(
name: "label",
table: "auth_sessions");
migrationBuilder.AlterColumn<Guid>(
name: "challenge_id",
table: "auth_sessions",
type: "uuid",
nullable: true,
oldClrType: typeof(Guid),
oldType: "uuid");
migrationBuilder.CreateTable(
name: "api_keys",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
label = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
session_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_api_keys", x => x.id);
table.ForeignKey(
name: "fk_api_keys_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_api_keys_auth_sessions_session_id",
column: x => x.session_id,
principalTable: "auth_sessions",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_api_keys_account_id",
table: "api_keys",
column: "account_id");
migrationBuilder.CreateIndex(
name: "ix_api_keys_session_id",
table: "api_keys",
column: "session_id");
migrationBuilder.AddForeignKey(
name: "fk_auth_sessions_auth_challenges_challenge_id",
table: "auth_sessions",
column: "challenge_id",
principalTable: "auth_challenges",
principalColumn: "id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_auth_sessions_auth_challenges_challenge_id",
table: "auth_sessions");
migrationBuilder.DropTable(
name: "api_keys");
migrationBuilder.AlterColumn<Guid>(
name: "challenge_id",
table: "auth_sessions",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
oldClrType: typeof(Guid),
oldType: "uuid",
oldNullable: true);
migrationBuilder.AddColumn<string>(
name: "label",
table: "auth_sessions",
type: "character varying(1024)",
maxLength: 1024,
nullable: true);
migrationBuilder.AddForeignKey(
name: "fk_auth_sessions_auth_challenges_challenge_id",
table: "auth_sessions",
column: "challenge_id",
principalTable: "auth_challenges",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddCreditAndLevelingRecords : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "experience_records",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
reason_type = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
reason = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
delta = table.Column<long>(type: "bigint", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_experience_records", x => x.id);
table.ForeignKey(
name: "fk_experience_records_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "social_credit_records",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
reason_type = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
reason = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
delta = table.Column<double>(type: "double precision", nullable: false),
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_social_credit_records", x => x.id);
table.ForeignKey(
name: "fk_social_credit_records_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_experience_records_account_id",
table: "experience_records",
column: "account_id");
migrationBuilder.CreateIndex(
name: "ix_social_credit_records_account_id",
table: "social_credit_records",
column: "account_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "experience_records");
migrationBuilder.DropTable(
name: "social_credit_records");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddLevelingBonusMultiplier : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "bonus_multiplier",
table: "experience_records",
type: "double precision",
nullable: false,
defaultValue: 0.0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "bonus_multiplier",
table: "experience_records");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class CacheSocialCreditsInProfile : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "social_credits",
table: "account_profiles",
type: "double precision",
nullable: false,
defaultValue: 0.0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "social_credits",
table: "account_profiles");
}
}
}

View File

@@ -98,6 +98,10 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("activated_at");
b.Property<Guid?>("AutomatedId")
.HasColumnType("uuid")
.HasColumnName("automated_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
@@ -458,6 +462,10 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("character varying(1024)")
.HasColumnName("pronouns");
b.Property<double>("SocialCredits")
.HasColumnType("double precision")
.HasColumnName("social_credits");
b.Property<string>("TimeZone")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
@@ -796,6 +804,51 @@ namespace DysonNetwork.Pass.Migrations
b.ToTable("account_statuses", (string)null);
});
modelBuilder.Entity("DysonNetwork.Pass.Auth.ApiKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.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>("Label")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("label");
b.Property<Guid>("SessionId")
.HasColumnType("uuid")
.HasColumnName("session_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_api_keys");
b.HasIndex("AccountId")
.HasDatabaseName("ix_api_keys_account_id");
b.HasIndex("SessionId")
.HasDatabaseName("ix_api_keys_session_id");
b.ToTable("api_keys", (string)null);
});
modelBuilder.Entity("DysonNetwork.Pass.Auth.AuthChallenge", b =>
{
b.Property<Guid>("Id")
@@ -957,7 +1010,7 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("uuid")
.HasColumnName("app_id");
b.Property<Guid>("ChallengeId")
b.Property<Guid?>("ChallengeId")
.HasColumnType("uuid")
.HasColumnName("challenge_id");
@@ -973,11 +1026,6 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Label")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("label");
b.Property<Instant?>("LastGrantedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_granted_at");
@@ -998,6 +1046,110 @@ namespace DysonNetwork.Pass.Migrations
b.ToTable("auth_sessions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Pass.Credit.SocialCreditRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.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<double>("Delta")
.HasColumnType("double precision")
.HasColumnName("delta");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("reason");
b.Property<string>("ReasonType")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("reason_type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_social_credit_records");
b.HasIndex("AccountId")
.HasDatabaseName("ix_social_credit_records_account_id");
b.ToTable("social_credit_records", (string)null);
});
modelBuilder.Entity("DysonNetwork.Pass.Leveling.ExperienceRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<double>("BonusMultiplier")
.HasColumnType("double precision")
.HasColumnName("bonus_multiplier");
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<long>("Delta")
.HasColumnType("bigint")
.HasColumnName("delta");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("reason");
b.Property<string>("ReasonType")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("reason_type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_experience_records");
b.HasIndex("AccountId")
.HasDatabaseName("ix_experience_records_account_id");
b.ToTable("experience_records", (string)null);
});
modelBuilder.Entity("DysonNetwork.Pass.Permission.PermissionGroup", b =>
{
b.Property<Guid>("Id")
@@ -1628,6 +1780,27 @@ namespace DysonNetwork.Pass.Migrations
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Pass.Auth.ApiKey", b =>
{
b.HasOne("DysonNetwork.Pass.Account.Account", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_api_keys_accounts_account_id");
b.HasOne("DysonNetwork.Pass.Auth.AuthSession", "Session")
.WithMany()
.HasForeignKey("SessionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_api_keys_auth_sessions_session_id");
b.Navigation("Account");
b.Navigation("Session");
});
modelBuilder.Entity("DysonNetwork.Pass.Auth.AuthChallenge", b =>
{
b.HasOne("DysonNetwork.Pass.Account.Account", "Account")
@@ -1671,8 +1844,6 @@ namespace DysonNetwork.Pass.Migrations
b.HasOne("DysonNetwork.Pass.Auth.AuthChallenge", "Challenge")
.WithMany()
.HasForeignKey("ChallengeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_sessions_auth_challenges_challenge_id");
b.Navigation("Account");
@@ -1680,6 +1851,30 @@ namespace DysonNetwork.Pass.Migrations
b.Navigation("Challenge");
});
modelBuilder.Entity("DysonNetwork.Pass.Credit.SocialCreditRecord", b =>
{
b.HasOne("DysonNetwork.Pass.Account.Account", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_social_credit_records_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Pass.Leveling.ExperienceRecord", b =>
{
b.HasOne("DysonNetwork.Pass.Account.Account", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_experience_records_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Pass.Permission.PermissionGroupMember", b =>
{
b.HasOne("DysonNetwork.Pass.Permission.PermissionGroup", "Group")

View File

@@ -4,6 +4,7 @@ using DysonNetwork.Pass.Startup;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.PageData;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Shared.Stream;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
@@ -16,6 +17,7 @@ builder.Services.AddAppMetrics();
// Add application services
builder.Services.AddRegistryService(builder.Configuration);
builder.Services.AddStreamConnection(builder.Configuration);
builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication();

View File

@@ -1,6 +1,8 @@
using System.Net;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Leveling;
using DysonNetwork.Pass.Permission;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.FileProviders;
@@ -76,6 +78,8 @@ public static class ApplicationConfiguration
app.MapGrpcService<AuthServiceGrpc>();
app.MapGrpcService<ActionLogServiceGrpc>();
app.MapGrpcService<PermissionServiceGrpc>();
app.MapGrpcService<SocialCreditServiceGrpc>();
app.MapGrpcService<ExperienceServiceGrpc>();
app.MapGrpcService<BotAccountReceiverGrpc>();
return app;

View File

@@ -15,7 +15,9 @@ using System.Text.Json;
using System.Threading.RateLimiting;
using DysonNetwork.Pass.Auth.OidcProvider.Options;
using DysonNetwork.Pass.Auth.OidcProvider.Services;
using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Handlers;
using DysonNetwork.Pass.Leveling;
using DysonNetwork.Pass.Safety;
using DysonNetwork.Pass.Wallet.PaymentHandlers;
using DysonNetwork.Shared.Cache;
@@ -203,6 +205,8 @@ public static class ServiceCollectionExtensions
services.AddScoped<PaymentService>();
services.AddScoped<AfdianPaymentHandler>();
services.AddScoped<SafetyService>();
services.AddScoped<SocialCreditService>();
services.AddScoped<ExperienceService>();
services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider"));
services.AddScoped<OidcProviderService>();

View File

@@ -24,7 +24,7 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
}
var accountId = currentUser.Id!;
var deviceId = currentSession.Challenge.DeviceId!;
var deviceId = currentSession.Challenge?.DeviceId ?? Guid.NewGuid().ToString();
if (string.IsNullOrEmpty(deviceId))
{
@@ -32,7 +32,8 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
return;
}
using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync(new WebSocketAcceptContext
{ KeepAliveInterval = TimeSpan.FromSeconds(60) });
var cts = new CancellationTokenSource();
var connectionKey = (accountId, deviceId);
@@ -65,7 +66,12 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
}
catch (Exception ex)
{
logger.LogError(ex, "WebSocket disconnected with user @{UserName}#{UserId} and device #{DeviceId} unexpectedly");
logger.LogError(ex,
"WebSocket disconnected with user @{UserName}#{UserId} and device #{DeviceId} unexpectedly",
currentUser.Name,
currentUser.Id,
deviceId
);
}
finally
{
@@ -99,7 +105,7 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
break;
var packet = WebSocketPacket.FromBytes(buffer[..receiveResult.Count]);
_ = ws.HandlePacket(currentUser, connectionKey.DeviceId, packet, webSocket);
await ws.HandlePacket(currentUser, connectionKey.DeviceId, packet, webSocket);
}
}
catch (OperationCanceledException)

View File

@@ -49,11 +49,18 @@ public class WebSocketService
public void Disconnect((string AccountId, string DeviceId) key, string? reason = null)
{
if (!ActiveConnections.TryGetValue(key, out var data)) return;
try
{
data.Socket.CloseAsync(
WebSocketCloseStatus.NormalClosure,
reason ?? "Server just decided to disconnect.",
CancellationToken.None
);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error while closing WebSocket for {AccountId}:{DeviceId}", key.AccountId, key.DeviceId);
}
data.Cts.Cancel();
ActiveConnections.TryRemove(key, out _);
}

View File

@@ -1,34 +1,29 @@
using CorePush.Apple;
using CorePush.Firebase;
using DysonNetwork.Pusher.Connection;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Pusher.Services;
using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using System.Threading.Channels;
using WebSocketPacket = DysonNetwork.Pusher.Connection.WebSocketPacket;
namespace DysonNetwork.Pusher.Notification;
public class PushService : IDisposable
public class PushService
{
private readonly AppDatabase _db;
private readonly FlushBufferService _fbs;
private readonly WebSocketService _ws;
private readonly QueueService _queueService;
private readonly ILogger<PushService> _logger;
private readonly FirebaseSender? _fcm;
private readonly ApnSender? _apns;
private readonly string? _apnsTopic;
private readonly Channel<PushWorkItem> _channel;
private readonly int _maxConcurrency;
private readonly CancellationTokenSource _cts = new();
private readonly List<Task> _workers = new();
public PushService(
IConfiguration config,
AppDatabase db,
FlushBufferService fbs,
WebSocketService ws,
QueueService queueService,
IHttpClientFactory httpFactory,
ILogger<PushService> logger
)
@@ -58,48 +53,9 @@ public class PushService : IDisposable
}
_db = db;
_fbs = fbs;
_ws = ws;
_queueService = queueService;
_logger = logger;
// --- Concurrency & channel config ---
// Defaults: 8 workers, bounded capacity 2000 items.
_maxConcurrency = Math.Max(1, cfgSection.GetValue<int?>("MaxConcurrency") ?? 8);
var capacity = Math.Max(1, cfgSection.GetValue<int?>("ChannelCapacity") ?? 2000);
_channel = Channel.CreateBounded<PushWorkItem>(new BoundedChannelOptions(capacity)
{
SingleWriter = false,
SingleReader = false,
FullMode = BoundedChannelFullMode.Wait, // apply backpressure instead of dropping
AllowSynchronousContinuations = false
});
// Start background consumers
for (int i = 0; i < _maxConcurrency; i++)
{
_workers.Add(Task.Run(() => WorkerLoop(_cts.Token)));
}
_logger.LogInformation("PushService initialized with {Workers} workers and capacity {Capacity}", _maxConcurrency, capacity);
}
public void Dispose()
{
try
{
_channel.Writer.TryComplete();
_cts.Cancel();
}
catch { /* ignore */ }
try
{
Task.WhenAll(_workers).Wait(TimeSpan.FromSeconds(5));
}
catch { /* ignore */ }
_cts.Dispose();
}
public async Task UnsubscribeDevice(string deviceId)
@@ -165,7 +121,7 @@ public class PushService : IDisposable
{
meta ??= [];
if (title is null && subtitle is null && content is null)
throw new ArgumentException("Unable to send notification that completely empty.");
throw new ArgumentException("Unable to send notification that is completely empty.");
if (actionUri is not null) meta["action_uri"] = actionUri;
@@ -181,35 +137,63 @@ public class PushService : IDisposable
};
if (save)
_fbs.Enqueue(notification);
if (!isSilent)
await DeliveryNotification(notification); // returns quickly (does NOT wait for APNS/FCM)
{
_db.Notifications.Add(notification);
await _db.SaveChangesAsync();
}
private async Task DeliveryNotification(Notification notification)
if (!isSilent)
_ = _queueService.EnqueuePushNotification(notification, accountId, save);
}
public async Task DeliverPushNotification(Notification notification, CancellationToken cancellationToken = default)
{
_ws.SendPacketToAccount(notification.AccountId.ToString(), new WebSocketPacket()
{
Type = "notifications.new",
Data = notification,
});
try
{
_logger.LogInformation(
"Delivering notification: {NotificationTopic} #{NotificationId} with meta {NotificationMeta}",
"Delivering push notification: {NotificationTopic} with meta {NotificationMeta}",
notification.Topic,
notification.Id,
notification.Meta
);
// WS send: still immediate (fire-and-forget from caller perspective)
_ws.SendPacketToAccount(notification.AccountId.ToString(), new Connection.WebSocketPacket
{
Type = "notifications.new",
Data = notification
});
// Query subscribers and enqueue push work (non-blocking to the HTTP request)
var subscribers = await _db.PushSubscriptions
// Get all push subscriptions for the account
var subscriptions = await _db.PushSubscriptions
.Where(s => s.AccountId == notification.AccountId)
.AsNoTracking()
.ToListAsync();
.ToListAsync(cancellationToken);
await EnqueuePushWork(notification, subscribers);
if (subscriptions.Count == 0)
{
_logger.LogInformation("No push subscriptions found for account {AccountId}", notification.AccountId);
return;
}
// Send push notifications
var tasks = new List<Task>();
foreach (var subscription in subscriptions)
{
try
{
tasks.Add(SendPushNotificationAsync(subscription, notification));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sending push notification to {DeviceId}", subscription.DeviceId);
}
}
await Task.WhenAll(tasks);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in DeliverPushNotification");
throw;
}
}
public async Task MarkNotificationsViewed(ICollection<Notification> notifications)
@@ -228,6 +212,7 @@ public class PushService : IDisposable
var now = SystemClock.Instance.GetCurrentInstant();
await _db.Notifications
.Where(n => n.AccountId == accountId)
.Where(n => n.ViewedAt == null)
.ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now));
}
@@ -235,9 +220,8 @@ public class PushService : IDisposable
{
if (save)
{
accounts.ForEach(x =>
{
var newNotification = new Notification
var now = SystemClock.Instance.GetCurrentInstant();
var notifications = accounts.Select(accountId => new Notification
{
Topic = notification.Topic,
Title = notification.Title,
@@ -245,10 +229,16 @@ public class PushService : IDisposable
Content = notification.Content,
Meta = notification.Meta,
Priority = notification.Priority,
AccountId = x
};
_fbs.Enqueue(newNotification);
});
AccountId = accountId,
CreatedAt = now,
UpdatedAt = now
}).ToList();
if (notifications.Count != 0)
{
await _db.Notifications.AddRangeAsync(notifications);
await _db.SaveChangesAsync();
}
}
_logger.LogInformation(
@@ -269,56 +259,10 @@ public class PushService : IDisposable
});
}
// Fetch all subscribers once and enqueue to workers
var subscribers = await _db.PushSubscriptions
.Where(s => accounts.Contains(s.AccountId))
.AsNoTracking()
.ToListAsync();
await EnqueuePushWork(notification, subscribers);
await DeliverPushNotification(notification);
}
private async Task EnqueuePushWork(Notification notification, IEnumerable<PushSubscription> subscriptions)
{
foreach (var sub in subscriptions)
{
// Use the current notification reference (no mutation of content after this point).
var item = new PushWorkItem(notification, sub);
// Respect backpressure if channel is full.
await _channel.Writer.WriteAsync(item, _cts.Token);
}
}
private async Task WorkerLoop(CancellationToken ct)
{
try
{
await foreach (var item in _channel.Reader.ReadAllAsync(ct))
{
try
{
await _PushSingleNotification(item.Notification, item.Subscription);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Worker handled exception for notification #{Id}", item.Notification.Id);
}
}
}
catch (OperationCanceledException)
{
// normal shutdown
}
}
private readonly record struct PushWorkItem(Notification Notification, PushSubscription Subscription);
private async Task _PushSingleNotification(Notification notification, PushSubscription subscription)
private async Task SendPushNotificationAsync(PushSubscription subscription, Notification notification)
{
try
{
@@ -415,4 +359,27 @@ public class PushService : IDisposable
_logger.LogInformation(
$"Successfully pushed notification #{notification.Id} to device {subscription.DeviceId} provider {subscription.Provider}");
}
public async Task SaveNotification(Notification notification)
{
_db.Notifications.Add(notification);
await _db.SaveChangesAsync();
}
public async Task SaveNotification(Notification notification, List<Guid> accounts)
{
_db.Notifications.AddRange(accounts.Select(a => new Notification
{
AccountId = a,
Topic = notification.Topic,
Content = notification.Content,
Title = notification.Title,
Subtitle = notification.Subtitle,
Meta = notification.Meta,
Priority = notification.Priority,
CreatedAt = notification.CreatedAt,
UpdatedAt = notification.UpdatedAt,
}));
await _db.SaveChangesAsync();
}
}

View File

@@ -3,6 +3,7 @@ using DysonNetwork.Pusher.Startup;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Shared.Stream;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
@@ -12,6 +13,7 @@ builder.ConfigureAppKestrel(builder.Configuration);
// Add application services
builder.Services.AddRegistryService(builder.Configuration);
builder.Services.AddStreamConnection(builder.Configuration);
builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication();

View File

@@ -5,19 +5,19 @@ using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using System.Text.Json;
namespace DysonNetwork.Pusher.Services;
public class PusherServiceGrpc(
EmailService emailService,
QueueService queueService,
WebSocketService websocket,
PushService pushService,
AccountClientHelper accountsHelper
PushService pushService
) : PusherService.PusherServiceBase
{
public override async Task<Empty> SendEmail(SendEmailRequest request, ServerCallContext context)
{
await emailService.SendEmailAsync(
await queueService.EnqueueEmail(
request.Email.ToName,
request.Email.ToAddress,
request.Email.Subject,
@@ -47,8 +47,11 @@ public class PusherServiceGrpc(
Data = GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Packet.Data),
ErrorMessage = request.Packet.ErrorMessage
};
foreach (var userId in request.UserIds)
{
websocket.SendPacketToAccount(userId, packet);
}
return Task.FromResult(new Empty());
}
@@ -75,8 +78,11 @@ public class PusherServiceGrpc(
Data = GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Packet.Data),
ErrorMessage = request.Packet.ErrorMessage
};
foreach (var deviceId in request.DeviceIds)
{
websocket.SendPacketToDevice(deviceId, packet);
}
return Task.FromResult(new Empty());
}
@@ -84,20 +90,30 @@ public class PusherServiceGrpc(
public override async Task<Empty> SendPushNotificationToUser(SendPushNotificationToUserRequest request,
ServerCallContext context)
{
var account = await accountsHelper.GetAccount(Guid.Parse(request.UserId));
await pushService.SendNotification(
account,
request.Notification.Topic,
request.Notification.Title,
request.Notification.Subtitle,
request.Notification.Body,
request.Notification.HasMeta
var notification = new Notification.Notification
{
Topic = request.Notification.Topic,
Title = request.Notification.Title,
Subtitle = request.Notification.Subtitle,
Content = request.Notification.Body,
Meta = request.Notification.HasMeta
? GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Notification.Meta) ?? []
: [],
request.Notification.ActionUri,
request.Notification.IsSilent,
AccountId = Guid.Parse(request.UserId),
};
if (request.Notification.ActionUri is not null)
notification.Meta["action_uri"] = request.Notification.ActionUri;
if (request.Notification.IsSavable)
await pushService.SaveNotification(notification);
await queueService.EnqueuePushNotification(
notification,
Guid.Parse(request.UserId),
request.Notification.IsSavable
);
return new Empty();
}
@@ -114,10 +130,22 @@ public class PusherServiceGrpc(
? GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Notification.Meta) ?? []
: [],
};
if (request.Notification.ActionUri is not null)
notification.Meta["action_uri"] = request.Notification.ActionUri;
var accounts = request.UserIds.Select(Guid.Parse).ToList();
await pushService.SendNotificationBatch(notification, accounts, request.Notification.IsSavable);
var userIds = request.UserIds.Select(Guid.Parse).ToList();
if (request.Notification.IsSavable)
await pushService.SaveNotification(notification, userIds);
var tasks = userIds
.Select(userId => queueService.EnqueuePushNotification(
notification,
userId,
request.Notification.IsSavable
));
await Task.WhenAll(tasks);
return new Empty();
}

View File

@@ -0,0 +1,141 @@
using System.Text.Json;
using DysonNetwork.Pusher.Email;
using DysonNetwork.Pusher.Notification;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using Google.Protobuf;
using NATS.Client.Core;
namespace DysonNetwork.Pusher.Services;
public class QueueBackgroundService(
INatsConnection nats,
IServiceProvider serviceProvider,
ILogger<QueueBackgroundService> logger,
IConfiguration configuration
)
: BackgroundService
{
public const string QueueName = "pusher.queue";
public const string QueueGroup = "pusher.workers";
private readonly int _consumerCount = configuration.GetValue<int?>("ConsumerCount") ?? Environment.ProcessorCount;
private readonly List<Task> _consumerTasks = [];
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Starting {ConsumerCount} queue consumers", _consumerCount);
// Start multiple consumers
for (var i = 0; i < _consumerCount; i++)
_consumerTasks.Add(Task.Run(() => RunConsumerAsync(stoppingToken), stoppingToken));
// Wait for all consumers to complete
await Task.WhenAll(_consumerTasks);
}
private async Task RunConsumerAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Queue consumer started");
await foreach (var msg in nats.SubscribeAsync<byte[]>(
QueueName,
queueGroup: QueueGroup,
cancellationToken: stoppingToken))
{
try
{
var message = GrpcTypeHelper.ConvertByteStringToObject<QueueMessage>(ByteString.CopyFrom(msg.Data));
if (message is not null)
{
await ProcessMessageAsync(msg, message, stoppingToken);
}
else
{
logger.LogWarning($"Invalid message format for {msg.Subject}");
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Normal shutdown
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Error in queue consumer");
// Add a small delay to prevent tight error loops
await Task.Delay(1000, stoppingToken);
}
}
}
private async ValueTask ProcessMessageAsync(NatsMsg<byte[]> rawMsg, QueueMessage message,
CancellationToken cancellationToken)
{
using var scope = serviceProvider.CreateScope();
logger.LogDebug("Processing message of type {MessageType}", message.Type);
try
{
switch (message.Type)
{
case QueueMessageType.Email:
await ProcessEmailMessageAsync(message, scope);
break;
case QueueMessageType.PushNotification:
await ProcessPushNotificationMessageAsync(message, scope, cancellationToken);
break;
default:
logger.LogWarning("Unknown message type: {MessageType}", message.Type);
break;
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing message of type {MessageType}", message.Type);
// Don't rethrow to prevent the message from being retried indefinitely
// In a production scenario, you might want to implement a dead-letter queue
}
}
private static async Task ProcessEmailMessageAsync(QueueMessage message, IServiceScope scope)
{
var emailService = scope.ServiceProvider.GetRequiredService<EmailService>();
var emailMessage = JsonSerializer.Deserialize<EmailMessage>(message.Data)
?? throw new InvalidOperationException("Invalid email message format");
await emailService.SendEmailAsync(
emailMessage.ToName,
emailMessage.ToAddress,
emailMessage.Subject,
emailMessage.Body);
}
private static async Task ProcessPushNotificationMessageAsync(QueueMessage message, IServiceScope scope,
CancellationToken cancellationToken)
{
var pushService = scope.ServiceProvider.GetRequiredService<PushService>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<QueueBackgroundService>>();
var notification = JsonSerializer.Deserialize<Notification.Notification>(message.Data);
if (notification == null)
{
logger.LogError("Invalid push notification data format");
return;
}
try
{
logger.LogDebug("Processing push notification for account {AccountId}", notification.AccountId);
await pushService.DeliverPushNotification(notification, cancellationToken);
logger.LogDebug("Successfully processed push notification for account {AccountId}", notification.AccountId);
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing push notification for account {AccountId}", notification.AccountId);
// Don't rethrow to prevent the message from being retried indefinitely
}
}
}

View File

@@ -0,0 +1,61 @@
using System.Text.Json;
using DysonNetwork.Shared.Proto;
using NATS.Client.Core;
namespace DysonNetwork.Pusher.Services;
public class QueueService(INatsConnection nats)
{
public async Task EnqueueEmail(string toName, string toAddress, string subject, string body)
{
var message = new QueueMessage
{
Type = QueueMessageType.Email,
Data = JsonSerializer.Serialize(new EmailMessage
{
ToName = toName,
ToAddress = toAddress,
Subject = subject,
Body = body
})
};
var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray();
await nats.PublishAsync(QueueBackgroundService.QueueName, rawMessage);
}
public async Task EnqueuePushNotification(Notification.Notification notification, Guid userId, bool isSavable = false)
{
// Update the account ID in case it wasn't set
notification.AccountId = userId;
var message = new QueueMessage
{
Type = QueueMessageType.PushNotification,
TargetId = userId.ToString(),
Data = JsonSerializer.Serialize(notification)
};
var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray();
await nats.PublishAsync(QueueBackgroundService.QueueName, rawMessage);
}
}
public class QueueMessage
{
public QueueMessageType Type { get; set; }
public string? TargetId { get; set; }
public string Data { get; set; } = string.Empty;
}
public enum QueueMessageType
{
Email,
PushNotification
}
public class EmailMessage
{
public string ToName { get; set; } = string.Empty;
public string ToAddress { get; set; } = string.Empty;
public string Subject { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
}

View File

@@ -134,10 +134,16 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddAppBusinessServices(this IServiceCollection services)
{
services.AddScoped<WebSocketService>();
services.AddSingleton<WebSocketService>();
services.AddScoped<EmailService>();
services.AddScoped<PushService>();
// Register QueueService as a singleton since it's thread-safe
services.AddSingleton<QueueService>();
// Register the background service
services.AddHostedService<QueueBackgroundService>();
return services;
}
}

View File

@@ -33,7 +33,10 @@ public class DysonTokenAuthHandler(
AuthSession session;
try
{
session = await ValidateToken(tokenInfo.Token);
session = await ValidateToken(
tokenInfo.Token,
Request.HttpContext.Connection.RemoteIpAddress?.ToString()
);
}
catch (InvalidOperationException ex)
{
@@ -58,7 +61,7 @@ public class DysonTokenAuthHandler(
};
// Add scopes as claims
session.Challenge.Scopes.ToList().ForEach(scope => claims.Add(new Claim("scope", scope)));
session.Challenge?.Scopes.ToList().ForEach(scope => claims.Add(new Claim("scope", scope)));
// Add superuser claim if applicable
if (session.Account.IsSuperuser)
@@ -78,12 +81,15 @@ public class DysonTokenAuthHandler(
}
}
private async Task<AuthSession> ValidateToken(string token)
private async Task<AuthSession> ValidateToken(string token, string? ipAddress)
{
var resp = await auth.AuthenticateAsync(new AuthenticateRequest { Token = token });
var resp = await auth.AuthenticateAsync(new AuthenticateRequest
{
Token = token,
IpAddress = ipAddress
});
if (!resp.Valid) throw new InvalidOperationException(resp.Message);
if (resp.Session == null) throw new InvalidOperationException("Session not found.");
return resp.Session;
return resp.Session ?? throw new InvalidOperationException("Session not found.");
}
private static byte[] Base64UrlDecode(string base64Url)

View File

@@ -0,0 +1,350 @@
using DysonNetwork.Shared.Proto;
using NodaTime;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Shared.Data;
public class AccountReference : ModelBase
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Nick { get; set; } = string.Empty;
public string Language { get; set; } = string.Empty;
public Instant? ActivatedAt { get; set; }
public bool IsSuperuser { get; set; }
public Guid? AutomatedId { get; set; }
public AccountProfileReference Profile { get; set; } = null!;
public List<AccountContactReference> Contacts { get; set; } = new();
public List<AccountBadgeReference> Badges { get; set; } = new();
public SubscriptionReference? PerkSubscription { get; set; }
public Proto.Account ToProtoValue()
{
var proto = new Proto.Account
{
Id = Id.ToString(),
Name = Name,
Nick = Nick,
Language = Language,
ActivatedAt = ActivatedAt?.ToTimestamp(),
IsSuperuser = IsSuperuser,
Profile = Profile.ToProtoValue(),
PerkSubscription = PerkSubscription?.ToProtoValue(),
CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp()
};
foreach (var contact in Contacts)
proto.Contacts.Add(contact.ToProtoValue());
foreach (var badge in Badges)
proto.Badges.Add(badge.ToProtoValue());
return proto;
}
public static AccountReference FromProtoValue(Proto.Account proto)
{
var account = new AccountReference
{
Id = Guid.Parse(proto.Id),
Name = proto.Name,
Nick = proto.Nick,
Language = proto.Language,
ActivatedAt = proto.ActivatedAt?.ToInstant(),
IsSuperuser = proto.IsSuperuser,
AutomatedId = string.IsNullOrEmpty(proto.AutomatedId) ? null : Guid.Parse(proto.AutomatedId),
PerkSubscription = proto.PerkSubscription != null
? SubscriptionReference.FromProtoValue(proto.PerkSubscription)
: null,
CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant()
};
account.Profile = AccountProfileReference.FromProtoValue(proto.Profile);
foreach (var contactProto in proto.Contacts)
account.Contacts.Add(AccountContactReference.FromProtoValue(contactProto));
foreach (var badgeProto in proto.Badges)
account.Badges.Add(AccountBadgeReference.FromProtoValue(badgeProto));
return account;
}
}
public class AccountProfileReference : ModelBase
{
public Guid Id { get; set; }
public string? FirstName { get; set; }
public string? MiddleName { get; set; }
public string? LastName { get; set; }
public string? Bio { get; set; }
public string? Gender { get; set; }
public string? Pronouns { get; set; }
public string? TimeZone { get; set; }
public string? Location { get; set; }
public List<ProfileLinkReference>? Links { get; set; }
public Instant? Birthday { get; set; }
public Instant? LastSeenAt { get; set; }
public VerificationMark? Verification { get; set; }
public int Experience { get; set; }
public int Level => Leveling.ExperiencePerLevel.Count(xp => Experience >= xp) - 1;
public double SocialCredits { get; set; } = 100;
public int SocialCreditsLevel => SocialCredits switch
{
< 100 => -1,
> 100 and < 200 => 0,
< 200 => 1,
_ => 2
};
public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1
? 100
: (Experience - Leveling.ExperiencePerLevel[Level]) * 100.0 /
(Leveling.ExperiencePerLevel[Level + 1] - Leveling.ExperiencePerLevel[Level]);
public CloudFileReferenceObject? Picture { get; set; }
public CloudFileReferenceObject? Background { get; set; }
public Guid AccountId { get; set; }
public Proto.AccountProfile ToProtoValue()
{
var proto = new Proto.AccountProfile
{
Id = Id.ToString(),
FirstName = FirstName ?? string.Empty,
MiddleName = MiddleName ?? string.Empty,
LastName = LastName ?? string.Empty,
Bio = Bio ?? string.Empty,
Gender = Gender ?? string.Empty,
Pronouns = Pronouns ?? string.Empty,
TimeZone = TimeZone ?? string.Empty,
Location = Location ?? string.Empty,
Birthday = Birthday?.ToTimestamp(),
LastSeenAt = LastSeenAt?.ToTimestamp(),
Experience = Experience,
Level = Level,
LevelingProgress = LevelingProgress,
SocialCredits = SocialCredits,
SocialCreditsLevel = SocialCreditsLevel,
Picture = Picture?.ToProtoValue(),
Background = Background?.ToProtoValue(),
AccountId = AccountId.ToString(),
Verification = Verification?.ToProtoValue(),
CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp()
};
return proto;
}
public static AccountProfileReference FromProtoValue(Proto.AccountProfile proto)
{
return new AccountProfileReference
{
Id = Guid.Parse(proto.Id),
FirstName = string.IsNullOrEmpty(proto.FirstName) ? null : proto.FirstName,
MiddleName = string.IsNullOrEmpty(proto.MiddleName) ? null : proto.MiddleName,
LastName = string.IsNullOrEmpty(proto.LastName) ? null : proto.LastName,
Bio = string.IsNullOrEmpty(proto.Bio) ? null : proto.Bio,
Gender = string.IsNullOrEmpty(proto.Gender) ? null : proto.Gender,
Pronouns = string.IsNullOrEmpty(proto.Pronouns) ? null : proto.Pronouns,
TimeZone = string.IsNullOrEmpty(proto.TimeZone) ? null : proto.TimeZone,
Location = string.IsNullOrEmpty(proto.Location) ? null : proto.Location,
Birthday = proto.Birthday?.ToInstant(),
LastSeenAt = proto.LastSeenAt?.ToInstant(),
Experience = proto.Experience,
SocialCredits = proto.SocialCredits,
Picture = proto.Picture != null ? CloudFileReferenceObject.FromProtoValue(proto.Picture) : null,
Background = proto.Background != null ? CloudFileReferenceObject.FromProtoValue(proto.Background) : null,
AccountId = Guid.Parse(proto.AccountId),
Verification = proto.Verification != null ? VerificationMark.FromProtoValue(proto.Verification) : null,
CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant()
};
}
}
public class AccountContactReference : ModelBase
{
public Guid Id { get; set; }
public AccountContactReferenceType Type { get; set; }
public Instant? VerifiedAt { get; set; }
public bool IsPrimary { get; set; } = false;
public bool IsPublic { get; set; } = false;
public string Content { get; set; } = string.Empty;
public Guid AccountId { get; set; }
public Shared.Proto.AccountContact ToProtoValue()
{
var proto = new Shared.Proto.AccountContact
{
Id = Id.ToString(),
Type = Type switch
{
AccountContactReferenceType.Email => Shared.Proto.AccountContactType.Email,
AccountContactReferenceType.PhoneNumber => Shared.Proto.AccountContactType.PhoneNumber,
AccountContactReferenceType.Address => Shared.Proto.AccountContactType.Address,
_ => Shared.Proto.AccountContactType.Unspecified
},
Content = Content,
IsPrimary = IsPrimary,
VerifiedAt = VerifiedAt?.ToTimestamp(),
AccountId = AccountId.ToString(),
CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp()
};
return proto;
}
public static AccountContactReference FromProtoValue(Shared.Proto.AccountContact proto)
{
var contact = new AccountContactReference
{
Id = Guid.Parse(proto.Id),
AccountId = Guid.Parse(proto.AccountId),
Type = proto.Type switch
{
Shared.Proto.AccountContactType.Email => AccountContactReferenceType.Email,
Shared.Proto.AccountContactType.PhoneNumber => AccountContactReferenceType.PhoneNumber,
Shared.Proto.AccountContactType.Address => AccountContactReferenceType.Address,
_ => AccountContactReferenceType.Email
},
Content = proto.Content,
IsPrimary = proto.IsPrimary,
VerifiedAt = proto.VerifiedAt?.ToInstant(),
CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant()
};
return contact;
}
}
public enum AccountContactReferenceType
{
Email,
PhoneNumber,
Address
}
public class AccountBadgeReference : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Type { get; set; } = null!;
public string? Label { get; set; }
public string? Caption { get; set; }
public Dictionary<string, object?> Meta { get; set; } = new();
public Instant? ActivatedAt { get; set; }
public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; }
public AccountBadge ToProtoValue()
{
var proto = new AccountBadge
{
Id = Id.ToString(),
Type = Type,
Label = Label ?? string.Empty,
Caption = Caption ?? string.Empty,
ActivatedAt = ActivatedAt?.ToTimestamp(),
ExpiredAt = ExpiredAt?.ToTimestamp(),
AccountId = AccountId.ToString(),
CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp()
};
proto.Meta.Add(GrpcTypeHelper.ConvertToValueMap(Meta));
return proto;
}
public static AccountBadgeReference FromProtoValue(AccountBadge proto)
{
var badge = new AccountBadgeReference
{
Id = Guid.Parse(proto.Id),
AccountId = Guid.Parse(proto.AccountId),
Type = proto.Type,
Label = proto.Label,
Caption = proto.Caption,
ActivatedAt = proto.ActivatedAt?.ToInstant(),
ExpiredAt = proto.ExpiredAt?.ToInstant(),
CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant()
};
return badge;
}
}
public class ProfileLinkReference
{
public string Name { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
}
public static class Leveling
{
public static readonly List<int> ExperiencePerLevel =
[
0, // Level 0
100, // Level 1
250, // Level 2
500, // Level 3
1000, // Level 4
2000, // Level 5
4000, // Level 6
8000, // Level 7
16000, // Level 8
32000, // Level 9
64000, // Level 10
128000, // Level 11
256000, // Level 12
512000, // Level 13
1024000
];
}
public class ApiKeyReference : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Label { get; set; } = null!;
public Guid AccountId { get; set; }
public Guid SessionId { get; set; }
public string? Key { get; set; }
public ApiKey ToProtoValue()
{
return new ApiKey
{
Id = Id.ToString(),
Label = Label,
AccountId = AccountId.ToString(),
SessionId = SessionId.ToString(),
Key = Key,
CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp()
};
}
public static ApiKeyReference FromProtoValue(ApiKey proto)
{
return new ApiKeyReference
{
Id = Guid.Parse(proto.Id),
AccountId = Guid.Parse(proto.AccountId),
SessionId = Guid.Parse(proto.SessionId),
Label = proto.Label,
Key = proto.Key,
CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant()
};
}
}

View File

@@ -10,6 +10,8 @@ public abstract class ActionLogType
public const string PostUpdate = "posts.update";
public const string PostDelete = "posts.delete";
public const string PostReact = "posts.react";
public const string PostPin = "posts.pin";
public const string PostUnpin = "posts.unpin";
public const string MessageCreate = "messages.create";
public const string MessageUpdate = "messages.update";
public const string MessageDelete = "messages.delete";

View File

@@ -0,0 +1,64 @@
using NodaTime;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Shared.Data;
public class SubscriptionReference
{
public Guid Id { get; set; }
public string Identifier { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public bool IsActive { get; set; }
public bool IsAvailable { get; set; }
public Instant BegunAt { get; set; }
public Instant? EndedAt { get; set; }
public Instant? RenewalAt { get; set; }
public SubscriptionReferenceStatus Status { get; set; }
public static SubscriptionReference FromProtoValue(Proto.SubscriptionReferenceObject proto)
{
return new SubscriptionReference
{
Id = Guid.Parse(proto.Id),
Identifier = proto.Identifier,
DisplayName = proto.DisplayName,
IsActive = proto.IsActive,
IsAvailable = proto.IsAvailable,
BegunAt = proto.BegunAt.ToInstant(),
EndedAt = proto.EndedAt?.ToInstant(),
RenewalAt = proto.RenewalAt?.ToInstant(),
Status = (SubscriptionReferenceStatus)proto.Status
};
}
public Proto.SubscriptionReferenceObject ToProtoValue()
{
return new Proto.SubscriptionReferenceObject
{
Id = Id.ToString(),
Identifier = Identifier,
DisplayName = DisplayName,
IsActive = IsActive,
IsAvailable = IsAvailable,
BegunAt = BegunAt.ToTimestamp(),
EndedAt = EndedAt?.ToTimestamp(),
RenewalAt = RenewalAt?.ToTimestamp(),
Status = Status switch
{
SubscriptionReferenceStatus.Unpaid => Proto.SubscriptionStatus.Unpaid,
SubscriptionReferenceStatus.Active => Proto.SubscriptionStatus.Active,
SubscriptionReferenceStatus.Expired => Proto.SubscriptionStatus.Expired,
SubscriptionReferenceStatus.Cancelled => Proto.SubscriptionStatus.Cancelled,
_ => Proto.SubscriptionStatus.Unpaid
}
};
}
}
public enum SubscriptionReferenceStatus
{
Unpaid = 0,
Active = 1,
Expired = 2,
Cancelled = 3
}

View File

@@ -20,8 +20,6 @@ public static class KestrelConfiguration
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = maxRequestBodySize;
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
var configuredUrl = Environment.GetEnvironmentVariable("ASPNETCORE_URLS");
if (!string.IsNullOrEmpty(configuredUrl)) return;

View File

@@ -21,7 +21,6 @@ public static class GrpcClientHelper
? X509Certificate2.CreateFromPemFile(clientCertPath, clientKeyPath)
: X509Certificate2.CreateFromEncryptedPemFile(clientCertPath, clientCertPassword, clientKeyPath)
);
// TODO: Verify the ca in the future
handler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true;
var httpClient = new HttpClient(handler);
httpClient.DefaultRequestVersion = HttpVersion.Version20;

View File

@@ -32,6 +32,8 @@ message Account {
google.protobuf.Timestamp created_at = 14;
google.protobuf.Timestamp updated_at = 15;
google.protobuf.StringValue automated_id = 17;
}
// Enum for status attitude
@@ -75,6 +77,8 @@ message AccountProfile {
int32 experience = 14;
int32 level = 15;
double leveling_progress = 16;
double social_credits = 17;
int32 social_credits_level = 18;
CloudFile picture = 19;
CloudFile background = 20;
@@ -244,7 +248,9 @@ message GetAccountStatusBatchResponse {
service AccountService {
// Account Operations
rpc GetAccount(GetAccountRequest) returns (Account) {}
rpc GetBotAccount(GetBotAccountRequest) returns (Account) {}
rpc GetAccountBatch(GetAccountBatchRequest) returns (GetAccountBatchResponse) {}
rpc GetBotAccountBatch(GetBotAccountBatchRequest) returns (GetAccountBatchResponse) {}
rpc LookupAccountBatch(LookupAccountBatchRequest) returns (GetAccountBatchResponse) {}
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse) {}
@@ -319,10 +325,18 @@ message GetAccountRequest {
string id = 1; // Account ID to retrieve
}
message GetBotAccountRequest {
string automated_id = 1;
}
message GetAccountBatchRequest {
repeated string id = 1; // Account ID to retrieve
}
message GetBotAccountBatchRequest {
repeated string automated_id = 1;
}
message LookupAccountBatchRequest {
repeated string names = 1;
}

View File

@@ -13,7 +13,6 @@ import 'account.proto';
// Represents a user session
message AuthSession {
string id = 1;
google.protobuf.StringValue label = 2;
optional google.protobuf.Timestamp last_granted_at = 3;
optional google.protobuf.Timestamp expired_at = 4;
string account_id = 5;
@@ -71,6 +70,7 @@ service AuthService {
message AuthenticateRequest {
string token = 1;
optional google.protobuf.StringValue ip_address = 2;
}
message AuthenticateResponse {

View File

@@ -5,6 +5,7 @@ package proto;
option csharp_namespace = "DysonNetwork.Shared.Proto";
import "google/protobuf/timestamp.proto";
import "google/protobuf/wrappers.proto";
import "account.proto";
import "file.proto";
@@ -18,6 +19,12 @@ message CustomAppOauthConfig {
bool allow_offline_access = 7;
}
message CustomAppLinks {
string home_page = 1;
string privacy_policy = 2;
string terms_of_service = 3;
}
enum CustomAppStatus {
CUSTOM_APP_STATUS_UNSPECIFIED = 0;
DEVELOPING = 1;
@@ -34,10 +41,10 @@ message CustomApp {
CustomAppStatus status = 5;
// jsonb columns represented as bytes
bytes picture = 6;
bytes background = 7;
bytes verification = 8;
bytes links = 9;
CloudFile picture = 6;
CloudFile background = 7;
VerificationMark verification = 8;
CustomAppLinks links = 9;
CustomAppOauthConfig oauth_config = 13;
string project_id = 10;
@@ -108,6 +115,8 @@ message BotAccount {
message CreateBotAccountRequest {
Account account = 1;
string automated_id = 2;
optional string picture_id = 8;
optional string background_id = 9;
}
message CreateBotAccountResponse {
@@ -117,6 +126,8 @@ message CreateBotAccountResponse {
message UpdateBotAccountRequest {
string automated_id = 1; // ID of the bot account to update
Account account = 2; // Updated account information
optional string picture_id = 8;
optional string background_id = 9;
}
message UpdateBotAccountResponse {
@@ -132,15 +143,43 @@ message DeleteBotAccountResponse {
bool success = 1; // Whether the deletion was successful
}
// This service should be implemented by the Pass service to handle the creation, update, and deletion of bot accounts
service BotAccountReceiverService {
// Create a new bot account
rpc CreateBotAccount(CreateBotAccountRequest) returns (CreateBotAccountResponse);
// Update an existing bot account
rpc UpdateBotAccount(UpdateBotAccountRequest) returns (UpdateBotAccountResponse);
// Delete a bot account
rpc DeleteBotAccount(DeleteBotAccountRequest) returns (DeleteBotAccountResponse);
message ApiKey {
string id = 1;
string label = 2;
string account_id = 3;
string session_id = 4;
google.protobuf.StringValue key = 5;
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
}
message GetApiKeyRequest {
string id = 1;
}
message ListApiKeyRequest {
string automated_id = 1;
}
message GetApiKeyBatchResponse {
repeated ApiKey data = 1;
}
message DeleteApiKeyResponse {
bool success = 1;
}
// This service should be implemented by the Pass service to handle the creation, update, and deletion of bot accounts
service BotAccountReceiverService {
rpc CreateBotAccount(CreateBotAccountRequest) returns (CreateBotAccountResponse);
rpc UpdateBotAccount(UpdateBotAccountRequest) returns (UpdateBotAccountResponse);
rpc DeleteBotAccount(DeleteBotAccountRequest) returns (DeleteBotAccountResponse);
rpc GetApiKey(GetApiKeyRequest) returns (ApiKey);
rpc ListApiKey(ListApiKeyRequest) returns (GetApiKeyBatchResponse);
rpc CreateApiKey(ApiKey) returns (ApiKey);
rpc UpdateApiKey(ApiKey) returns (ApiKey);
rpc RotateApiKey(GetApiKeyRequest) returns (ApiKey);
rpc DeleteApiKey(GetApiKeyRequest) returns (DeleteApiKeyResponse);
}

View File

@@ -206,6 +206,11 @@ message DeleteResourceReferencesRequest {
optional string usage = 2;
}
message DeleteResourceReferencesBatchRequest {
repeated string resource_ids = 1;
optional string usage = 2;
}
message DeleteResourceReferencesResponse {
int32 deleted_count = 1;
}
@@ -278,6 +283,9 @@ service FileReferenceService {
// Deletes references for a specific resource and optional usage
rpc DeleteResourceReferences(DeleteResourceReferencesRequest) returns (DeleteResourceReferencesResponse);
// Deletes references for multiple specific resources and optional usage
rpc DeleteResourceReferencesBatch(DeleteResourceReferencesBatchRequest) returns (DeleteResourceReferencesResponse);
// Deletes a specific file reference
rpc DeleteReference(DeleteReferenceRequest) returns (DeleteReferenceResponse);

View File

@@ -0,0 +1,83 @@
syntax = "proto3";
package proto;
option csharp_namespace = "DysonNetwork.Shared.Proto";
import "google/protobuf/timestamp.proto";
import "google/protobuf/empty.proto";
import "google/protobuf/field_mask.proto";
// ====================================
// Message Definitions
// ====================================
// SocialCreditRecord represents a record of social credit changes for an account
message SocialCreditRecord {
string id = 1; // UUID string
string reason_type = 2;
string reason = 3;
double delta = 4;
string account_id = 5; // UUID string
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
}
// ExperienceRecord represents a record of experience points gained by an account
message ExperienceRecord {
string id = 1; // UUID string
string reason_type = 2;
string reason = 3;
int64 delta = 4;
double bonus_multiplier = 5;
string account_id = 6; // UUID string
google.protobuf.Timestamp created_at = 7;
google.protobuf.Timestamp updated_at = 8;
}
// ====================================
// Request/Response Messages
// ====================================
// Social Credit Requests/Responses
message AddSocialCreditRecordRequest {
string reason_type = 1;
string reason = 2;
double delta = 3;
string account_id = 4; // UUID string
}
message GetSocialCreditRequest {
string account_id = 1; // UUID string
}
message SocialCreditResponse {
double amount = 1;
}
// Experience Requests/Responses
message AddExperienceRecordRequest {
string reason_type = 1;
string reason = 2;
int64 delta = 3;
string account_id = 4; // UUID string
}
// ====================================
// Service Definitions
// ====================================
// SocialCreditService provides operations for managing social credit scores
service SocialCreditService {
// Adds a new social credit record for an account
rpc AddRecord(AddSocialCreditRecordRequest) returns (SocialCreditRecord);
// Gets the current social credit score for an account
rpc GetSocialCredit(GetSocialCreditRequest) returns (SocialCreditResponse);
}
// ExperienceService provides operations for managing experience points
service ExperienceService {
// Adds a new experience record for an account
rpc AddRecord(AddExperienceRecordRequest) returns (ExperienceRecord);
}

View File

@@ -12,6 +12,13 @@ public class AccountClientHelper(AccountService.AccountServiceClient accounts)
return response;
}
public async Task<Account> GetBotAccount(Guid automatedId)
{
var request = new GetBotAccountRequest { AutomatedId = automatedId.ToString() };
var response = await accounts.GetBotAccountAsync(request);
return response;
}
public async Task<List<Account>> GetAccountBatch(List<Guid> ids)
{
var request = new GetAccountBatchRequest();
@@ -20,6 +27,14 @@ public class AccountClientHelper(AccountService.AccountServiceClient accounts)
return response.Accounts.ToList();
}
public async Task<List<Account>> GetBotAccountBatch(List<Guid> automatedIds)
{
var request = new GetBotAccountBatchRequest();
request.AutomatedId.AddRange(automatedIds.Select(id => id.ToString()));
var response = await accounts.GetBotAccountBatchAsync(request);
return response.Accounts.ToList();
}
public async Task<Dictionary<Guid, AccountStatusReference>> GetAccountStatusBatch(List<Guid> ids)
{
var request = new GetAccountBatchRequest();

View File

@@ -4,6 +4,8 @@ namespace DysonNetwork.Shared.Stream;
public class AccountDeletedEvent
{
public static string Type => "account.deleted";
public Guid AccountId { get; set; } = Guid.NewGuid();
public Instant DeletedAt { get; set; } = SystemClock.Instance.GetCurrentInstant();
}

View File

@@ -1,7 +1,8 @@
using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.WebReader;
using DysonNetwork.Sphere.Discovery;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Realm;
using DysonNetwork.Sphere.WebReader;
using Microsoft.EntityFrameworkCore;
using NodaTime;
@@ -11,6 +12,7 @@ public class ActivityService(
AppDatabase db,
Publisher.PublisherService pub,
PostService ps,
RealmService rs,
DiscoveryService ds,
AccountService.AccountServiceClient accounts
)
@@ -27,58 +29,28 @@ public class ActivityService(
public async Task<List<Activity>> GetActivitiesForAnyone(
int take,
Instant? cursor,
HashSet<string>? debugInclude = null
)
HashSet<string>? debugInclude = null)
{
var activities = new List<Activity>();
debugInclude ??= new HashSet<string>();
// Add realm discovery if needed
if (cursor == null && (debugInclude.Contains("realms") || Random.Shared.NextDouble() < 0.2))
{
var realms = await ds.GetCommunityRealmAsync(null, 5, 0, true);
if (realms.Count > 0)
{
activities.Add(new DiscoveryActivity(
realms.Select(x => new DiscoveryItem("realm", x)).ToList()
).ToActivity());
}
var realmActivity = await GetRealmDiscoveryActivity();
if (realmActivity != null)
activities.Add(realmActivity);
}
// Add article discovery if needed
if (debugInclude.Contains("articles") || Random.Shared.NextDouble() < 0.2)
{
var recentFeedIds = await db.WebArticles
.GroupBy(a => a.FeedId)
.OrderByDescending(g => g.Max(a => a.PublishedAt))
.Take(10) // Get recent 10 distinct feeds
.Select(g => g.Key)
.ToListAsync();
// For each feed, get one random article
var recentArticles = new List<WebArticle>();
var random = new Random();
foreach (var feedId in recentFeedIds.OrderBy(_ => random.Next()))
{
var article = await db.WebArticles
.Include(a => a.Feed)
.Where(a => a.FeedId == feedId)
.OrderBy(_ => EF.Functions.Random())
.FirstOrDefaultAsync();
if (article == null) continue;
recentArticles.Add(article);
if (recentArticles.Count >= 5) break; // Limit to 5 articles
var articleActivity = await GetArticleDiscoveryActivity();
if (articleActivity != null)
activities.Add(articleActivity);
}
if (recentArticles.Count > 0)
{
activities.Add(new DiscoveryActivity(
recentArticles.Select(x => new DiscoveryItem("article", x)).ToList()
).ToActivity());
}
}
// Fetch a larger batch of recent posts to rank
// Get and process posts
var postsQuery = db.Posts
.Include(e => e.RepliedPost)
.Include(e => e.ForwardedPost)
@@ -89,31 +61,13 @@ public class ActivityService(
.Where(p => cursor == null || p.PublishedAt < cursor)
.OrderByDescending(p => p.PublishedAt)
.FilterWithVisibility(null, [], [], isListing: true)
.Take(take * 5); // Fetch more posts to have a good pool for ranking
.Take(take * 5);
var posts = await postsQuery.ToListAsync();
posts = await ps.LoadPostInfo(posts, null, true);
var posts = await GetAndProcessPosts(postsQuery);
posts = RankPosts(posts, take);
var postsId = posts.Select(e => e.Id).ToList();
var reactionMaps = await ps.GetPostReactionMapBatch(postsId);
foreach (var post in posts)
post.ReactionsCount =
reactionMaps.TryGetValue(post.Id, out var count) ? count : new Dictionary<string, int>();
// Rank and sort
// TODO: This feature is disabled for now
/*
var now = SystemClock.Instance.GetCurrentInstant();
var rankedPosts = posts
.Select(p => new { Post = p, Rank = CalculateHotRank(p, now) })
.OrderByDescending(x => x.Rank)
.Select(x => x.Post)
.Take(take)
.ToList(); */
// Formatting data
foreach (var post in posts)
activities.Add(post.ToActivity());
// Add posts to activities
activities.AddRange(posts.Select(post => post.ToActivity()));
if (activities.Count == 0)
activities.Add(Activity.Empty());
@@ -126,135 +80,76 @@ public class ActivityService(
Instant? cursor,
Account currentUser,
string? filter = null,
HashSet<string>? debugInclude = null
)
HashSet<string>? debugInclude = null)
{
var activities = new List<Activity>();
debugInclude ??= [];
// Get user's friends and publishers
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{
AccountId = currentUser.Id
});
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
debugInclude ??= [];
// Add discovery activities if no specific filter is applied
if (string.IsNullOrEmpty(filter))
{
// Add realm discovery if needed
if (cursor == null && (debugInclude.Contains("realms") || Random.Shared.NextDouble() < 0.2))
{
var realms = await ds.GetCommunityRealmAsync(null, 5, 0, true);
if (realms.Count > 0)
{
activities.Add(new DiscoveryActivity(
realms.Select(x => new DiscoveryItem("realm", x)).ToList()
).ToActivity());
}
var realmActivity = await GetRealmDiscoveryActivity();
if (realmActivity != null)
activities.Add(realmActivity);
}
// Add publisher discovery if needed
if (cursor == null && (debugInclude.Contains("publishers") || Random.Shared.NextDouble() < 0.2))
{
var popularPublishers = await GetPopularPublishers(5);
if (popularPublishers.Count > 0)
{
activities.Add(new DiscoveryActivity(
popularPublishers.Select(x => new DiscoveryItem("publisher", x)).ToList()
).ToActivity());
}
var publisherActivity = await GetPublisherDiscoveryActivity();
if (publisherActivity != null)
activities.Add(publisherActivity);
}
// Add article discovery if needed
if (debugInclude.Contains("articles") || Random.Shared.NextDouble() < 0.2)
{
var recentFeedIds = await db.WebArticles
.GroupBy(a => a.FeedId)
.OrderByDescending(g => g.Max(a => a.PublishedAt))
.Take(10) // Get recent 10 distinct feeds
.Select(g => g.Key)
.ToListAsync();
// For each feed, get one random article
var recentArticles = new List<WebArticle>();
var random = new Random();
foreach (var feedId in recentFeedIds.OrderBy(_ => random.Next()))
{
var article = await db.WebArticles
.Include(a => a.Feed)
.Where(a => a.FeedId == feedId)
.OrderBy(_ => EF.Functions.Random())
.FirstOrDefaultAsync();
if (article == null) continue;
recentArticles.Add(article);
if (recentArticles.Count >= 5) break; // Limit to 5 articles
}
if (recentArticles.Count > 0)
{
activities.Add(new DiscoveryActivity(
recentArticles.Select(x => new DiscoveryItem("article", x)).ToList()
).ToActivity());
}
var articleActivity = await GetArticleDiscoveryActivity();
if (articleActivity != null)
activities.Add(articleActivity);
}
}
// Get publishers based on filter
var filteredPublishers = filter switch
{
"subscriptions" => await pub.GetSubscribedPublishers(Guid.Parse(currentUser.Id)),
"friends" => (await pub.GetUserPublishersBatch(userFriends)).SelectMany(x => x.Value)
.DistinctBy(x => x.Id)
.ToList(),
_ => null
};
var filteredPublishers = await GetFilteredPublishers(filter, currentUser, userFriends);
var filteredPublishersId = filteredPublishers?.Select(e => e.Id).ToList();
// Build the query based on the filter
var postsQuery = db.Posts
.Include(e => e.RepliedPost)
.Include(e => e.ForwardedPost)
.Include(e => e.Categories)
.Include(e => e.Tags)
.Include(e => e.Realm)
.Where(e => e.RepliedPostId == null)
.Where(p => cursor == null || p.PublishedAt < cursor)
.OrderByDescending(p => p.PublishedAt)
.AsQueryable();
var userRealms = await rs.GetUserRealms(Guid.Parse(currentUser.Id));
if (filteredPublishersId is not null)
postsQuery = postsQuery.Where(p => filteredPublishersId.Contains(p.PublisherId));
// Build and execute the posts query
var postsQuery = BuildPostsQuery(cursor, filteredPublishersId, userRealms);
// Complete the query with visibility filtering and execute
var posts = await postsQuery
.FilterWithVisibility(currentUser, userFriends, filter is null ? userPublishers : [], isListing: true)
.Take(take * 5) // Fetch more posts to have a good pool for ranking
.ToListAsync();
// Apply visibility filtering and execute
postsQuery = postsQuery
.FilterWithVisibility(
currentUser,
userFriends,
filter is null ? userPublishers : [],
isListing: true)
.Take(take * 5);
posts = await ps.LoadPostInfo(posts, currentUser, true);
// Get, process and rank posts
var posts = await GetAndProcessPosts(
postsQuery,
currentUser,
userFriends,
userPublishers,
trackViews: true);
var postsId = posts.Select(e => e.Id).ToList();
var reactionMaps = await ps.GetPostReactionMapBatch(postsId);
foreach (var post in posts)
{
post.ReactionsCount =
reactionMaps.TryGetValue(post.Id, out var count) ? count : new Dictionary<string, int>();
posts = RankPosts(posts, take);
// Track view for each post in the feed
await ps.IncreaseViewCount(post.Id, currentUser.Id.ToString());
}
// Rank and sort
// TODO: This feature is disabled for now
/*
var now = SystemClock.Instance.GetCurrentInstant();
var rankedPosts = posts
.Select(p => new { Post = p, Rank = CalculateHotRank(p, now) })
.OrderByDescending(x => x.Rank)
.Select(x => x.Post)
.Take(take)
.ToList(); */
// Formatting data
// Add posts to activities
activities.AddRange(posts.Select(post => post.ToActivity()));
if (activities.Count == 0)
@@ -263,11 +158,20 @@ public class ActivityService(
return activities;
}
private static double CalculatePopularity(List<Post.Post> posts)
private static List<Post.Post> RankPosts(List<Post.Post> posts, int take)
{
var score = posts.Sum(p => p.Upvotes - p.Downvotes);
var postCount = posts.Count;
return score + postCount;
// TODO: This feature is disabled for now
// Uncomment and implement when ready
/*
var now = SystemClock.Instance.GetCurrentInstant();
return posts
.Select(p => new { Post = p, Rank = CalculateHotRank(p, now) })
.OrderByDescending(x => x.Rank)
.Select(x => x.Post)
.Take(take)
.ToList();
*/
return posts.Take(take).ToList();
}
private async Task<List<Publisher.Publisher>> GetPopularPublishers(int take)
@@ -282,7 +186,7 @@ public class ActivityService(
var publisherIds = posts.Select(p => p.PublisherId).Distinct().ToList();
var publishers = await db.Publishers.Where(p => publisherIds.Contains(p.Id)).ToListAsync();
var rankedPublishers = publishers
return publishers
.Select(p => new
{
Publisher = p,
@@ -292,7 +196,129 @@ public class ActivityService(
.Select(x => x.Publisher)
.Take(take)
.ToList();
}
return rankedPublishers;
private async Task<Activity?> GetRealmDiscoveryActivity(int count = 5)
{
var realms = await ds.GetCommunityRealmAsync(null, count, 0, true);
return realms.Count > 0
? new DiscoveryActivity(realms.Select(x => new DiscoveryItem("realm", x)).ToList()).ToActivity()
: null;
}
private async Task<Activity?> GetPublisherDiscoveryActivity(int count = 5)
{
var popularPublishers = await GetPopularPublishers(count);
return popularPublishers.Count > 0
? new DiscoveryActivity(popularPublishers.Select(x => new DiscoveryItem("publisher", x)).ToList())
.ToActivity()
: null;
}
private async Task<Activity?> GetArticleDiscoveryActivity(int count = 5, int feedSampleSize = 10)
{
var recentFeedIds = await db.WebArticles
.GroupBy(a => a.FeedId)
.OrderByDescending(g => g.Max(a => a.PublishedAt))
.Take(feedSampleSize)
.Select(g => g.Key)
.ToListAsync();
var recentArticles = new List<WebArticle>();
var random = new Random();
foreach (var feedId in recentFeedIds.OrderBy(_ => random.Next()))
{
var article = await db.WebArticles
.Include(a => a.Feed)
.Where(a => a.FeedId == feedId)
.OrderBy(_ => EF.Functions.Random())
.FirstOrDefaultAsync();
if (article == null) continue;
recentArticles.Add(article);
if (recentArticles.Count >= count) break;
}
return recentArticles.Count > 0
? new DiscoveryActivity(recentArticles.Select(x => new DiscoveryItem("article", x)).ToList()).ToActivity()
: null;
}
private async Task<List<Post.Post>> GetAndProcessPosts(
IQueryable<Post.Post> baseQuery,
Account? currentUser = null,
List<Guid>? userFriends = null,
List<Publisher.Publisher>? userPublishers = null,
bool trackViews = true)
{
var posts = await baseQuery.ToListAsync();
posts = await ps.LoadPostInfo(posts, currentUser, true);
var postsId = posts.Select(e => e.Id).ToList();
var reactionMaps = await ps.GetPostReactionMapBatch(postsId);
foreach (var post in posts)
{
post.ReactionsCount = reactionMaps.GetValueOrDefault(post.Id, new Dictionary<string, int>());
if (trackViews && currentUser != null)
{
await ps.IncreaseViewCount(post.Id, currentUser.Id.ToString());
}
}
return posts;
}
private IQueryable<Post.Post> BuildPostsQuery(
Instant? cursor,
List<Guid>? filteredPublishersId = null,
List<Guid>? userRealms = null
)
{
var query = db.Posts
.Include(e => e.RepliedPost)
.Include(e => e.ForwardedPost)
.Include(e => e.Categories)
.Include(e => e.Tags)
.Include(e => e.Realm)
.Where(e => e.RepliedPostId == null)
.Where(p => cursor == null || p.PublishedAt < cursor)
.OrderByDescending(p => p.PublishedAt)
.AsQueryable();
if (filteredPublishersId != null && filteredPublishersId.Any())
query = query.Where(p => filteredPublishersId.Contains(p.PublisherId));
if (userRealms == null)
query = query.Where(p => p.Realm == null || p.Realm.IsPublic);
else
query = query.Where(p =>
p.Realm == null || p.Realm.IsPublic || p.RealmId == null || userRealms.Contains(p.RealmId.Value));
return query;
}
private async Task<List<Publisher.Publisher>?> GetFilteredPublishers(
string? filter,
Account currentUser,
List<Guid> userFriends)
{
return filter?.ToLower() switch
{
"subscriptions" => await pub.GetSubscribedPublishers(Guid.Parse(currentUser.Id)),
"friends" => (await pub.GetUserPublishersBatch(userFriends))
.SelectMany(x => x.Value)
.DistinctBy(x => x.Id)
.ToList(),
_ => null
};
}
private static double CalculatePopularity(List<Post.Post> posts)
{
var score = posts.Sum(p => p.Upvotes - p.Downvotes);
var postCount = posts.Count;
return score + postCount;
}
}

View File

@@ -35,6 +35,7 @@ public class AppDatabase(
public DbSet<PostCategory> PostCategories { get; set; } = null!;
public DbSet<PostCollection> PostCollections { get; set; } = null!;
public DbSet<PostFeaturedRecord> PostFeaturedRecords { get; set; } = null!;
public DbSet<PostCategorySubscription> PostCategorySubscriptions { get; set; } = null!;
public DbSet<Poll.Poll> Polls { get; set; } = null!;
public DbSet<Poll.PollQuestion> PollQuestions { get; set; } = null!;
@@ -55,6 +56,7 @@ public class AppDatabase(
public DbSet<WebReader.WebArticle> WebArticles { get; set; } = null!;
public DbSet<WebReader.WebFeed> WebFeeds { get; set; } = null!;
public DbSet<WebReader.WebFeedSubscription> WebFeedSubscriptions { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{

View File

@@ -150,7 +150,7 @@ public class ChatRoomController(
[Authorize]
public async Task<ActionResult<ChatRoom>> GetDirectChatRoom(Guid accountId)
{
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser)
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var room = await db.ChatRooms
@@ -968,23 +968,29 @@ public class ChatRoomController(
private async Task _SendInviteNotify(ChatMember member, Account sender)
{
var account = await accounts.GetAccountAsync(new GetAccountRequest { Id = member.AccountId.ToString() });
CultureService.SetCultureInfo(account);
string title = localizer["ChatInviteTitle"];
string body = member.ChatRoom.Type == ChatRoomType.DirectMessage
? localizer["ChatInviteDirectBody", sender.Nick]
: localizer["ChatInviteBody", member.ChatRoom.Name ?? "Unnamed"];
CultureService.SetCultureInfo(member.Account!.Language);
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = member.Account.Id.ToString(),
UserId = account.Id,
Notification = new PushNotification
{
Topic = "invites.chats",
Title = title,
Body = body,
IsSavable = true
IsSavable = true,
Meta = GrpcTypeHelper.ConvertObjectToByteString(new
{
room_id = member.ChatRoomId
})
}
}
);

View File

@@ -69,7 +69,8 @@ public partial class ChatService(
dbMessage,
dbMessage.Sender,
dbMessage.ChatRoom,
WebSocketPacketType.MessageUpdate
WebSocketPacketType.MessageUpdate,
notify: false
);
}
}
@@ -87,8 +88,7 @@ public partial class ChatService(
/// <param name="message">The message to process</param>
/// <param name="webReader">The web reader service</param>
/// <returns>The message with link previews added to its meta data</returns>
public async Task<Message> PreviewMessageLinkAsync(Message message,
WebReader.WebReaderService? webReader = null)
public async Task<Message> PreviewMessageLinkAsync(Message message, WebReaderService? webReader = null)
{
if (string.IsNullOrEmpty(message.Content))
return message;
@@ -110,8 +110,7 @@ public partial class ChatService(
}
var embeds = (List<Dictionary<string, object>>)message.Meta["embeds"];
webReader ??= scopeFactory.CreateScope().ServiceProvider
.GetRequiredService<WebReader.WebReaderService>();
webReader ??= scopeFactory.CreateScope().ServiceProvider.GetRequiredService<WebReaderService>();
// Process up to 3 links to avoid excessive processing
var processedLinks = 0;
@@ -195,7 +194,8 @@ public partial class ChatService(
Message message,
ChatMember sender,
ChatRoom room,
string type = WebSocketPacketType.MessageNew
string type = WebSocketPacketType.MessageNew,
bool notify = true
)
{
message.Sender = sender;
@@ -205,11 +205,29 @@ public partial class ChatService(
var scopedNty = scope.ServiceProvider.GetRequiredService<PusherService.PusherServiceClient>();
var scopedCrs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
var members = await scopedCrs.ListRoomMembers(room.Id);
var request = new PushWebSocketPacketToUsersRequest
{
Packet = new WebSocketPacket
{
Type = type,
Data = GrpcTypeHelper.ConvertObjectToByteString(message),
},
};
request.UserIds.AddRange(members.Select(a => a.Account).Where(a => a is not null)
.Select(a => a!.Id.ToString()));
await scopedNty.PushWebSocketPacketToUsersAsync(request);
if (!notify)
{
logger.LogInformation($"Delivered message to {request.UserIds.Count} accounts.");
return;
}
var roomSubject = room is { Type: ChatRoomType.DirectMessage, Name: null } ? "DM" :
room.Realm is not null ? $"{room.Name}, {room.Realm.Name}" : room.Name;
var members = await scopedCrs.ListRoomMembers(room.Id);
if (sender.Account is null)
sender = await scopedCrs.LoadMemberAccount(sender);
if (sender.Account is null)
@@ -273,17 +291,6 @@ public partial class ChatService(
var now = SystemClock.Instance.GetCurrentInstant();
var request = new PushWebSocketPacketToUsersRequest
{
Packet = new WebSocketPacket
{
Type = type,
Data = GrpcTypeHelper.ConvertObjectToByteString(message),
},
};
request.UserIds.AddRange(members.Select(a => a.Account).Where(a => a is not null).Select(a => a!.Id.ToString()));
await scopedNty.PushWebSocketPacketToUsersAsync(request);
List<Account> accountsToNotify = [];
foreach (
var member in members
@@ -524,25 +531,51 @@ public partial class ChatService(
})
.ToListAsync();
var changesMembers = changes
// Get messages that need member data
var messagesNeedingSenders = changes
.Where(c => c.Message != null)
.Select(c => c.Message!.Sender)
.DistinctBy(x => x.Id)
.Select(c => c.Message!)
.ToList();
changesMembers = await crs.LoadMemberAccounts(changesMembers);
foreach (var change in changes)
// If no messages need senders, return with the latest timestamp from changes
if (messagesNeedingSenders.Count <= 0)
{
if (change.Message == null) continue;
var sender = changesMembers.FirstOrDefault(x => x.Id == change.Message.SenderId);
if (sender is not null)
change.Message.Sender = sender;
}
var latestTimestamp = changes.Count > 0
? changes.Max(c => c.Timestamp)
: SystemClock.Instance.GetCurrentInstant();
return new SyncResponse
{
Changes = changes,
CurrentTimestamp = SystemClock.Instance.GetCurrentInstant()
CurrentTimestamp = latestTimestamp
};
}
// Load member accounts for messages that need them
var changesMembers = messagesNeedingSenders
.Select(m => m.Sender)
.DistinctBy(x => x.Id)
.ToList();
changesMembers = await crs.LoadMemberAccounts(changesMembers);
// Update sender information for messages that have it
foreach (var message in messagesNeedingSenders)
{
var sender = changesMembers.FirstOrDefault(x => x.Id == message.SenderId);
if (sender is not null)
message.Sender = sender;
}
// Use the latest timestamp from changes, or current time if no changes
var latestChangeTimestamp = changes.Count > 0
? changes.Max(c => c.Timestamp)
: SystemClock.Instance.GetCurrentInstant();
return new SyncResponse
{
Changes = changes,
CurrentTimestamp = latestChangeTimestamp
};
}

View File

@@ -1,6 +1,6 @@
<template>
<div v-if="post" class="container max-w-5xl mx-auto mt-4">
<n-grid cols="1 l:5" responsive="screen" :x-gap="16">
<n-grid cols="1 l:5" responsive="screen" :x-gap="16" :y-gap="16">
<n-gi span="3">
<post-item :item="post" />
</n-gi>

View File

@@ -60,6 +60,7 @@
<PackageReference Include="StackExchange.Redis" Version="2.8.41"/>
<PackageReference Include="StackExchange.Redis.Extensions.AspNetCore" Version="11.0.0"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3"/>
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="9.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.3"/>
<PackageReference Include="System.ServiceModel.Syndication" Version="9.0.7" />
<PackageReference Include="TencentCloudSDK.Tmt" Version="3.0.1276" />

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class AddPostParentGone : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "forwarded_gone",
table: "posts",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "replied_gone",
table: "posts",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "forwarded_gone",
table: "posts");
migrationBuilder.DropColumn(
name: "replied_gone",
table: "posts");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class AddWebFeedSubscription : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "web_feed_subscriptions",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
feed_id = table.Column<Guid>(type: "uuid", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_web_feed_subscriptions", x => x.id);
table.ForeignKey(
name: "fk_web_feed_subscriptions_web_feeds_feed_id",
column: x => x.feed_id,
principalTable: "web_feeds",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_web_feed_subscriptions_feed_id",
table: "web_feed_subscriptions",
column: "feed_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "web_feed_subscriptions");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class AddPostPin : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "pin_mode",
table: "posts",
type: "integer",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "pin_mode",
table: "posts");
}
}
}

Some files were not shown because too many files have changed in this diff Show More