Compare commits
73 Commits
fc6cee17d7
...
master
Author | SHA1 | Date | |
---|---|---|---|
|
fb6721cb1b | ||
|
9fcb169c94 | ||
|
572874431d | ||
|
f595ac8001 | ||
|
18674e0e1d | ||
|
da4c4d3a84 | ||
|
aec01b117d | ||
|
d299c32e35 | ||
|
344007af66 | ||
|
d4de5aeac2 | ||
|
8ce5ba50f4 | ||
|
5a44952b27 | ||
|
c30946daf6 | ||
|
0221d7b294 | ||
|
c44b0b64c3 | ||
|
442ee3bcfd | ||
|
081815c512 | ||
|
eab2a388ae | ||
|
5f7ab49abb | ||
|
4ff89173b2 | ||
|
f2052410c7 | ||
|
83a49be725 | ||
|
9b205a73fd | ||
|
d5157eb7e3 | ||
|
75c92c51db | ||
|
915054fce0 | ||
|
63653680ba | ||
|
84c4df6620 | ||
|
8c748fd57a | ||
|
4684550ebf | ||
|
51db08f374 | ||
|
9f38a288b9 | ||
|
75a975049c | ||
|
f8c35c0350 | ||
|
d9a5fed77f | ||
|
7cb14940d9 | ||
|
953bf5d4de | ||
|
d9620fd6a4 | ||
|
541e2dd14c | ||
|
c7925d98c8 | ||
|
f759b19bcb | ||
|
5d7429a416 | ||
|
fb7e52d6f3 | ||
|
50e888b075 | ||
|
76c8bbf307 | ||
|
8f3825e92c | ||
|
d1c3610ec8 | ||
|
4b958a3c31 | ||
|
1f9021d459 | ||
|
7ad9deaf70 | ||
|
c1c17b5f4e | ||
|
d92220b4bc | ||
|
4d1972bc99 | ||
|
83c052ec4e | ||
|
57a75fe9e6 | ||
|
379bc37aff | ||
|
0217fbb13b | ||
|
4e9943e6a2 | ||
|
b3cc623168 | ||
|
3ee5e5367d | ||
|
85fef30c7f | ||
|
e8d8dcbb2d | ||
|
3b679d6134 | ||
|
ec44b51ab6 | ||
|
2e52a13c30 | ||
|
1e8e2e9ea7 | ||
|
9e8363c004 | ||
|
56c40ee001 | ||
|
e3dfccfee3 | ||
|
d555fcaf17 | ||
|
2fdefae718 | ||
|
e78858b7b4 | ||
|
636b674229 |
@@ -1,7 +1,7 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using DysonNetwork.Develop.Project;
|
using DysonNetwork.Develop.Project;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
using NodaTime;
|
|
||||||
using NodaTime.Serialization.Protobuf;
|
using NodaTime.Serialization.Protobuf;
|
||||||
|
|
||||||
namespace DysonNetwork.Develop.Identity;
|
namespace DysonNetwork.Develop.Identity;
|
||||||
@@ -15,6 +15,14 @@ public class BotAccount : ModelBase
|
|||||||
|
|
||||||
public Guid ProjectId { get; set; }
|
public Guid ProjectId { get; set; }
|
||||||
public DevProject Project { get; set; } = null!;
|
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()
|
public Shared.Proto.BotAccount ToProtoValue()
|
||||||
{
|
{
|
||||||
|
@@ -1,8 +1,13 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using DysonNetwork.Develop.Project;
|
using DysonNetwork.Develop.Project;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using DysonNetwork.Shared.Registry;
|
||||||
|
using Grpc.Core;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using NodaTime;
|
||||||
|
using NodaTime.Serialization.Protobuf;
|
||||||
|
|
||||||
namespace DysonNetwork.Develop.Identity;
|
namespace DysonNetwork.Develop.Identity;
|
||||||
|
|
||||||
@@ -13,18 +18,62 @@ public class BotAccountController(
|
|||||||
BotAccountService botService,
|
BotAccountService botService,
|
||||||
DeveloperService developerService,
|
DeveloperService developerService,
|
||||||
DevProjectService projectService,
|
DevProjectService projectService,
|
||||||
ILogger<BotAccountController> logger
|
ILogger<BotAccountController> logger,
|
||||||
|
AccountClientHelper accounts,
|
||||||
|
BotAccountReceiverService.BotAccountReceiverServiceClient accountsReceiver
|
||||||
)
|
)
|
||||||
: ControllerBase
|
: ControllerBase
|
||||||
{
|
{
|
||||||
public record BotRequest(
|
public class CommonBotRequest
|
||||||
[Required] [MaxLength(1024)] string? Slug
|
{
|
||||||
);
|
[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(32)] public string? PictureId { get; set; }
|
||||||
[MaxLength(1024)] string? Slug,
|
[MaxLength(32)] public string? BackgroundId { get; set; }
|
||||||
bool? IsActive
|
}
|
||||||
) : BotRequest(Slug);
|
|
||||||
|
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]
|
[HttpGet]
|
||||||
public async Task<IActionResult> ListBots(
|
public async Task<IActionResult> ListBots(
|
||||||
@@ -39,15 +88,15 @@ public class BotAccountController(
|
|||||||
return NotFound("Developer not found");
|
return NotFound("Developer not found");
|
||||||
|
|
||||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||||
PublisherMemberRole.Editor))
|
PublisherMemberRole.Viewer))
|
||||||
return StatusCode(403, "You must be an editor of the developer to list bots");
|
return StatusCode(403, "You must be an viewer of the developer to list bots");
|
||||||
|
|
||||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||||
if (project is null)
|
if (project is null)
|
||||||
return NotFound("Project not found or you don't have access");
|
return NotFound("Project not found or you don't have access");
|
||||||
|
|
||||||
var bots = await botService.GetBotsByProjectAsync(projectId);
|
var bots = await botService.GetBotsByProjectAsync(projectId);
|
||||||
return Ok(bots);
|
return Ok(await botService.LoadBotsAccountAsync(bots));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{botId:guid}")]
|
[HttpGet("{botId:guid}")]
|
||||||
@@ -64,8 +113,8 @@ public class BotAccountController(
|
|||||||
return NotFound("Developer not found");
|
return NotFound("Developer not found");
|
||||||
|
|
||||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||||
PublisherMemberRole.Editor))
|
PublisherMemberRole.Viewer))
|
||||||
return StatusCode(403, "You must be an editor of the developer to view bot details");
|
return StatusCode(403, "You must be an viewer of the developer to view bot details");
|
||||||
|
|
||||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||||
if (project is null)
|
if (project is null)
|
||||||
@@ -75,18 +124,16 @@ public class BotAccountController(
|
|||||||
if (bot is null || bot.ProjectId != projectId)
|
if (bot is null || bot.ProjectId != projectId)
|
||||||
return NotFound("Bot not found");
|
return NotFound("Bot not found");
|
||||||
|
|
||||||
return Ok(bot);
|
return Ok(await botService.LoadBotAccountAsync(bot));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> CreateBot(
|
public async Task<IActionResult> CreateBot(
|
||||||
[FromRoute] string pubName,
|
[FromRoute] string pubName,
|
||||||
[FromRoute] Guid projectId,
|
[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)
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
|
|
||||||
@@ -102,9 +149,43 @@ public class BotAccountController(
|
|||||||
if (project is null)
|
if (project is null)
|
||||||
return NotFound("Project not found or you don't have access");
|
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
|
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);
|
return Ok(bot);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -114,7 +195,7 @@ public class BotAccountController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{botId:guid}")]
|
[HttpPatch("{botId:guid}")]
|
||||||
public async Task<IActionResult> UpdateBot(
|
public async Task<IActionResult> UpdateBot(
|
||||||
[FromRoute] string pubName,
|
[FromRoute] string pubName,
|
||||||
[FromRoute] Guid projectId,
|
[FromRoute] Guid projectId,
|
||||||
@@ -141,12 +222,31 @@ public class BotAccountController(
|
|||||||
if (bot is null || bot.ProjectId != projectId)
|
if (bot is null || bot.ProjectId != projectId)
|
||||||
return NotFound("Bot not found");
|
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
|
try
|
||||||
{
|
{
|
||||||
var updatedBot = await botService.UpdateBotAsync(
|
var updatedBot = await botService.UpdateBotAsync(
|
||||||
bot,
|
bot,
|
||||||
request.Slug,
|
botAccount,
|
||||||
request.IsActive
|
request.PictureId,
|
||||||
|
request.BackgroundId
|
||||||
);
|
);
|
||||||
|
|
||||||
return Ok(updatedBot);
|
return Ok(updatedBot);
|
||||||
@@ -194,4 +294,167 @@ public class BotAccountController(
|
|||||||
return StatusCode(500, "An error occurred while deleting the bot account");
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
35
DysonNetwork.Develop/Identity/BotAccountPublicController.cs
Normal file
35
DysonNetwork.Develop/Identity/BotAccountPublicController.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@@ -1,13 +1,18 @@
|
|||||||
using DysonNetwork.Develop.Project;
|
using DysonNetwork.Develop.Project;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using DysonNetwork.Shared.Registry;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
|
||||||
using NodaTime.Serialization.Protobuf;
|
using NodaTime.Serialization.Protobuf;
|
||||||
|
|
||||||
namespace DysonNetwork.Develop.Identity;
|
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)
|
public async Task<BotAccount?> GetBotByIdAsync(Guid id)
|
||||||
{
|
{
|
||||||
@@ -23,39 +28,30 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
|
|||||||
.ToListAsync();
|
.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
|
// First, check if a bot with this slug already exists in this project
|
||||||
var existingBot = await db.BotAccounts
|
var existingBot = await db.BotAccounts
|
||||||
.FirstOrDefaultAsync(b => b.ProjectId == project.Id && b.Slug == slug);
|
.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();
|
if (existingBot != null)
|
||||||
|
throw new InvalidOperationException("A bot with this slug already exists in this project.");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// First create the bot account in the Pass service
|
var automatedId = Guid.NewGuid();
|
||||||
var createRequest = new CreateBotAccountRequest
|
var createRequest = new CreateBotAccountRequest
|
||||||
{
|
{
|
||||||
AutomatedId = Guid.NewGuid().ToString(),
|
AutomatedId = automatedId.ToString(),
|
||||||
Account = new Account
|
Account = account,
|
||||||
{
|
PictureId = pictureId,
|
||||||
Name = slug,
|
BackgroundId = backgroundId
|
||||||
Nick = $"Bot {slug}",
|
|
||||||
Language = "en",
|
|
||||||
Profile = new AccountProfile
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid().ToString(),
|
|
||||||
CreatedAt = now.ToTimestamp(),
|
|
||||||
UpdatedAt = now.ToTimestamp()
|
|
||||||
},
|
|
||||||
CreatedAt = now.ToTimestamp(),
|
|
||||||
UpdatedAt = now.ToTimestamp()
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var createResponse = await accountReceiver.CreateBotAccountAsync(createRequest);
|
var createResponse = await accountReceiver.CreateBotAccountAsync(createRequest);
|
||||||
@@ -64,7 +60,7 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
|
|||||||
// Then create the local bot account
|
// Then create the local bot account
|
||||||
var bot = new BotAccount
|
var bot = new BotAccount
|
||||||
{
|
{
|
||||||
Id = Guid.Parse(botAccount.AutomatedId),
|
Id = automatedId,
|
||||||
Slug = slug,
|
Slug = slug,
|
||||||
ProjectId = project.Id,
|
ProjectId = project.Id,
|
||||||
Project = project,
|
Project = project,
|
||||||
@@ -80,7 +76,8 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
|
|||||||
}
|
}
|
||||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.AlreadyExists)
|
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)
|
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;
|
db.Update(bot);
|
||||||
if (slug != null && bot.Slug != slug)
|
await db.SaveChangesAsync();
|
||||||
{
|
|
||||||
bot.Slug = slug;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isActive.HasValue && bot.IsActive != isActive.Value)
|
|
||||||
{
|
|
||||||
bot.IsActive = isActive.Value;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!updated) return bot;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -115,12 +105,9 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
|
|||||||
var updateRequest = new UpdateBotAccountRequest
|
var updateRequest = new UpdateBotAccountRequest
|
||||||
{
|
{
|
||||||
AutomatedId = bot.Id.ToString(),
|
AutomatedId = bot.Id.ToString(),
|
||||||
Account = new Shared.Proto.Account
|
Account = account,
|
||||||
{
|
PictureId = pictureId,
|
||||||
Name = $"bot-{bot.Slug}",
|
BackgroundId = backgroundId
|
||||||
Nick = $"Bot {bot.Slug}",
|
|
||||||
UpdatedAt = SystemClock.Instance.GetCurrentInstant().ToTimestamp()
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var updateResponse = await accountReceiver.UpdateBotAccountAsync(updateRequest);
|
var updateResponse = await accountReceiver.UpdateBotAccountAsync(updateRequest);
|
||||||
@@ -160,9 +147,28 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
|
|||||||
{
|
{
|
||||||
// Account not found in Pass service, continue with local deletion
|
// Account not found in Pass service, continue with local deletion
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the local bot account
|
// Delete the local bot account
|
||||||
db.BotAccounts.Remove(bot);
|
db.BotAccounts.Remove(bot);
|
||||||
await db.SaveChangesAsync();
|
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> ?? [];
|
||||||
|
}
|
||||||
|
}
|
@@ -32,7 +32,7 @@ public class CustomApp : ModelBase, IIdentifiedResource
|
|||||||
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
|
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
|
||||||
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
|
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
|
||||||
|
|
||||||
[Column(TypeName = "jsonb")] public 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 CustomAppOauthConfig? OauthConfig { get; set; }
|
||||||
[Column(TypeName = "jsonb")] public CustomAppLinks? Links { 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,
|
CustomAppStatus.Suspended => Shared.Proto.CustomAppStatus.Suspended,
|
||||||
_ => Shared.Proto.CustomAppStatus.Unspecified
|
_ => Shared.Proto.CustomAppStatus.Unspecified
|
||||||
},
|
},
|
||||||
Picture = Picture is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Picture)),
|
Picture = Picture?.ToProtoValue(),
|
||||||
Background = Background is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Background)),
|
Background = Background?.ToProtoValue(),
|
||||||
Verification = Verification is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Verification)),
|
Verification = Verification?.ToProtoValue(),
|
||||||
Links = Links is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Links)),
|
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
|
OauthConfig = OauthConfig is null ? null : new DysonNetwork.Shared.Proto.CustomAppOauthConfig
|
||||||
{
|
{
|
||||||
ClientUri = OauthConfig.ClientUri ?? string.Empty,
|
ClientUri = OauthConfig.ClientUri ?? string.Empty,
|
||||||
RedirectUris = { OauthConfig.RedirectUris ?? Array.Empty<string>() },
|
RedirectUris = { OauthConfig.RedirectUris ?? [] },
|
||||||
PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? Array.Empty<string>() },
|
PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? [] },
|
||||||
AllowedScopes = { OauthConfig.AllowedScopes ?? Array.Empty<string>() },
|
AllowedScopes = { OauthConfig.AllowedScopes ?? [] },
|
||||||
AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? Array.Empty<string>() },
|
AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? [] },
|
||||||
RequirePkce = OauthConfig.RequirePkce,
|
RequirePkce = OauthConfig.RequirePkce,
|
||||||
AllowOfflineAccess = OauthConfig.AllowOfflineAccess
|
AllowOfflineAccess = OauthConfig.AllowOfflineAccess
|
||||||
},
|
},
|
||||||
@@ -99,10 +104,18 @@ public class CustomApp : ModelBase, IIdentifiedResource
|
|||||||
ProjectId = string.IsNullOrEmpty(p.ProjectId) ? Guid.Empty : Guid.Parse(p.ProjectId);
|
ProjectId = string.IsNullOrEmpty(p.ProjectId) ? Guid.Empty : Guid.Parse(p.ProjectId);
|
||||||
CreatedAt = p.CreatedAt.ToInstant();
|
CreatedAt = p.CreatedAt.ToInstant();
|
||||||
UpdatedAt = p.UpdatedAt.ToInstant();
|
UpdatedAt = p.UpdatedAt.ToInstant();
|
||||||
if (p.Picture.Length > 0) Picture = System.Text.Json.JsonSerializer.Deserialize<CloudFileReferenceObject>(p.Picture.ToStringUtf8());
|
if (p.Picture is not null) Picture = CloudFileReferenceObject.FromProtoValue(p.Picture);
|
||||||
if (p.Background.Length > 0) Background = System.Text.Json.JsonSerializer.Deserialize<CloudFileReferenceObject>(p.Background.ToStringUtf8());
|
if (p.Background is not null) Background = CloudFileReferenceObject.FromProtoValue(p.Background);
|
||||||
if (p.Verification.Length > 0) Verification = System.Text.Json.JsonSerializer.Deserialize<DysonNetwork.Shared.Data.VerificationMark>(p.Verification.ToStringUtf8());
|
if (p.Verification is not null) Verification = VerificationMark.FromProtoValue(p.Verification);
|
||||||
if (p.Links.Length > 0) Links = System.Text.Json.JsonSerializer.Deserialize<CustomAppLinks>(p.Links.ToStringUtf8());
|
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;
|
return this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -3,12 +3,14 @@ using DysonNetwork.Develop.Project;
|
|||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
namespace DysonNetwork.Develop.Identity;
|
namespace DysonNetwork.Develop.Identity;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/api/developers/{pubName}/projects/{projectId:guid}/apps")]
|
[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(
|
public record CustomAppRequest(
|
||||||
[MaxLength(1024)] string? Slug,
|
[MaxLength(1024)] string? Slug,
|
||||||
@@ -21,25 +23,58 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
CustomAppOauthConfig? OauthConfig
|
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]
|
[HttpGet]
|
||||||
|
[Authorize]
|
||||||
public async Task<IActionResult> ListApps([FromRoute] string pubName, [FromRoute] Guid projectId)
|
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);
|
var developer = await ds.GetDeveloperByName(pubName);
|
||||||
if (developer is null) return NotFound();
|
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);
|
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||||
if (project is null) return NotFound();
|
if (project is null) return NotFound();
|
||||||
|
|
||||||
var apps = await customApps.GetAppsByProjectAsync(projectId);
|
var apps = await customApps.GetAppsByProjectAsync(projectId);
|
||||||
return Ok(apps);
|
return Ok(apps);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{appId:guid}")]
|
[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);
|
var developer = await ds.GetDeveloperByName(pubName);
|
||||||
if (developer is null) return NotFound();
|
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);
|
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||||
if (project is null) return NotFound();
|
if (project is null) return NotFound();
|
||||||
|
|
||||||
@@ -53,18 +88,20 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> CreateApp(
|
public async Task<IActionResult> CreateApp(
|
||||||
[FromRoute] string pubName,
|
[FromRoute] string pubName,
|
||||||
[FromRoute] Guid projectId,
|
[FromRoute] Guid projectId,
|
||||||
[FromBody] CustomAppRequest request)
|
[FromBody] CustomAppRequest request)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
|
|
||||||
var developer = await ds.GetDeveloperByName(pubName);
|
var developer = await ds.GetDeveloperByName(pubName);
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
if (developer is null)
|
||||||
if (developer is null || developer.Id != accountId)
|
return NotFound("Developer not found");
|
||||||
return Forbid();
|
|
||||||
|
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);
|
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||||
if (project is null)
|
if (project is null)
|
||||||
return NotFound("Project not found or you don't have access");
|
return NotFound("Project not found or you don't have access");
|
||||||
@@ -72,17 +109,14 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Slug))
|
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Slug))
|
||||||
return BadRequest("Name and slug are required");
|
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
|
try
|
||||||
{
|
{
|
||||||
var app = await customApps.CreateAppAsync(projectId, request);
|
var app = await customApps.CreateAppAsync(projectId, request);
|
||||||
if (app == null)
|
if (app == null)
|
||||||
return BadRequest("Failed to create app");
|
return BadRequest("Failed to create app");
|
||||||
|
|
||||||
return CreatedAtAction(
|
return CreatedAtAction(
|
||||||
nameof(GetApp),
|
nameof(GetApp),
|
||||||
new { pubName, projectId, appId = app.Id },
|
new { pubName, projectId, appId = app.Id },
|
||||||
app
|
app
|
||||||
);
|
);
|
||||||
@@ -102,16 +136,16 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
[FromBody] CustomAppRequest request
|
[FromBody] CustomAppRequest request
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
|
|
||||||
var developer = await ds.GetDeveloperByName(pubName);
|
var developer = await ds.GetDeveloperByName(pubName);
|
||||||
if (developer is null)
|
if (developer is null)
|
||||||
return NotFound("Developer not found");
|
return NotFound("Developer not found");
|
||||||
|
|
||||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
||||||
return StatusCode(403, "You must be an editor of the developer to update a custom app");
|
return StatusCode(403, "You must be an editor of the developer to update a custom app");
|
||||||
|
|
||||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||||
if (project is null)
|
if (project is null)
|
||||||
return NotFound("Project not found or you don't have access");
|
return NotFound("Project not found or you don't have access");
|
||||||
@@ -139,16 +173,16 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
[FromRoute] Guid appId
|
[FromRoute] Guid appId
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
|
|
||||||
var developer = await ds.GetDeveloperByName(pubName);
|
var developer = await ds.GetDeveloperByName(pubName);
|
||||||
if (developer is null)
|
if (developer is null)
|
||||||
return NotFound("Developer not found");
|
return NotFound("Developer not found");
|
||||||
|
|
||||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
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 a custom app");
|
return StatusCode(403, "You must be an editor of the developer to delete a custom app");
|
||||||
|
|
||||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||||
if (project is null)
|
if (project is null)
|
||||||
return NotFound("Project not found or you don't have access");
|
return NotFound("Project not found or you don't have access");
|
||||||
@@ -160,7 +194,238 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
var result = await customApps.DeleteAppAsync(appId);
|
var result = await customApps.DeleteAppAsync(appId);
|
||||||
if (!result)
|
if (!result)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
return NoContent();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@@ -2,6 +2,8 @@ using DysonNetwork.Develop.Project;
|
|||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace DysonNetwork.Develop.Identity;
|
namespace DysonNetwork.Develop.Identity;
|
||||||
|
|
||||||
@@ -94,6 +96,87 @@ public class CustomAppService(
|
|||||||
return await query.FirstOrDefaultAsync(a => a.Id == id);
|
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)
|
public async Task<List<CustomApp>> GetAppsByProjectAsync(Guid projectId)
|
||||||
{
|
{
|
||||||
return await db.CustomApps
|
return await db.CustomApps
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using DysonNetwork.Develop.Project;
|
using DysonNetwork.Develop.Project;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
@@ -11,7 +12,7 @@ public class Developer
|
|||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
public Guid PublisherId { get; set; }
|
public Guid PublisherId { get; set; }
|
||||||
|
|
||||||
public List<DevProject> Projects { get; set; } = [];
|
[JsonIgnore] public List<DevProject> Projects { get; set; } = [];
|
||||||
|
|
||||||
[NotMapped] public PublisherInfo? Publisher { get; set; }
|
[NotMapped] public PublisherInfo? Publisher { get; set; }
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,10 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace DysonNetwork.Develop.Identity;
|
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)
|
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)
|
public async Task<bool> IsMemberWithRole(Guid pubId, Guid accountId, PublisherMemberRole role)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
324
DysonNetwork.Develop/Migrations/20250819163227_AddBotAccount.Designer.cs
generated
Normal file
324
DysonNetwork.Develop/Migrations/20250819163227_AddBotAccount.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -25,6 +25,48 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
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 =>
|
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -216,6 +258,18 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
b.ToTable("dev_projects", (string)null);
|
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 =>
|
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
|
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
|
||||||
|
@@ -3,6 +3,7 @@ using DysonNetwork.Shared.Auth;
|
|||||||
using DysonNetwork.Shared.Http;
|
using DysonNetwork.Shared.Http;
|
||||||
using DysonNetwork.Shared.Registry;
|
using DysonNetwork.Shared.Registry;
|
||||||
using DysonNetwork.Develop.Startup;
|
using DysonNetwork.Develop.Startup;
|
||||||
|
using DysonNetwork.Shared.Stream;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -10,6 +11,7 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
builder.ConfigureAppKestrel(builder.Configuration);
|
builder.ConfigureAppKestrel(builder.Configuration);
|
||||||
|
|
||||||
builder.Services.AddRegistryService(builder.Configuration);
|
builder.Services.AddRegistryService(builder.Configuration);
|
||||||
|
builder.Services.AddStreamConnection(builder.Configuration);
|
||||||
builder.Services.AddAppServices(builder.Configuration);
|
builder.Services.AddAppServices(builder.Configuration);
|
||||||
builder.Services.AddAppAuthentication();
|
builder.Services.AddAppAuthentication();
|
||||||
builder.Services.AddAppSwagger();
|
builder.Services.AddAppSwagger();
|
||||||
|
@@ -12,7 +12,8 @@
|
|||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"App": "Host=localhost;Port=5432;Database=dyson_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
|
"App": "Host=localhost;Port=5432;Database=dyson_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
|
||||||
"FastRetrieve": "localhost:6379",
|
"FastRetrieve": "localhost:6379",
|
||||||
"Etcd": "etcd.orb.local:2379"
|
"Etcd": "etcd.orb.local:2379",
|
||||||
|
"Stream": "nats.orb.local:4222"
|
||||||
},
|
},
|
||||||
"KnownProxies": [
|
"KnownProxies": [
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
@@ -23,7 +24,7 @@
|
|||||||
},
|
},
|
||||||
"Service": {
|
"Service": {
|
||||||
"Name": "DysonNetwork.Develop",
|
"Name": "DysonNetwork.Develop",
|
||||||
"Url": "https://localhost:7099",
|
"Url": "https://localhost:7192",
|
||||||
"ClientCert": "../Certificates/client.crt",
|
"ClientCert": "../Certificates/client.crt",
|
||||||
"ClientKey": "../Certificates/client.key"
|
"ClientKey": "../Certificates/client.key"
|
||||||
}
|
}
|
||||||
|
404
DysonNetwork.Drive/Migrations/20250819164302_RemoveUploadedTo.Designer.cs
generated
Normal file
404
DysonNetwork.Drive/Migrations/20250819164302_RemoveUploadedTo.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -172,11 +172,6 @@ namespace DysonNetwork.Drive.Migrations
|
|||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("uploaded_at");
|
.HasColumnName("uploaded_at");
|
||||||
|
|
||||||
b.Property<string>("UploadedTo")
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)")
|
|
||||||
.HasColumnName("uploaded_to");
|
|
||||||
|
|
||||||
b.Property<Dictionary<string, object>>("UserMeta")
|
b.Property<Dictionary<string, object>>("UserMeta")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("user_meta");
|
.HasColumnName("user_meta");
|
||||||
@@ -382,7 +377,7 @@ namespace DysonNetwork.Drive.Migrations
|
|||||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
|
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
|
||||||
.WithMany()
|
.WithMany("References")
|
||||||
.HasForeignKey("FileId")
|
.HasForeignKey("FileId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
@@ -391,6 +386,11 @@ namespace DysonNetwork.Drive.Migrations
|
|||||||
b.Navigation("File");
|
b.Navigation("File");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("References");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
|
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Files");
|
b.Navigation("Files");
|
||||||
|
@@ -5,6 +5,7 @@ using DysonNetwork.Shared.Auth;
|
|||||||
using DysonNetwork.Shared.Http;
|
using DysonNetwork.Shared.Http;
|
||||||
using DysonNetwork.Shared.PageData;
|
using DysonNetwork.Shared.PageData;
|
||||||
using DysonNetwork.Shared.Registry;
|
using DysonNetwork.Shared.Registry;
|
||||||
|
using DysonNetwork.Shared.Stream;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using tusdotnet.Stores;
|
using tusdotnet.Stores;
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxV
|
|||||||
|
|
||||||
// Add application services
|
// Add application services
|
||||||
builder.Services.AddRegistryService(builder.Configuration);
|
builder.Services.AddRegistryService(builder.Configuration);
|
||||||
|
builder.Services.AddStreamConnection(builder.Configuration);
|
||||||
builder.Services.AddAppServices(builder.Configuration);
|
builder.Services.AddAppServices(builder.Configuration);
|
||||||
builder.Services.AddAppRateLimiting();
|
builder.Services.AddAppRateLimiting();
|
||||||
builder.Services.AddAppAuthentication();
|
builder.Services.AddAppAuthentication();
|
||||||
|
56
DysonNetwork.Drive/Startup/BroadcastEventHandler.cs
Normal file
56
DysonNetwork.Drive/Startup/BroadcastEventHandler.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -140,6 +140,8 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<Storage.FileReferenceService>();
|
services.AddScoped<Storage.FileReferenceService>();
|
||||||
services.AddScoped<Billing.UsageService>();
|
services.AddScoped<Billing.UsageService>();
|
||||||
services.AddScoped<Billing.QuotaService>();
|
services.AddScoped<Billing.QuotaService>();
|
||||||
|
|
||||||
|
services.AddHostedService<BroadcastEventHandler>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
@@ -33,10 +33,6 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
|||||||
[JsonIgnore] public FileBundle? Bundle { get; set; }
|
[JsonIgnore] public FileBundle? Bundle { get; set; }
|
||||||
public Guid? BundleId { get; set; }
|
public Guid? BundleId { get; set; }
|
||||||
|
|
||||||
[Obsolete("Deprecated, use PoolId instead. For database migration only.")]
|
|
||||||
[MaxLength(128)]
|
|
||||||
public string? UploadedTo { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The field is set to true if the recycling job plans to delete the file.
|
/// 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.
|
/// Due to the unstable of the recycling job, this doesn't really delete the file until a human verifies it.
|
||||||
@@ -60,6 +56,8 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
|||||||
[NotMapped]
|
[NotMapped]
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public string? FastUploadLink { get; set; }
|
public string? FastUploadLink { get; set; }
|
||||||
|
|
||||||
|
public ICollection<CloudFileReference> References { get; set; } = new List<CloudFileReference>();
|
||||||
|
|
||||||
public Guid AccountId { get; set; }
|
public Guid AccountId { get; set; }
|
||||||
|
|
||||||
|
@@ -190,10 +190,8 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
|
|||||||
.Where(r => r.ResourceId == resourceId && r.Usage == usage)
|
.Where(r => r.ResourceId == resourceId && r.Usage == usage)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
if (!references.Any())
|
if (references.Count == 0)
|
||||||
{
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
|
||||||
|
|
||||||
var fileIds = references.Select(r => r.FileId).Distinct().ToList();
|
var fileIds = references.Select(r => r.FileId).Distinct().ToList();
|
||||||
|
|
||||||
@@ -207,6 +205,28 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
|
|||||||
|
|
||||||
return deletedCount;
|
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>
|
/// <summary>
|
||||||
/// Deletes a specific file reference
|
/// Deletes a specific file reference
|
||||||
|
@@ -85,7 +85,7 @@ namespace DysonNetwork.Drive.Storage
|
|||||||
public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferences(
|
public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferences(
|
||||||
DeleteResourceReferencesRequest request, ServerCallContext context)
|
DeleteResourceReferencesRequest request, ServerCallContext context)
|
||||||
{
|
{
|
||||||
var deletedCount = 0;
|
int deletedCount;
|
||||||
if (request.Usage is null)
|
if (request.Usage is null)
|
||||||
deletedCount = await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId);
|
deletedCount = await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId);
|
||||||
else
|
else
|
||||||
@@ -93,6 +93,18 @@ namespace DysonNetwork.Drive.Storage
|
|||||||
await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId, request.Usage!);
|
await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId, request.Usage!);
|
||||||
return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
|
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,
|
public override async Task<DeleteReferenceResponse> DeleteReference(DeleteReferenceRequest request,
|
||||||
ServerCallContext context)
|
ServerCallContext context)
|
||||||
|
@@ -102,6 +102,7 @@ public class FileService(
|
|||||||
|
|
||||||
private static readonly string[] AnimatedImageTypes =
|
private static readonly string[] AnimatedImageTypes =
|
||||||
["image/gif", "image/apng", "image/avif"];
|
["image/gif", "image/apng", "image/avif"];
|
||||||
|
|
||||||
private static readonly string[] AnimatedImageExtensions =
|
private static readonly string[] AnimatedImageExtensions =
|
||||||
[".gif", ".apng", ".avif"];
|
[".gif", ".apng", ".avif"];
|
||||||
|
|
||||||
@@ -278,15 +279,15 @@ public class FileService(
|
|||||||
s.Rotation
|
s.Rotation
|
||||||
}).Where(s => double.IsNormal(s.AvgFrameRate)).ToList(),
|
}).Where(s => double.IsNormal(s.AvgFrameRate)).ToList(),
|
||||||
["audio_streams"] = mediaInfo.AudioStreams.Select(s => new
|
["audio_streams"] = mediaInfo.AudioStreams.Select(s => new
|
||||||
{
|
{
|
||||||
s.BitRate,
|
s.BitRate,
|
||||||
s.Channels,
|
s.Channels,
|
||||||
s.ChannelLayout,
|
s.ChannelLayout,
|
||||||
s.CodecName,
|
s.CodecName,
|
||||||
s.Duration,
|
s.Duration,
|
||||||
s.Language,
|
s.Language,
|
||||||
s.SampleRateHz
|
s.SampleRateHz
|
||||||
})
|
})
|
||||||
.ToList(),
|
.ToList(),
|
||||||
};
|
};
|
||||||
if (mediaInfo.PrimaryVideoStream is not null)
|
if (mediaInfo.PrimaryVideoStream is not null)
|
||||||
@@ -336,7 +337,14 @@ public class FileService(
|
|||||||
if (!pool.PolicyConfig.NoOptimization)
|
if (!pool.PolicyConfig.NoOptimization)
|
||||||
switch (contentType.Split('/')[0])
|
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";
|
newMimeType = "image/webp";
|
||||||
using (var vipsImage = Image.NewFromFile(originalFilePath))
|
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
|
var bundle = await db.Bundles
|
||||||
.Where(e => e.Id == id)
|
.Where(e => e.Id == id)
|
||||||
@@ -880,4 +925,4 @@ file class UpdatableCloudFile(CloudFile file)
|
|||||||
.SetProperty(f => f.UserMeta, userMeta!)
|
.SetProperty(f => f.UserMeta, userMeta!)
|
||||||
.SetProperty(f => f.IsMarkedRecycle, IsMarkedRecycle);
|
.SetProperty(f => f.IsMarkedRecycle, IsMarkedRecycle);
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -12,7 +12,8 @@
|
|||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
|
"App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
|
||||||
"FastRetrieve": "localhost:6379",
|
"FastRetrieve": "localhost:6379",
|
||||||
"Etcd": "etcd.orb.local:2379"
|
"Etcd": "etcd.orb.local:2379",
|
||||||
|
"Stream": "nats.orb.local:4222"
|
||||||
},
|
},
|
||||||
"Authentication": {
|
"Authentication": {
|
||||||
"Schemes": {
|
"Schemes": {
|
||||||
|
@@ -20,7 +20,7 @@ public class Account : ModelBase
|
|||||||
[MaxLength(32)] public string Language { get; set; } = string.Empty;
|
[MaxLength(32)] public string Language { get; set; } = string.Empty;
|
||||||
public Instant? ActivatedAt { get; set; }
|
public Instant? ActivatedAt { get; set; }
|
||||||
public bool IsSuperuser { get; set; } = false;
|
public bool IsSuperuser { get; set; } = false;
|
||||||
|
|
||||||
// The ID is the BotAccount ID in the DysonNetwork.Develop
|
// The ID is the BotAccount ID in the DysonNetwork.Develop
|
||||||
public Guid? AutomatedId { get; set; }
|
public Guid? AutomatedId { get; set; }
|
||||||
|
|
||||||
@@ -51,7 +51,8 @@ public class Account : ModelBase
|
|||||||
Profile = Profile.ToProtoValue(),
|
Profile = Profile.ToProtoValue(),
|
||||||
PerkSubscription = PerkSubscription?.ToProtoValue(),
|
PerkSubscription = PerkSubscription?.ToProtoValue(),
|
||||||
CreatedAt = CreatedAt.ToTimestamp(),
|
CreatedAt = CreatedAt.ToTimestamp(),
|
||||||
UpdatedAt = UpdatedAt.ToTimestamp()
|
UpdatedAt = UpdatedAt.ToTimestamp(),
|
||||||
|
AutomatedId = AutomatedId?.ToString()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add contacts
|
// Add contacts
|
||||||
@@ -81,10 +82,10 @@ public class Account : ModelBase
|
|||||||
: null,
|
: null,
|
||||||
CreatedAt = proto.CreatedAt.ToInstant(),
|
CreatedAt = proto.CreatedAt.ToInstant(),
|
||||||
UpdatedAt = proto.UpdatedAt.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)
|
foreach (var contactProto in proto.Contacts)
|
||||||
account.Contacts.Add(AccountContact.FromProtoValue(contactProto));
|
account.Contacts.Add(AccountContact.FromProtoValue(contactProto));
|
||||||
|
|
||||||
@@ -119,7 +120,7 @@ public abstract class Leveling
|
|||||||
|
|
||||||
public class AccountProfile : ModelBase, IIdentifiedResource
|
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? FirstName { get; set; }
|
||||||
[MaxLength(256)] public string? MiddleName { get; set; }
|
[MaxLength(256)] public string? MiddleName { get; set; }
|
||||||
[MaxLength(256)] public string? LastName { 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 VerificationMark? Verification { get; set; }
|
||||||
[Column(TypeName = "jsonb")] public BadgeReferenceObject? ActiveBadge { 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;
|
[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]
|
[NotMapped]
|
||||||
public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1
|
public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1
|
||||||
? 100
|
? 100
|
||||||
@@ -168,6 +180,8 @@ public class AccountProfile : ModelBase, IIdentifiedResource
|
|||||||
Experience = Experience,
|
Experience = Experience,
|
||||||
Level = Level,
|
Level = Level,
|
||||||
LevelingProgress = LevelingProgress,
|
LevelingProgress = LevelingProgress,
|
||||||
|
SocialCredits = SocialCredits,
|
||||||
|
SocialCreditsLevel = SocialCreditsLevel,
|
||||||
Picture = Picture?.ToProtoValue(),
|
Picture = Picture?.ToProtoValue(),
|
||||||
Background = Background?.ToProtoValue(),
|
Background = Background?.ToProtoValue(),
|
||||||
AccountId = AccountId.ToString(),
|
AccountId = AccountId.ToString(),
|
||||||
@@ -198,6 +212,7 @@ public class AccountProfile : ModelBase, IIdentifiedResource
|
|||||||
Verification = proto.Verification is null ? null : VerificationMark.FromProtoValue(proto.Verification),
|
Verification = proto.Verification is null ? null : VerificationMark.FromProtoValue(proto.Verification),
|
||||||
ActiveBadge = proto.ActiveBadge is null ? null : BadgeReferenceObject.FromProtoValue(proto.ActiveBadge),
|
ActiveBadge = proto.ActiveBadge is null ? null : BadgeReferenceObject.FromProtoValue(proto.ActiveBadge),
|
||||||
Experience = proto.Experience,
|
Experience = proto.Experience,
|
||||||
|
SocialCredits = proto.SocialCredits,
|
||||||
Picture = proto.Picture is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Picture),
|
Picture = proto.Picture is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Picture),
|
||||||
Background = proto.Background is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Background),
|
Background = proto.Background is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Background),
|
||||||
AccountId = Guid.Parse(proto.AccountId),
|
AccountId = Guid.Parse(proto.AccountId),
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using DysonNetwork.Pass.Auth;
|
using DysonNetwork.Pass.Auth;
|
||||||
|
using DysonNetwork.Pass.Credit;
|
||||||
using DysonNetwork.Pass.Wallet;
|
using DysonNetwork.Pass.Wallet;
|
||||||
using DysonNetwork.Shared.Error;
|
using DysonNetwork.Shared.Error;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -15,7 +16,8 @@ public class AccountController(
|
|||||||
AuthService auth,
|
AuthService auth,
|
||||||
AccountService accounts,
|
AccountService accounts,
|
||||||
SubscriptionService subscriptions,
|
SubscriptionService subscriptions,
|
||||||
AccountEventService events
|
AccountEventService events,
|
||||||
|
SocialCreditService socialCreditService
|
||||||
) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("{name}")]
|
[HttpGet("{name}")]
|
||||||
@@ -48,6 +50,25 @@ public class AccountController(
|
|||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
return account is null ? NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier)) : account.Badges.ToList();
|
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
|
public class AccountCreateRequest
|
||||||
{
|
{
|
||||||
|
@@ -24,11 +24,13 @@ public class AccountCurrentController(
|
|||||||
AccountEventService events,
|
AccountEventService events,
|
||||||
AuthService auth,
|
AuthService auth,
|
||||||
FileService.FileServiceClient files,
|
FileService.FileServiceClient files,
|
||||||
FileReferenceService.FileReferenceServiceClient fileRefs
|
FileReferenceService.FileReferenceServiceClient fileRefs,
|
||||||
|
Credit.SocialCreditService creditService
|
||||||
) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
|
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ApiError>(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<ActionResult<Account>> GetCurrentIdentity()
|
public async Task<ActionResult<Account>> GetCurrentIdentity()
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
@@ -268,7 +270,9 @@ public class AccountCurrentController(
|
|||||||
.OrderByDescending(x => x.CreatedAt)
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
.FirstOrDefaultAsync();
|
.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")]
|
[HttpPost("check-in")]
|
||||||
@@ -323,10 +327,11 @@ public class AccountCurrentController(
|
|||||||
TraceId = HttpContext.TraceIdentifier
|
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)),
|
["captchaToken"] = new[] { "Invalid captcha token." }
|
||||||
|
}, traceId: HttpContext.TraceIdentifier)),
|
||||||
_ => await events.CheckInDaily(currentUser, backdated)
|
_ => await events.CheckInDaily(currentUser, backdated)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -823,4 +828,60 @@ public class AccountCurrentController(
|
|||||||
return BadRequest(ex.Message);
|
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);
|
||||||
|
}
|
||||||
|
}
|
@@ -15,7 +15,8 @@ public class AccountEventService(
|
|||||||
ICacheService cache,
|
ICacheService cache,
|
||||||
IStringLocalizer<Localization.AccountEventResource> localizer,
|
IStringLocalizer<Localization.AccountEventResource> localizer,
|
||||||
PusherService.PusherServiceClient pusher,
|
PusherService.PusherServiceClient pusher,
|
||||||
SubscriptionService subscriptions
|
SubscriptionService subscriptions,
|
||||||
|
Pass.Leveling.ExperienceService experienceService
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
private static readonly Random Random = new();
|
private static readonly Random Random = new();
|
||||||
@@ -327,13 +328,15 @@ public class AccountEventService(
|
|||||||
result.RewardPoints = null;
|
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);
|
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
|
// The lock will be automatically released by the await using statement
|
||||||
return result;
|
return result;
|
||||||
|
@@ -1,14 +1,18 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
using DysonNetwork.Pass.Auth;
|
using DysonNetwork.Pass.Auth;
|
||||||
using DysonNetwork.Pass.Auth.OpenId;
|
using DysonNetwork.Pass.Auth.OpenId;
|
||||||
using DysonNetwork.Pass.Email;
|
using DysonNetwork.Pass.Email;
|
||||||
using DysonNetwork.Pass.Localization;
|
using DysonNetwork.Pass.Localization;
|
||||||
using DysonNetwork.Pass.Permission;
|
using DysonNetwork.Pass.Permission;
|
||||||
using DysonNetwork.Shared.Cache;
|
using DysonNetwork.Shared.Cache;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using DysonNetwork.Shared.Stream;
|
||||||
using EFCore.BulkExtensions;
|
using EFCore.BulkExtensions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
|
using NATS.Client.Core;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using OtpNet;
|
using OtpNet;
|
||||||
using AuthService = DysonNetwork.Pass.Auth.AuthService;
|
using AuthService = DysonNetwork.Pass.Auth.AuthService;
|
||||||
@@ -18,12 +22,15 @@ namespace DysonNetwork.Pass.Account;
|
|||||||
public class AccountService(
|
public class AccountService(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
MagicSpellService spells,
|
MagicSpellService spells,
|
||||||
|
FileService.FileServiceClient files,
|
||||||
|
FileReferenceService.FileReferenceServiceClient fileRefs,
|
||||||
AccountUsernameService uname,
|
AccountUsernameService uname,
|
||||||
EmailService mailer,
|
EmailService mailer,
|
||||||
PusherService.PusherServiceClient pusher,
|
PusherService.PusherServiceClient pusher,
|
||||||
IStringLocalizer<NotificationResource> localizer,
|
IStringLocalizer<NotificationResource> localizer,
|
||||||
ICacheService cache,
|
ICacheService cache,
|
||||||
ILogger<AccountService> logger
|
ILogger<AccountService> logger,
|
||||||
|
INatsConnection nats
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
public static void SetCultureInfo(Account account)
|
public static void SetCultureInfo(Account account)
|
||||||
@@ -178,24 +185,54 @@ 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();
|
var dupeAutomateCount = await db.Accounts.Where(a => a.AutomatedId == automatedId).CountAsync();
|
||||||
if (dupeAutomateCount > 0)
|
if (dupeAutomateCount > 0)
|
||||||
throw new InvalidOperationException("Automated ID has already been used.");
|
throw new InvalidOperationException("Automated ID has already been used.");
|
||||||
|
|
||||||
var dupeNameCount = await db.Accounts.Where(a => a.Name == account.Name).CountAsync();
|
var dupeNameCount = await db.Accounts.Where(a => a.Name == account.Name).CountAsync();
|
||||||
if (dupeNameCount > 0)
|
if (dupeNameCount > 0)
|
||||||
throw new InvalidOperationException("Account name has already been taken.");
|
throw new InvalidOperationException("Account name has already been taken.");
|
||||||
|
|
||||||
account.AutomatedId = automatedId;
|
account.AutomatedId = automatedId;
|
||||||
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
|
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||||
account.IsSuperuser = false;
|
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);
|
db.Accounts.Add(account);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return account;
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Account?> GetBotAccount(Guid automatedId)
|
public async Task<Account?> GetBotAccount(Guid automatedId)
|
||||||
{
|
{
|
||||||
return await db.Accounts.FirstOrDefaultAsync(a => a.AutomatedId == automatedId);
|
return await db.Accounts.FirstOrDefaultAsync(a => a.AutomatedId == automatedId);
|
||||||
@@ -491,11 +528,11 @@ public class AccountService(
|
|||||||
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
|
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (session is null) throw new InvalidOperationException("Session was not found.");
|
if (session is null) throw new InvalidOperationException("Session was not found.");
|
||||||
|
|
||||||
// The current session should be included in the sessions' list
|
// The current session should be included in the sessions' list
|
||||||
db.AuthSessions.Remove(session);
|
db.AuthSessions.Remove(session);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
if (session.Challenge.ClientId.HasValue)
|
if (session.Challenge.ClientId.HasValue)
|
||||||
{
|
{
|
||||||
if (!await IsDeviceActive(session.Challenge.ClientId.Value))
|
if (!await IsDeviceActive(session.Challenge.ClientId.Value))
|
||||||
@@ -503,7 +540,7 @@ public class AccountService(
|
|||||||
{ DeviceId = session.Challenge.Client!.DeviceId }
|
{ DeviceId = session.Challenge.Client!.DeviceId }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation("Deleted session #{SessionId}", session.Id);
|
logger.LogInformation("Deleted session #{SessionId}", session.Id);
|
||||||
|
|
||||||
await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{session.Id}");
|
await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{session.Id}");
|
||||||
@@ -531,7 +568,7 @@ public class AccountService(
|
|||||||
.Include(s => s.Challenge)
|
.Include(s => s.Challenge)
|
||||||
.Where(s => s.Challenge.ClientId == device.Id)
|
.Where(s => s.Challenge.ClientId == device.Id)
|
||||||
.ExecuteUpdateAsync(p => p.SetProperty(s => s.DeletedAt, s => now));
|
.ExecuteUpdateAsync(p => p.SetProperty(s => s.DeletedAt, s => now));
|
||||||
|
|
||||||
db.AuthClients.Remove(device);
|
db.AuthClients.Remove(device);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
@@ -693,8 +730,14 @@ public class AccountService(
|
|||||||
await db.AuthSessions
|
await db.AuthSessions
|
||||||
.Where(s => s.AccountId == account.Id)
|
.Where(s => s.AccountId == account.Id)
|
||||||
.ExecuteDeleteAsync();
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
db.Accounts.Remove(account);
|
db.Accounts.Remove(account);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await nats.PublishAsync(AccountDeletedEvent.Type, JsonSerializer.SerializeToUtf8Bytes(new AccountDeletedEvent
|
||||||
|
{
|
||||||
|
AccountId = account.Id,
|
||||||
|
DeletedAt = SystemClock.Instance.GetCurrentInstant()
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -42,6 +42,26 @@ public class AccountServiceGrpc(
|
|||||||
return account.ToProtoValue();
|
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,
|
public override async Task<GetAccountBatchResponse> GetAccountBatch(GetAccountBatchRequest request,
|
||||||
ServerCallContext context)
|
ServerCallContext context)
|
||||||
{
|
{
|
||||||
@@ -56,7 +76,35 @@ public class AccountServiceGrpc(
|
|||||||
.Where(a => accountIds.Contains(a.Id))
|
.Where(a => accountIds.Contains(a.Id))
|
||||||
.Include(a => a.Profile)
|
.Include(a => a.Profile)
|
||||||
.ToListAsync();
|
.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<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(
|
var perks = await subscriptions.GetPerkSubscriptionsAsync(
|
||||||
accounts.Select(x => x.Id).ToList()
|
accounts.Select(x => x.Id).ToList()
|
||||||
);
|
);
|
||||||
@@ -76,7 +124,8 @@ public class AccountServiceGrpc(
|
|||||||
return status.ToProtoValue();
|
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
|
var accountIds = request.Id
|
||||||
.Select(id => Guid.TryParse(id, out var accountId) ? accountId : (Guid?)null)
|
.Select(id => Guid.TryParse(id, out var accountId) ? accountId : (Guid?)null)
|
||||||
@@ -98,14 +147,14 @@ public class AccountServiceGrpc(
|
|||||||
.Where(a => accountNames.Contains(a.Name))
|
.Where(a => accountNames.Contains(a.Name))
|
||||||
.Include(a => a.Profile)
|
.Include(a => a.Profile)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var perks = await subscriptions.GetPerkSubscriptionsAsync(
|
var perks = await subscriptions.GetPerkSubscriptionsAsync(
|
||||||
accounts.Select(x => x.Id).ToList()
|
accounts.Select(x => x.Id).ToList()
|
||||||
);
|
);
|
||||||
foreach (var account in accounts)
|
foreach (var account in accounts)
|
||||||
if (perks.TryGetValue(account.Id, out var perk))
|
if (perks.TryGetValue(account.Id, out var perk))
|
||||||
account.PerkSubscription = perk?.ToReference();
|
account.PerkSubscription = perk?.ToReference();
|
||||||
|
|
||||||
var response = new GetAccountBatchResponse();
|
var response = new GetAccountBatchResponse();
|
||||||
response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
|
response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
|
||||||
return response;
|
return response;
|
||||||
|
@@ -1,11 +1,20 @@
|
|||||||
|
using DysonNetwork.Shared.Data;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using NodaTime;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime.Serialization.Protobuf;
|
using NodaTime.Serialization.Protobuf;
|
||||||
|
using ApiKey = DysonNetwork.Shared.Proto.ApiKey;
|
||||||
|
using AuthService = DysonNetwork.Pass.Auth.AuthService;
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Account;
|
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
|
: BotAccountReceiverService.BotAccountReceiverServiceBase
|
||||||
{
|
{
|
||||||
public override async Task<CreateBotAccountResponse> CreateBotAccount(
|
public override async Task<CreateBotAccountResponse> CreateBotAccount(
|
||||||
@@ -14,7 +23,12 @@ public class BotAccountReceiverGrpc(AppDatabase db, AccountService accounts)
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var account = Account.FromProtoValue(request.Account);
|
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
|
return new CreateBotAccountResponse
|
||||||
{
|
{
|
||||||
@@ -34,16 +48,44 @@ public class BotAccountReceiverGrpc(AppDatabase db, AccountService accounts)
|
|||||||
ServerCallContext context
|
ServerCallContext context
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var automatedId = Guid.Parse(request.AutomatedId);
|
var account = Account.FromProtoValue(request.Account);
|
||||||
var account = await accounts.GetBotAccount(automatedId);
|
|
||||||
if (account is null)
|
if (request.PictureId is not null)
|
||||||
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found"));
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
account.Name = request.Account.Name;
|
|
||||||
account.Nick = request.Account.Nick;
|
|
||||||
account.Profile = AccountProfile.FromProtoValue(request.Account.Profile);
|
|
||||||
account.Language = request.Account.Language;
|
|
||||||
|
|
||||||
db.Accounts.Update(account);
|
db.Accounts.Update(account);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
@@ -56,7 +98,7 @@ public class BotAccountReceiverGrpc(AppDatabase db, AccountService accounts)
|
|||||||
CreatedAt = account.CreatedAt.ToTimestamp(),
|
CreatedAt = account.CreatedAt.ToTimestamp(),
|
||||||
UpdatedAt = account.UpdatedAt.ToTimestamp(),
|
UpdatedAt = account.UpdatedAt.ToTimestamp(),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,10 +110,109 @@ public class BotAccountReceiverGrpc(AppDatabase db, AccountService accounts)
|
|||||||
var automatedId = Guid.Parse(request.AutomatedId);
|
var automatedId = Guid.Parse(request.AutomatedId);
|
||||||
var account = await accounts.GetBotAccount(automatedId);
|
var account = await accounts.GetBotAccount(automatedId);
|
||||||
if (account is null)
|
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);
|
await accounts.DeleteAccount(account);
|
||||||
|
|
||||||
return new DeleteBotAccountResponse();
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
@@ -2,6 +2,8 @@ using System.Linq.Expressions;
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using DysonNetwork.Pass.Account;
|
using DysonNetwork.Pass.Account;
|
||||||
using DysonNetwork.Pass.Auth;
|
using DysonNetwork.Pass.Auth;
|
||||||
|
using DysonNetwork.Pass.Credit;
|
||||||
|
using DysonNetwork.Pass.Leveling;
|
||||||
using DysonNetwork.Pass.Permission;
|
using DysonNetwork.Pass.Permission;
|
||||||
using DysonNetwork.Pass.Wallet;
|
using DysonNetwork.Pass.Wallet;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
@@ -38,6 +40,7 @@ public class AppDatabase(
|
|||||||
public DbSet<AuthSession> AuthSessions { get; set; } = null!;
|
public DbSet<AuthSession> AuthSessions { get; set; } = null!;
|
||||||
public DbSet<AuthChallenge> AuthChallenges { get; set; } = null!;
|
public DbSet<AuthChallenge> AuthChallenges { get; set; } = null!;
|
||||||
public DbSet<AuthClient> AuthClients { 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<Wallet.Wallet> Wallets { get; set; } = null!;
|
||||||
public DbSet<WalletPocket> WalletPockets { get; set; } = null!;
|
public DbSet<WalletPocket> WalletPockets { get; set; } = null!;
|
||||||
@@ -47,6 +50,9 @@ public class AppDatabase(
|
|||||||
public DbSet<Coupon> WalletCoupons { get; set; } = null!;
|
public DbSet<Coupon> WalletCoupons { get; set; } = null!;
|
||||||
|
|
||||||
public DbSet<Punishment> Punishments { get; set; } = null!;
|
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)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
|
50
DysonNetwork.Pass/Auth/ApiKey.cs
Normal file
50
DysonNetwork.Pass/Auth/ApiKey.cs
Normal 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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
90
DysonNetwork.Pass/Auth/ApiKeyController.cs
Normal file
90
DysonNetwork.Pass/Auth/ApiKeyController.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@@ -49,7 +49,10 @@ public class DysonTokenAuthHandler(
|
|||||||
|
|
||||||
try
|
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)
|
if (!valid || session is null)
|
||||||
return AuthenticateResult.Fail(message ?? "Authentication failed.");
|
return AuthenticateResult.Fail(message ?? "Authentication failed.");
|
||||||
|
|
||||||
@@ -67,7 +70,7 @@ public class DysonTokenAuthHandler(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Add scopes as claims
|
// 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
|
// Add superuser claim if applicable
|
||||||
if (session.Account.IsSuperuser)
|
if (session.Account.IsSuperuser)
|
||||||
|
@@ -51,7 +51,11 @@ public class AuthController(
|
|||||||
.Where(e => e.Type == PunishmentType.BlockLogin || e.Type == PunishmentType.DisableAccount)
|
.Where(e => e.Type == PunishmentType.BlockLogin || e.Type == PunishmentType.DisableAccount)
|
||||||
.Where(e => e.ExpiredAt == null || now < e.ExpiredAt)
|
.Where(e => e.ExpiredAt == null || now < e.ExpiredAt)
|
||||||
.FirstOrDefaultAsync();
|
.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 ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
|
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
|
||||||
|
@@ -52,7 +52,7 @@ public class AuthService(
|
|||||||
riskScore += 1;
|
riskScore += 1;
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge.IpAddress) &&
|
if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge?.IpAddress) &&
|
||||||
!lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase))
|
!lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase))
|
||||||
riskScore += 1;
|
riskScore += 1;
|
||||||
}
|
}
|
||||||
@@ -318,6 +318,87 @@ public class AuthService(
|
|||||||
return factor.VerifyPassword(pinCode);
|
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
|
// Helper methods for Base64Url encoding/decoding
|
||||||
private static string Base64UrlEncode(byte[] data)
|
private static string Base64UrlEncode(byte[] data)
|
||||||
{
|
{
|
||||||
@@ -329,7 +410,7 @@ public class AuthService(
|
|||||||
|
|
||||||
private static byte[] Base64UrlDecode(string base64Url)
|
private static byte[] Base64UrlDecode(string base64Url)
|
||||||
{
|
{
|
||||||
string padded = base64Url
|
var padded = base64Url
|
||||||
.Replace('-', '+')
|
.Replace('-', '+')
|
||||||
.Replace('_', '/');
|
.Replace('_', '/');
|
||||||
|
|
||||||
|
@@ -1,9 +1,5 @@
|
|||||||
using DysonNetwork.Pass.Wallet;
|
|
||||||
using DysonNetwork.Shared.Cache;
|
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using NodaTime;
|
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Auth;
|
namespace DysonNetwork.Pass.Auth;
|
||||||
|
|
||||||
@@ -18,7 +14,7 @@ public class AuthServiceGrpc(
|
|||||||
ServerCallContext context
|
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)
|
if (!valid || session is null)
|
||||||
return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." };
|
return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." };
|
||||||
|
|
||||||
|
@@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using NodaTime.Serialization.Protobuf;
|
using NodaTime.Serialization.Protobuf;
|
||||||
using Point = NetTopologySuite.Geometries.Point;
|
using Point = NetTopologySuite.Geometries.Point;
|
||||||
@@ -12,26 +11,28 @@ namespace DysonNetwork.Pass.Auth;
|
|||||||
public class AuthSession : ModelBase
|
public class AuthSession : ModelBase
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
[MaxLength(1024)] public string? Label { get; set; }
|
|
||||||
public Instant? LastGrantedAt { get; set; }
|
public Instant? LastGrantedAt { get; set; }
|
||||||
public Instant? ExpiredAt { get; set; }
|
public Instant? ExpiredAt { get; set; }
|
||||||
|
|
||||||
public Guid AccountId { get; set; }
|
public Guid AccountId { get; set; }
|
||||||
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
[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 Guid? AppId { get; set; }
|
||||||
|
|
||||||
public Shared.Proto.AuthSession ToProtoValue() => new()
|
public Shared.Proto.AuthSession ToProtoValue() => new()
|
||||||
{
|
{
|
||||||
Id = Id.ToString(),
|
Id = Id.ToString(),
|
||||||
Label = Label,
|
|
||||||
LastGrantedAt = LastGrantedAt?.ToTimestamp(),
|
LastGrantedAt = LastGrantedAt?.ToTimestamp(),
|
||||||
ExpiredAt = ExpiredAt?.ToTimestamp(),
|
ExpiredAt = ExpiredAt?.ToTimestamp(),
|
||||||
AccountId = AccountId.ToString(),
|
AccountId = AccountId.ToString(),
|
||||||
Account = Account.ToProtoValue(),
|
Account = Account.ToProtoValue(),
|
||||||
ChallengeId = ChallengeId.ToString(),
|
ChallengeId = ChallengeId.ToString(),
|
||||||
Challenge = Challenge.ToProtoValue(),
|
Challenge = Challenge?.ToProtoValue(),
|
||||||
AppId = AppId?.ToString()
|
AppId = AppId?.ToString()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@@ -5,8 +5,10 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Web;
|
||||||
using DysonNetwork.Pass.Account;
|
using DysonNetwork.Pass.Account;
|
||||||
using DysonNetwork.Pass.Auth.OidcProvider.Options;
|
using DysonNetwork.Pass.Auth.OidcProvider.Options;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
@@ -19,10 +21,199 @@ public class OidcProviderController(
|
|||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
OidcProviderService oidcService,
|
OidcProviderService oidcService,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IOptions<OidcProviderOptions> options
|
IOptions<OidcProviderOptions> options,
|
||||||
)
|
ILogger<OidcProviderController> logger
|
||||||
: ControllerBase
|
) : 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")]
|
[HttpPost("token")]
|
||||||
[Consumes("application/x-www-form-urlencoded")]
|
[Consumes("application/x-www-form-urlencoded")]
|
||||||
public async Task<IActionResult> Token([FromForm] TokenRequest request)
|
public async Task<IActionResult> Token([FromForm] TokenRequest request)
|
||||||
@@ -35,74 +226,74 @@ public class OidcProviderController(
|
|||||||
case "authorization_code" when request.Code == null:
|
case "authorization_code" when request.Code == null:
|
||||||
return BadRequest("Authorization code is required");
|
return BadRequest("Authorization code is required");
|
||||||
case "authorization_code":
|
case "authorization_code":
|
||||||
{
|
{
|
||||||
var client = await oidcService.FindClientByIdAsync(request.ClientId.Value);
|
var client = await oidcService.FindClientBySlugAsync(request.ClientId);
|
||||||
if (client == null ||
|
if (client == null ||
|
||||||
!await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret))
|
!await oidcService.ValidateClientCredentialsAsync(Guid.Parse(client.Id), request.ClientSecret))
|
||||||
return BadRequest(new ErrorResponse
|
return BadRequest(new ErrorResponse
|
||||||
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
|
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
|
||||||
|
|
||||||
// Generate tokens
|
// Generate tokens
|
||||||
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||||
clientId: request.ClientId.Value,
|
clientId: Guid.Parse(client.Id),
|
||||||
authorizationCode: request.Code!,
|
authorizationCode: request.Code!,
|
||||||
redirectUri: request.RedirectUri,
|
redirectUri: request.RedirectUri,
|
||||||
codeVerifier: request.CodeVerifier
|
codeVerifier: request.CodeVerifier
|
||||||
);
|
);
|
||||||
|
|
||||||
return Ok(tokenResponse);
|
return Ok(tokenResponse);
|
||||||
}
|
}
|
||||||
case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken):
|
case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken):
|
||||||
return BadRequest(new ErrorResponse
|
return BadRequest(new ErrorResponse
|
||||||
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
|
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
|
||||||
case "refresh_token":
|
case "refresh_token":
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
try
|
// Decode the base64 refresh token to get the session ID
|
||||||
{
|
var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
|
||||||
// Decode the base64 refresh token to get the session ID
|
var sessionId = new Guid(sessionIdBytes);
|
||||||
var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
|
|
||||||
var sessionId = new Guid(sessionIdBytes);
|
|
||||||
|
|
||||||
// Find the session and related data
|
// Find the session and related data
|
||||||
var session = await oidcService.FindSessionByIdAsync(sessionId);
|
var session = await oidcService.FindSessionByIdAsync(sessionId);
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
if (session?.AppId is null || session.ExpiredAt < now)
|
if (session?.AppId is null || session.ExpiredAt < now)
|
||||||
{
|
|
||||||
return BadRequest(new ErrorResponse
|
|
||||||
{
|
|
||||||
Error = "invalid_grant",
|
|
||||||
ErrorDescription = "Invalid or expired refresh token"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the client
|
|
||||||
var client = await oidcService.FindClientByIdAsync(session.AppId.Value);
|
|
||||||
if (client == null)
|
|
||||||
{
|
|
||||||
return BadRequest(new ErrorResponse
|
|
||||||
{
|
|
||||||
Error = "invalid_client",
|
|
||||||
ErrorDescription = "Client not found"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new tokens
|
|
||||||
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
|
||||||
clientId: session.AppId!.Value,
|
|
||||||
sessionId: session.Id
|
|
||||||
);
|
|
||||||
|
|
||||||
return Ok(tokenResponse);
|
|
||||||
}
|
|
||||||
catch (FormatException)
|
|
||||||
{
|
{
|
||||||
return BadRequest(new ErrorResponse
|
return BadRequest(new ErrorResponse
|
||||||
{
|
{
|
||||||
Error = "invalid_grant",
|
Error = "invalid_grant",
|
||||||
ErrorDescription = "Invalid refresh token format"
|
ErrorDescription = "Invalid or expired refresh token"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the client
|
||||||
|
var client = await oidcService.FindClientByIdAsync(session.AppId.Value);
|
||||||
|
if (client == null)
|
||||||
|
{
|
||||||
|
return BadRequest(new ErrorResponse
|
||||||
|
{
|
||||||
|
Error = "invalid_client",
|
||||||
|
ErrorDescription = "Client not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new tokens
|
||||||
|
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||||
|
clientId: session.AppId!.Value,
|
||||||
|
sessionId: session.Id
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(tokenResponse);
|
||||||
}
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return BadRequest(new ErrorResponse
|
||||||
|
{
|
||||||
|
Error = "invalid_grant",
|
||||||
|
ErrorDescription = "Invalid refresh token format"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
|
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
|
||||||
}
|
}
|
||||||
@@ -116,7 +307,7 @@ public class OidcProviderController(
|
|||||||
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
|
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
|
||||||
|
|
||||||
// Get requested scopes from the token
|
// Get requested scopes from the token
|
||||||
var scopes = currentSession.Challenge.Scopes;
|
var scopes = currentSession.Challenge?.Scopes ?? [];
|
||||||
|
|
||||||
var userInfo = new Dictionary<string, object>
|
var userInfo = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
@@ -150,10 +341,10 @@ public class OidcProviderController(
|
|||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
issuer = issuer,
|
issuer,
|
||||||
authorization_endpoint = $"{baseUrl}/auth/authorize",
|
authorization_endpoint = $"{baseUrl}/auth/authorize",
|
||||||
token_endpoint = $"{baseUrl}/auth/open/token",
|
token_endpoint = $"{baseUrl}/api/auth/open/token",
|
||||||
userinfo_endpoint = $"{baseUrl}/auth/open/userinfo",
|
userinfo_endpoint = $"{baseUrl}/api/auth/open/userinfo",
|
||||||
jwks_uri = $"{baseUrl}/.well-known/jwks",
|
jwks_uri = $"{baseUrl}/.well-known/jwks",
|
||||||
scopes_supported = new[] { "openid", "profile", "email" },
|
scopes_supported = new[] { "openid", "profile", "email" },
|
||||||
response_types_supported = new[]
|
response_types_supported = new[]
|
||||||
@@ -220,7 +411,7 @@ public class TokenRequest
|
|||||||
|
|
||||||
[JsonPropertyName("client_id")]
|
[JsonPropertyName("client_id")]
|
||||||
[FromForm(Name = "client_id")]
|
[FromForm(Name = "client_id")]
|
||||||
public Guid? ClientId { get; set; }
|
public string? ClientId { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("client_secret")]
|
[JsonPropertyName("client_secret")]
|
||||||
[FromForm(Name = "client_secret")]
|
[FromForm(Name = "client_secret")]
|
||||||
@@ -237,4 +428,4 @@ public class TokenRequest
|
|||||||
[JsonPropertyName("code_verifier")]
|
[JsonPropertyName("code_verifier")]
|
||||||
[FromForm(Name = "code_verifier")]
|
[FromForm(Name = "code_verifier")]
|
||||||
public string? CodeVerifier { get; set; }
|
public string? CodeVerifier { get; set; }
|
||||||
}
|
}
|
@@ -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; }
|
||||||
|
}
|
@@ -20,7 +20,6 @@ public class TokenResponse
|
|||||||
[JsonPropertyName("scope")]
|
[JsonPropertyName("scope")]
|
||||||
public string? Scope { get; set; }
|
public string? Scope { get; set; }
|
||||||
|
|
||||||
|
|
||||||
[JsonPropertyName("id_token")]
|
[JsonPropertyName("id_token")]
|
||||||
public string? IdToken { get; set; }
|
public string? IdToken { get; set; }
|
||||||
}
|
}
|
||||||
|
@@ -11,6 +11,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
using AccountContactType = DysonNetwork.Pass.Account.AccountContactType;
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Auth.OidcProvider.Services;
|
namespace DysonNetwork.Pass.Auth.OidcProvider.Services;
|
||||||
|
|
||||||
@@ -31,15 +32,31 @@ public class OidcProviderService(
|
|||||||
return resp.App ?? null;
|
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();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
|
||||||
return await db.AuthSessions
|
var queryable = db.AuthSessions
|
||||||
.Include(s => s.Challenge)
|
.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 &&
|
.Where(s => s.AccountId == accountId &&
|
||||||
s.AppId == clientId &&
|
s.AppId == clientId &&
|
||||||
(s.ExpiredAt == null || s.ExpiredAt > now) &&
|
(s.ExpiredAt == null || s.ExpiredAt > now) &&
|
||||||
|
s.Challenge != null &&
|
||||||
s.Challenge.Type == ChallengeType.OAuth)
|
s.Challenge.Type == ChallengeType.OAuth)
|
||||||
.OrderByDescending(s => s.CreatedAt)
|
.OrderByDescending(s => s.CreatedAt)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
@@ -56,6 +73,149 @@ public class OidcProviderService(
|
|||||||
return resp.Valid;
|
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(
|
public async Task<TokenResponse> GenerateTokenResponseAsync(
|
||||||
Guid clientId,
|
Guid clientId,
|
||||||
string? authorizationCode = null,
|
string? authorizationCode = null,
|
||||||
@@ -71,24 +231,43 @@ public class OidcProviderService(
|
|||||||
AuthSession session;
|
AuthSession session;
|
||||||
var clock = SystemClock.Instance;
|
var clock = SystemClock.Instance;
|
||||||
var now = clock.GetCurrentInstant();
|
var now = clock.GetCurrentInstant();
|
||||||
|
string? nonce = null;
|
||||||
List<string>? scopes = null;
|
List<string>? scopes = null;
|
||||||
|
|
||||||
if (authorizationCode != null)
|
if (authorizationCode != null)
|
||||||
{
|
{
|
||||||
// Authorization code flow
|
// Authorization code flow
|
||||||
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
|
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
|
||||||
if (authCode is null) throw new InvalidOperationException("Invalid authorization code");
|
if (authCode == null)
|
||||||
var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync();
|
throw new InvalidOperationException("Invalid authorization code");
|
||||||
if (account is null) throw new InvalidOperationException("Account was not found");
|
|
||||||
|
// 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;
|
scopes = authCode.Scopes;
|
||||||
|
nonce = authCode.Nonce;
|
||||||
}
|
}
|
||||||
else if (sessionId.HasValue)
|
else if (sessionId.HasValue)
|
||||||
{
|
{
|
||||||
// Refresh token flow
|
// Refresh token flow
|
||||||
session = await FindSessionByIdAsync(sessionId.Value) ??
|
session = await FindSessionByIdAsync(sessionId.Value) ??
|
||||||
throw new InvalidOperationException("Invalid session");
|
throw new InvalidOperationException("Session not found");
|
||||||
|
|
||||||
// Verify the session is still valid
|
// Verify the session is still valid
|
||||||
if (session.ExpiredAt < now)
|
if (session.ExpiredAt < now)
|
||||||
@@ -102,13 +281,15 @@ public class OidcProviderService(
|
|||||||
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
|
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
|
||||||
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
|
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
|
||||||
|
|
||||||
// Generate an access token
|
// Generate tokens
|
||||||
var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
|
var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
|
||||||
|
var idToken = GenerateIdToken(client, session, nonce, scopes);
|
||||||
var refreshToken = GenerateRefreshToken(session);
|
var refreshToken = GenerateRefreshToken(session);
|
||||||
|
|
||||||
return new TokenResponse
|
return new TokenResponse
|
||||||
{
|
{
|
||||||
AccessToken = accessToken,
|
AccessToken = accessToken,
|
||||||
|
IdToken = idToken,
|
||||||
ExpiresIn = expiresIn,
|
ExpiresIn = expiresIn,
|
||||||
TokenType = "Bearer",
|
TokenType = "Bearer",
|
||||||
RefreshToken = refreshToken,
|
RefreshToken = refreshToken,
|
||||||
@@ -134,11 +315,10 @@ public class OidcProviderService(
|
|||||||
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
|
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
|
||||||
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
|
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
|
||||||
ClaimValueTypes.Integer64),
|
ClaimValueTypes.Integer64),
|
||||||
new Claim("client_id", client.Id)
|
|
||||||
]),
|
]),
|
||||||
Expires = expiresAt.ToDateTimeUtc(),
|
Expires = expiresAt.ToDateTimeUtc(),
|
||||||
Issuer = _options.IssuerUri,
|
Issuer = _options.IssuerUri,
|
||||||
Audience = client.Id
|
Audience = client.Slug
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try to use RSA signing if keys are available, fall back to HMAC
|
// 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());
|
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(
|
public async Task<string> GenerateAuthorizationCodeAsync(
|
||||||
Guid clientId,
|
Guid clientId,
|
||||||
Guid userId,
|
Guid userId,
|
||||||
@@ -278,7 +413,7 @@ public class OidcProviderService(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Store the code with its metadata in the cache
|
// 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);
|
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
|
||||||
|
|
||||||
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
|
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
|
||||||
@@ -292,7 +427,7 @@ public class OidcProviderService(
|
|||||||
string? codeVerifier = null
|
string? codeVerifier = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var cacheKey = $"auth:code:{code}";
|
var cacheKey = $"auth:oidc-code:{code}";
|
||||||
var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey);
|
var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey);
|
||||||
|
|
||||||
if (!found || authCode == null)
|
if (!found || authCode == null)
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using DysonNetwork.Pass.Wallet;
|
using DysonNetwork.Pass.Wallet;
|
||||||
@@ -22,8 +23,9 @@ public class TokenAuthService(
|
|||||||
/// then cache and return.
|
/// then cache and return.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="token">Incoming token string</param>
|
/// <param name="token">Incoming token string</param>
|
||||||
|
/// <param name="ipAddress">Client IP address, for logging purposes</param>
|
||||||
/// <returns>(Valid, Session, Message)</returns>
|
/// <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
|
try
|
||||||
{
|
{
|
||||||
@@ -32,6 +34,11 @@ public class TokenAuthService(
|
|||||||
logger.LogWarning("AuthenticateTokenAsync: no token provided");
|
logger.LogWarning("AuthenticateTokenAsync: no token provided");
|
||||||
return (false, null, "No token provided.");
|
return (false, null, "No token provided.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(ipAddress))
|
||||||
|
{
|
||||||
|
logger.LogDebug("AuthenticateTokenAsync: client IP: {IpAddress}", ipAddress);
|
||||||
|
}
|
||||||
|
|
||||||
// token fingerprint for correlation
|
// token fingerprint for correlation
|
||||||
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||||
@@ -70,7 +77,7 @@ public class TokenAuthService(
|
|||||||
"AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})",
|
"AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})",
|
||||||
sessionId,
|
sessionId,
|
||||||
session.AccountId,
|
session.AccountId,
|
||||||
session.Challenge.Scopes.Count,
|
session.Challenge?.Scopes.Count,
|
||||||
session.ExpiredAt
|
session.ExpiredAt
|
||||||
);
|
);
|
||||||
return (true, session, null);
|
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})",
|
"AuthenticateTokenAsync: DB session loaded (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId}, appId={AppId}, scopes={ScopeCount}, ip={Ip}, uaLen={UaLen})",
|
||||||
sessionId,
|
sessionId,
|
||||||
session.AccountId,
|
session.AccountId,
|
||||||
session.Challenge.ClientId,
|
session.Challenge?.ClientId,
|
||||||
session.AppId,
|
session.AppId,
|
||||||
session.Challenge.Scopes.Count,
|
session.Challenge?.Scopes.Count,
|
||||||
session.Challenge.IpAddress,
|
session.Challenge?.IpAddress,
|
||||||
(session.Challenge.UserAgent ?? string.Empty).Length
|
(session.Challenge?.UserAgent ?? string.Empty).Length
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId);
|
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})",
|
"AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})",
|
||||||
sessionId,
|
sessionId,
|
||||||
session.AccountId,
|
session.AccountId,
|
||||||
session.Challenge.ClientId
|
session.Challenge?.ClientId
|
||||||
);
|
);
|
||||||
return (true, session, null);
|
return (true, session, null);
|
||||||
}
|
}
|
||||||
|
@@ -60,6 +60,12 @@ const router = createRouter({
|
|||||||
name: 'authCallback',
|
name: 'authCallback',
|
||||||
component: () => import('../views/callback.vue'),
|
component: () => import('../views/callback.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/auth/authorize',
|
||||||
|
name: 'authAuthorize',
|
||||||
|
component: () => import('../views/authorize.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/:notFound(.*)',
|
path: '/:notFound(.*)',
|
||||||
name: 'errorNotFound',
|
name: 'errorNotFound',
|
||||||
|
@@ -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>
|
||||||
|
34
DysonNetwork.Pass/Credit/SocialCreditRecord.cs
Normal file
34
DysonNetwork.Pass/Credit/SocialCreditRecord.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
46
DysonNetwork.Pass/Credit/SocialCreditService.cs
Normal file
46
DysonNetwork.Pass/Credit/SocialCreditService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
27
DysonNetwork.Pass/Credit/SocialCreditServiceGrpc.cs
Normal file
27
DysonNetwork.Pass/Credit/SocialCreditServiceGrpc.cs
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
@@ -13,6 +13,7 @@
|
|||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="NATS.Client.Core" Version="2.6.6" />
|
||||||
<PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115">
|
<PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
34
DysonNetwork.Pass/Leveling/ExperienceRecord.cs
Normal file
34
DysonNetwork.Pass/Leveling/ExperienceRecord.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
42
DysonNetwork.Pass/Leveling/ExperienceService.cs
Normal file
42
DysonNetwork.Pass/Leveling/ExperienceService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
19
DysonNetwork.Pass/Leveling/ExperienceServiceGrpc.cs
Normal file
19
DysonNetwork.Pass/Leveling/ExperienceServiceGrpc.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
1825
DysonNetwork.Pass/Migrations/20250819162856_AddBotAccount.Designer.cs
generated
Normal file
1825
DysonNetwork.Pass/Migrations/20250819162856_AddBotAccount.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
DysonNetwork.Pass/Migrations/20250819162856_AddBotAccount.cs
Normal file
29
DysonNetwork.Pass/Migrations/20250819162856_AddBotAccount.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1884
DysonNetwork.Pass/Migrations/20250820104425_AddApiKeys.Designer.cs
generated
Normal file
1884
DysonNetwork.Pass/Migrations/20250820104425_AddApiKeys.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
114
DysonNetwork.Pass/Migrations/20250820104425_AddApiKeys.cs
Normal file
114
DysonNetwork.Pass/Migrations/20250820104425_AddApiKeys.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
2008
DysonNetwork.Pass/Migrations/20250820120632_AddCreditAndLevelingRecords.Designer.cs
generated
Normal file
2008
DysonNetwork.Pass/Migrations/20250820120632_AddCreditAndLevelingRecords.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
2012
DysonNetwork.Pass/Migrations/20250821093930_AddLevelingBonusMultiplier.Designer.cs
generated
Normal file
2012
DysonNetwork.Pass/Migrations/20250821093930_AddLevelingBonusMultiplier.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
2016
DysonNetwork.Pass/Migrations/20250822142926_CacheSocialCreditsInProfile.Designer.cs
generated
Normal file
2016
DysonNetwork.Pass/Migrations/20250822142926_CacheSocialCreditsInProfile.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -98,6 +98,10 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("activated_at");
|
.HasColumnName("activated_at");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AutomatedId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("automated_id");
|
||||||
|
|
||||||
b.Property<Instant>("CreatedAt")
|
b.Property<Instant>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("created_at");
|
.HasColumnName("created_at");
|
||||||
@@ -458,6 +462,10 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
.HasColumnType("character varying(1024)")
|
.HasColumnType("character varying(1024)")
|
||||||
.HasColumnName("pronouns");
|
.HasColumnName("pronouns");
|
||||||
|
|
||||||
|
b.Property<double>("SocialCredits")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("social_credits");
|
||||||
|
|
||||||
b.Property<string>("TimeZone")
|
b.Property<string>("TimeZone")
|
||||||
.HasMaxLength(1024)
|
.HasMaxLength(1024)
|
||||||
.HasColumnType("character varying(1024)")
|
.HasColumnType("character varying(1024)")
|
||||||
@@ -796,6 +804,51 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
b.ToTable("account_statuses", (string)null);
|
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 =>
|
modelBuilder.Entity("DysonNetwork.Pass.Auth.AuthChallenge", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -957,7 +1010,7 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("app_id");
|
.HasColumnName("app_id");
|
||||||
|
|
||||||
b.Property<Guid>("ChallengeId")
|
b.Property<Guid?>("ChallengeId")
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("challenge_id");
|
.HasColumnName("challenge_id");
|
||||||
|
|
||||||
@@ -973,11 +1026,6 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("expired_at");
|
.HasColumnName("expired_at");
|
||||||
|
|
||||||
b.Property<string>("Label")
|
|
||||||
.HasMaxLength(1024)
|
|
||||||
.HasColumnType("character varying(1024)")
|
|
||||||
.HasColumnName("label");
|
|
||||||
|
|
||||||
b.Property<Instant?>("LastGrantedAt")
|
b.Property<Instant?>("LastGrantedAt")
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("last_granted_at");
|
.HasColumnName("last_granted_at");
|
||||||
@@ -998,6 +1046,110 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
b.ToTable("auth_sessions", (string)null);
|
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 =>
|
modelBuilder.Entity("DysonNetwork.Pass.Permission.PermissionGroup", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1628,6 +1780,27 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
b.Navigation("Account");
|
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 =>
|
modelBuilder.Entity("DysonNetwork.Pass.Auth.AuthChallenge", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DysonNetwork.Pass.Account.Account", "Account")
|
b.HasOne("DysonNetwork.Pass.Account.Account", "Account")
|
||||||
@@ -1671,8 +1844,6 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
b.HasOne("DysonNetwork.Pass.Auth.AuthChallenge", "Challenge")
|
b.HasOne("DysonNetwork.Pass.Auth.AuthChallenge", "Challenge")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("ChallengeId")
|
.HasForeignKey("ChallengeId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired()
|
|
||||||
.HasConstraintName("fk_auth_sessions_auth_challenges_challenge_id");
|
.HasConstraintName("fk_auth_sessions_auth_challenges_challenge_id");
|
||||||
|
|
||||||
b.Navigation("Account");
|
b.Navigation("Account");
|
||||||
@@ -1680,6 +1851,30 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
b.Navigation("Challenge");
|
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 =>
|
modelBuilder.Entity("DysonNetwork.Pass.Permission.PermissionGroupMember", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DysonNetwork.Pass.Permission.PermissionGroup", "Group")
|
b.HasOne("DysonNetwork.Pass.Permission.PermissionGroup", "Group")
|
||||||
|
@@ -4,6 +4,7 @@ using DysonNetwork.Pass.Startup;
|
|||||||
using DysonNetwork.Shared.Http;
|
using DysonNetwork.Shared.Http;
|
||||||
using DysonNetwork.Shared.PageData;
|
using DysonNetwork.Shared.PageData;
|
||||||
using DysonNetwork.Shared.Registry;
|
using DysonNetwork.Shared.Registry;
|
||||||
|
using DysonNetwork.Shared.Stream;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -16,6 +17,7 @@ builder.Services.AddAppMetrics();
|
|||||||
|
|
||||||
// Add application services
|
// Add application services
|
||||||
builder.Services.AddRegistryService(builder.Configuration);
|
builder.Services.AddRegistryService(builder.Configuration);
|
||||||
|
builder.Services.AddStreamConnection(builder.Configuration);
|
||||||
builder.Services.AddAppServices(builder.Configuration);
|
builder.Services.AddAppServices(builder.Configuration);
|
||||||
builder.Services.AddAppRateLimiting();
|
builder.Services.AddAppRateLimiting();
|
||||||
builder.Services.AddAppAuthentication();
|
builder.Services.AddAppAuthentication();
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using DysonNetwork.Pass.Account;
|
using DysonNetwork.Pass.Account;
|
||||||
using DysonNetwork.Pass.Auth;
|
using DysonNetwork.Pass.Auth;
|
||||||
|
using DysonNetwork.Pass.Credit;
|
||||||
|
using DysonNetwork.Pass.Leveling;
|
||||||
using DysonNetwork.Pass.Permission;
|
using DysonNetwork.Pass.Permission;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.Extensions.FileProviders;
|
using Microsoft.Extensions.FileProviders;
|
||||||
@@ -76,6 +78,8 @@ public static class ApplicationConfiguration
|
|||||||
app.MapGrpcService<AuthServiceGrpc>();
|
app.MapGrpcService<AuthServiceGrpc>();
|
||||||
app.MapGrpcService<ActionLogServiceGrpc>();
|
app.MapGrpcService<ActionLogServiceGrpc>();
|
||||||
app.MapGrpcService<PermissionServiceGrpc>();
|
app.MapGrpcService<PermissionServiceGrpc>();
|
||||||
|
app.MapGrpcService<SocialCreditServiceGrpc>();
|
||||||
|
app.MapGrpcService<ExperienceServiceGrpc>();
|
||||||
app.MapGrpcService<BotAccountReceiverGrpc>();
|
app.MapGrpcService<BotAccountReceiverGrpc>();
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
@@ -15,7 +15,9 @@ using System.Text.Json;
|
|||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
using DysonNetwork.Pass.Auth.OidcProvider.Options;
|
using DysonNetwork.Pass.Auth.OidcProvider.Options;
|
||||||
using DysonNetwork.Pass.Auth.OidcProvider.Services;
|
using DysonNetwork.Pass.Auth.OidcProvider.Services;
|
||||||
|
using DysonNetwork.Pass.Credit;
|
||||||
using DysonNetwork.Pass.Handlers;
|
using DysonNetwork.Pass.Handlers;
|
||||||
|
using DysonNetwork.Pass.Leveling;
|
||||||
using DysonNetwork.Pass.Safety;
|
using DysonNetwork.Pass.Safety;
|
||||||
using DysonNetwork.Pass.Wallet.PaymentHandlers;
|
using DysonNetwork.Pass.Wallet.PaymentHandlers;
|
||||||
using DysonNetwork.Shared.Cache;
|
using DysonNetwork.Shared.Cache;
|
||||||
@@ -203,6 +205,8 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<PaymentService>();
|
services.AddScoped<PaymentService>();
|
||||||
services.AddScoped<AfdianPaymentHandler>();
|
services.AddScoped<AfdianPaymentHandler>();
|
||||||
services.AddScoped<SafetyService>();
|
services.AddScoped<SafetyService>();
|
||||||
|
services.AddScoped<SocialCreditService>();
|
||||||
|
services.AddScoped<ExperienceService>();
|
||||||
|
|
||||||
services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider"));
|
services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider"));
|
||||||
services.AddScoped<OidcProviderService>();
|
services.AddScoped<OidcProviderService>();
|
||||||
|
@@ -12,7 +12,8 @@
|
|||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"App": "Host=localhost;Port=5432;Database=dyson_pass;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
|
"App": "Host=localhost;Port=5432;Database=dyson_pass;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
|
||||||
"FastRetrieve": "localhost:6379",
|
"FastRetrieve": "localhost:6379",
|
||||||
"Etcd": "etcd.orb.local:2379"
|
"Etcd": "etcd.orb.local:2379",
|
||||||
|
"Stream": "nats.orb.local:4222"
|
||||||
},
|
},
|
||||||
"Authentication": {
|
"Authentication": {
|
||||||
"Schemes": {
|
"Schemes": {
|
||||||
|
@@ -24,7 +24,7 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
|
|||||||
}
|
}
|
||||||
|
|
||||||
var accountId = currentUser.Id!;
|
var accountId = currentUser.Id!;
|
||||||
var deviceId = currentSession.Challenge.DeviceId!;
|
var deviceId = currentSession.Challenge?.DeviceId ?? Guid.NewGuid().ToString();
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(deviceId))
|
if (string.IsNullOrEmpty(deviceId))
|
||||||
{
|
{
|
||||||
@@ -32,7 +32,8 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
|
|||||||
return;
|
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 cts = new CancellationTokenSource();
|
||||||
var connectionKey = (accountId, deviceId);
|
var connectionKey = (accountId, deviceId);
|
||||||
|
|
||||||
@@ -65,7 +66,12 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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
|
finally
|
||||||
{
|
{
|
||||||
@@ -99,7 +105,7 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
var packet = WebSocketPacket.FromBytes(buffer[..receiveResult.Count]);
|
var packet = WebSocketPacket.FromBytes(buffer[..receiveResult.Count]);
|
||||||
_ = ws.HandlePacket(currentUser, connectionKey.DeviceId, packet, webSocket);
|
await ws.HandlePacket(currentUser, connectionKey.DeviceId, packet, webSocket);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
@@ -113,4 +119,4 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -49,11 +49,18 @@ public class WebSocketService
|
|||||||
public void Disconnect((string AccountId, string DeviceId) key, string? reason = null)
|
public void Disconnect((string AccountId, string DeviceId) key, string? reason = null)
|
||||||
{
|
{
|
||||||
if (!ActiveConnections.TryGetValue(key, out var data)) return;
|
if (!ActiveConnections.TryGetValue(key, out var data)) return;
|
||||||
data.Socket.CloseAsync(
|
try
|
||||||
WebSocketCloseStatus.NormalClosure,
|
{
|
||||||
reason ?? "Server just decided to disconnect.",
|
data.Socket.CloseAsync(
|
||||||
CancellationToken.None
|
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();
|
data.Cts.Cancel();
|
||||||
ActiveConnections.TryRemove(key, out _);
|
ActiveConnections.TryRemove(key, out _);
|
||||||
}
|
}
|
||||||
|
@@ -1,34 +1,29 @@
|
|||||||
using CorePush.Apple;
|
using CorePush.Apple;
|
||||||
using CorePush.Firebase;
|
using CorePush.Firebase;
|
||||||
using DysonNetwork.Pusher.Connection;
|
using DysonNetwork.Pusher.Connection;
|
||||||
using DysonNetwork.Shared.Cache;
|
using DysonNetwork.Pusher.Services;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using System.Threading.Channels;
|
using WebSocketPacket = DysonNetwork.Pusher.Connection.WebSocketPacket;
|
||||||
|
|
||||||
namespace DysonNetwork.Pusher.Notification;
|
namespace DysonNetwork.Pusher.Notification;
|
||||||
|
|
||||||
public class PushService : IDisposable
|
public class PushService
|
||||||
{
|
{
|
||||||
private readonly AppDatabase _db;
|
private readonly AppDatabase _db;
|
||||||
private readonly FlushBufferService _fbs;
|
|
||||||
private readonly WebSocketService _ws;
|
private readonly WebSocketService _ws;
|
||||||
|
private readonly QueueService _queueService;
|
||||||
private readonly ILogger<PushService> _logger;
|
private readonly ILogger<PushService> _logger;
|
||||||
private readonly FirebaseSender? _fcm;
|
private readonly FirebaseSender? _fcm;
|
||||||
private readonly ApnSender? _apns;
|
private readonly ApnSender? _apns;
|
||||||
private readonly string? _apnsTopic;
|
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(
|
public PushService(
|
||||||
IConfiguration config,
|
IConfiguration config,
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
FlushBufferService fbs,
|
|
||||||
WebSocketService ws,
|
WebSocketService ws,
|
||||||
|
QueueService queueService,
|
||||||
IHttpClientFactory httpFactory,
|
IHttpClientFactory httpFactory,
|
||||||
ILogger<PushService> logger
|
ILogger<PushService> logger
|
||||||
)
|
)
|
||||||
@@ -58,48 +53,9 @@ public class PushService : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
_db = db;
|
_db = db;
|
||||||
_fbs = fbs;
|
|
||||||
_ws = ws;
|
_ws = ws;
|
||||||
|
_queueService = queueService;
|
||||||
_logger = logger;
|
_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)
|
public async Task UnsubscribeDevice(string deviceId)
|
||||||
@@ -165,7 +121,7 @@ public class PushService : IDisposable
|
|||||||
{
|
{
|
||||||
meta ??= [];
|
meta ??= [];
|
||||||
if (title is null && subtitle is null && content is null)
|
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;
|
if (actionUri is not null) meta["action_uri"] = actionUri;
|
||||||
|
|
||||||
@@ -181,35 +137,63 @@ public class PushService : IDisposable
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (save)
|
if (save)
|
||||||
_fbs.Enqueue(notification);
|
{
|
||||||
|
_db.Notifications.Add(notification);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
if (!isSilent)
|
if (!isSilent)
|
||||||
await DeliveryNotification(notification); // returns quickly (does NOT wait for APNS/FCM)
|
_ = _queueService.EnqueuePushNotification(notification, accountId, save);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeliveryNotification(Notification notification)
|
public async Task DeliverPushNotification(Notification notification, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_logger.LogInformation(
|
_ws.SendPacketToAccount(notification.AccountId.ToString(), new WebSocketPacket()
|
||||||
"Delivering notification: {NotificationTopic} #{NotificationId} 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",
|
Type = "notifications.new",
|
||||||
Data = notification
|
Data = notification,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Delivering push notification: {NotificationTopic} with meta {NotificationMeta}",
|
||||||
|
notification.Topic,
|
||||||
|
notification.Meta
|
||||||
|
);
|
||||||
|
|
||||||
// Query subscribers and enqueue push work (non-blocking to the HTTP request)
|
// Get all push subscriptions for the account
|
||||||
var subscribers = await _db.PushSubscriptions
|
var subscriptions = await _db.PushSubscriptions
|
||||||
.Where(s => s.AccountId == notification.AccountId)
|
.Where(s => s.AccountId == notification.AccountId)
|
||||||
.AsNoTracking()
|
.ToListAsync(cancellationToken);
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
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)
|
public async Task MarkNotificationsViewed(ICollection<Notification> notifications)
|
||||||
@@ -228,6 +212,7 @@ public class PushService : IDisposable
|
|||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
await _db.Notifications
|
await _db.Notifications
|
||||||
.Where(n => n.AccountId == accountId)
|
.Where(n => n.AccountId == accountId)
|
||||||
|
.Where(n => n.ViewedAt == null)
|
||||||
.ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now));
|
.ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,20 +220,25 @@ public class PushService : IDisposable
|
|||||||
{
|
{
|
||||||
if (save)
|
if (save)
|
||||||
{
|
{
|
||||||
accounts.ForEach(x =>
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var notifications = accounts.Select(accountId => new Notification
|
||||||
{
|
{
|
||||||
var newNotification = new Notification
|
Topic = notification.Topic,
|
||||||
{
|
Title = notification.Title,
|
||||||
Topic = notification.Topic,
|
Subtitle = notification.Subtitle,
|
||||||
Title = notification.Title,
|
Content = notification.Content,
|
||||||
Subtitle = notification.Subtitle,
|
Meta = notification.Meta,
|
||||||
Content = notification.Content,
|
Priority = notification.Priority,
|
||||||
Meta = notification.Meta,
|
AccountId = accountId,
|
||||||
Priority = notification.Priority,
|
CreatedAt = now,
|
||||||
AccountId = x
|
UpdatedAt = now
|
||||||
};
|
}).ToList();
|
||||||
_fbs.Enqueue(newNotification);
|
|
||||||
});
|
if (notifications.Count != 0)
|
||||||
|
{
|
||||||
|
await _db.Notifications.AddRangeAsync(notifications);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
@@ -269,56 +259,10 @@ public class PushService : IDisposable
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all subscribers once and enqueue to workers
|
await DeliverPushNotification(notification);
|
||||||
var subscribers = await _db.PushSubscriptions
|
|
||||||
.Where(s => accounts.Contains(s.AccountId))
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
await EnqueuePushWork(notification, subscribers);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task EnqueuePushWork(Notification notification, IEnumerable<PushSubscription> subscriptions)
|
private async Task SendPushNotificationAsync(PushSubscription subscription, Notification notification)
|
||||||
{
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -415,4 +359,27 @@ public class PushService : IDisposable
|
|||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
$"Successfully pushed notification #{notification.Id} to device {subscription.DeviceId} provider {subscription.Provider}");
|
$"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();
|
||||||
|
}
|
||||||
|
}
|
@@ -3,6 +3,7 @@ using DysonNetwork.Pusher.Startup;
|
|||||||
using DysonNetwork.Shared.Auth;
|
using DysonNetwork.Shared.Auth;
|
||||||
using DysonNetwork.Shared.Http;
|
using DysonNetwork.Shared.Http;
|
||||||
using DysonNetwork.Shared.Registry;
|
using DysonNetwork.Shared.Registry;
|
||||||
|
using DysonNetwork.Shared.Stream;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -12,6 +13,7 @@ builder.ConfigureAppKestrel(builder.Configuration);
|
|||||||
|
|
||||||
// Add application services
|
// Add application services
|
||||||
builder.Services.AddRegistryService(builder.Configuration);
|
builder.Services.AddRegistryService(builder.Configuration);
|
||||||
|
builder.Services.AddStreamConnection(builder.Configuration);
|
||||||
builder.Services.AddAppServices(builder.Configuration);
|
builder.Services.AddAppServices(builder.Configuration);
|
||||||
builder.Services.AddAppRateLimiting();
|
builder.Services.AddAppRateLimiting();
|
||||||
builder.Services.AddAppAuthentication();
|
builder.Services.AddAppAuthentication();
|
||||||
|
@@ -5,19 +5,19 @@ using DysonNetwork.Shared.Proto;
|
|||||||
using DysonNetwork.Shared.Registry;
|
using DysonNetwork.Shared.Registry;
|
||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace DysonNetwork.Pusher.Services;
|
namespace DysonNetwork.Pusher.Services;
|
||||||
|
|
||||||
public class PusherServiceGrpc(
|
public class PusherServiceGrpc(
|
||||||
EmailService emailService,
|
QueueService queueService,
|
||||||
WebSocketService websocket,
|
WebSocketService websocket,
|
||||||
PushService pushService,
|
PushService pushService
|
||||||
AccountClientHelper accountsHelper
|
|
||||||
) : PusherService.PusherServiceBase
|
) : PusherService.PusherServiceBase
|
||||||
{
|
{
|
||||||
public override async Task<Empty> SendEmail(SendEmailRequest request, ServerCallContext context)
|
public override async Task<Empty> SendEmail(SendEmailRequest request, ServerCallContext context)
|
||||||
{
|
{
|
||||||
await emailService.SendEmailAsync(
|
await queueService.EnqueueEmail(
|
||||||
request.Email.ToName,
|
request.Email.ToName,
|
||||||
request.Email.ToAddress,
|
request.Email.ToAddress,
|
||||||
request.Email.Subject,
|
request.Email.Subject,
|
||||||
@@ -47,13 +47,16 @@ public class PusherServiceGrpc(
|
|||||||
Data = GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Packet.Data),
|
Data = GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Packet.Data),
|
||||||
ErrorMessage = request.Packet.ErrorMessage
|
ErrorMessage = request.Packet.ErrorMessage
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var userId in request.UserIds)
|
foreach (var userId in request.UserIds)
|
||||||
|
{
|
||||||
websocket.SendPacketToAccount(userId, packet);
|
websocket.SendPacketToAccount(userId, packet);
|
||||||
|
}
|
||||||
|
|
||||||
return Task.FromResult(new Empty());
|
return Task.FromResult(new Empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDeviceRequest request,
|
public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDeviceRequest request,
|
||||||
ServerCallContext context)
|
ServerCallContext context)
|
||||||
{
|
{
|
||||||
var packet = new Connection.WebSocketPacket
|
var packet = new Connection.WebSocketPacket
|
||||||
@@ -75,29 +78,42 @@ public class PusherServiceGrpc(
|
|||||||
Data = GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Packet.Data),
|
Data = GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Packet.Data),
|
||||||
ErrorMessage = request.Packet.ErrorMessage
|
ErrorMessage = request.Packet.ErrorMessage
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var deviceId in request.DeviceIds)
|
foreach (var deviceId in request.DeviceIds)
|
||||||
|
{
|
||||||
websocket.SendPacketToDevice(deviceId, packet);
|
websocket.SendPacketToDevice(deviceId, packet);
|
||||||
|
}
|
||||||
|
|
||||||
return Task.FromResult(new Empty());
|
return Task.FromResult(new Empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<Empty> SendPushNotificationToUser(SendPushNotificationToUserRequest request,
|
public override async Task<Empty> SendPushNotificationToUser(SendPushNotificationToUserRequest request,
|
||||||
ServerCallContext context)
|
ServerCallContext context)
|
||||||
{
|
{
|
||||||
var account = await accountsHelper.GetAccount(Guid.Parse(request.UserId));
|
var notification = new Notification.Notification
|
||||||
await pushService.SendNotification(
|
{
|
||||||
account,
|
Topic = request.Notification.Topic,
|
||||||
request.Notification.Topic,
|
Title = request.Notification.Title,
|
||||||
request.Notification.Title,
|
Subtitle = request.Notification.Subtitle,
|
||||||
request.Notification.Subtitle,
|
Content = request.Notification.Body,
|
||||||
request.Notification.Body,
|
Meta = request.Notification.HasMeta
|
||||||
request.Notification.HasMeta
|
|
||||||
? GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Notification.Meta) ?? []
|
? GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Notification.Meta) ?? []
|
||||||
: [],
|
: [],
|
||||||
request.Notification.ActionUri,
|
AccountId = Guid.Parse(request.UserId),
|
||||||
request.Notification.IsSilent,
|
};
|
||||||
|
|
||||||
|
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
|
request.Notification.IsSavable
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Empty();
|
return new Empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,10 +130,22 @@ public class PusherServiceGrpc(
|
|||||||
? GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Notification.Meta) ?? []
|
? GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Notification.Meta) ?? []
|
||||||
: [],
|
: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (request.Notification.ActionUri is not null)
|
if (request.Notification.ActionUri is not null)
|
||||||
notification.Meta["action_uri"] = request.Notification.ActionUri;
|
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();
|
return new Empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
141
DysonNetwork.Pusher/Services/QueueBackgroundService.cs
Normal file
141
DysonNetwork.Pusher/Services/QueueBackgroundService.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
61
DysonNetwork.Pusher/Services/QueueService.cs
Normal file
61
DysonNetwork.Pusher/Services/QueueService.cs
Normal 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;
|
||||||
|
}
|
@@ -134,9 +134,15 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
public static IServiceCollection AddAppBusinessServices(this IServiceCollection services)
|
public static IServiceCollection AddAppBusinessServices(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddScoped<WebSocketService>();
|
services.AddSingleton<WebSocketService>();
|
||||||
services.AddScoped<EmailService>();
|
services.AddScoped<EmailService>();
|
||||||
services.AddScoped<PushService>();
|
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;
|
return services;
|
||||||
}
|
}
|
||||||
|
@@ -11,7 +11,8 @@
|
|||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"App": "Host=localhost;Port=5432;Database=dyson_pusher;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
|
"App": "Host=localhost;Port=5432;Database=dyson_pusher;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
|
||||||
"FastRetrieve": "localhost:6379",
|
"FastRetrieve": "localhost:6379",
|
||||||
"Etcd": "etcd.orb.local:2379"
|
"Etcd": "etcd.orb.local:2379",
|
||||||
|
"Stream": "nats.orb.local:4222"
|
||||||
},
|
},
|
||||||
"Notifications": {
|
"Notifications": {
|
||||||
"Push": {
|
"Push": {
|
||||||
|
@@ -33,7 +33,10 @@ public class DysonTokenAuthHandler(
|
|||||||
AuthSession session;
|
AuthSession session;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
session = await ValidateToken(tokenInfo.Token);
|
session = await ValidateToken(
|
||||||
|
tokenInfo.Token,
|
||||||
|
Request.HttpContext.Connection.RemoteIpAddress?.ToString()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex)
|
catch (InvalidOperationException ex)
|
||||||
{
|
{
|
||||||
@@ -58,7 +61,7 @@ public class DysonTokenAuthHandler(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Add scopes as claims
|
// 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
|
// Add superuser claim if applicable
|
||||||
if (session.Account.IsSuperuser)
|
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.Valid) throw new InvalidOperationException(resp.Message);
|
||||||
if (resp.Session == null) throw new InvalidOperationException("Session not found.");
|
return resp.Session ?? throw new InvalidOperationException("Session not found.");
|
||||||
return resp.Session;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] Base64UrlDecode(string base64Url)
|
private static byte[] Base64UrlDecode(string base64Url)
|
||||||
|
350
DysonNetwork.Shared/Data/Account.cs
Normal file
350
DysonNetwork.Shared/Data/Account.cs
Normal 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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@@ -10,6 +10,8 @@ public abstract class ActionLogType
|
|||||||
public const string PostUpdate = "posts.update";
|
public const string PostUpdate = "posts.update";
|
||||||
public const string PostDelete = "posts.delete";
|
public const string PostDelete = "posts.delete";
|
||||||
public const string PostReact = "posts.react";
|
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 MessageCreate = "messages.create";
|
||||||
public const string MessageUpdate = "messages.update";
|
public const string MessageUpdate = "messages.update";
|
||||||
public const string MessageDelete = "messages.delete";
|
public const string MessageDelete = "messages.delete";
|
||||||
@@ -37,4 +39,4 @@ public abstract class ActionLogType
|
|||||||
public const string ChatroomLeave = "chatrooms.leave";
|
public const string ChatroomLeave = "chatrooms.leave";
|
||||||
public const string ChatroomKick = "chatrooms.kick";
|
public const string ChatroomKick = "chatrooms.kick";
|
||||||
public const string ChatroomAdjustRole = "chatrooms.role.edit";
|
public const string ChatroomAdjustRole = "chatrooms.role.edit";
|
||||||
}
|
}
|
||||||
|
64
DysonNetwork.Shared/Data/Subscription.cs
Normal file
64
DysonNetwork.Shared/Data/Subscription.cs
Normal 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
|
||||||
|
}
|
@@ -21,6 +21,7 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.3.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.3.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7" />
|
||||||
|
<PackageReference Include="NATS.Client.Core" Version="2.6.6" />
|
||||||
<PackageReference Include="NetTopologySuite" Version="2.6.0" />
|
<PackageReference Include="NetTopologySuite" Version="2.6.0" />
|
||||||
<PackageReference Include="NodaTime" Version="3.2.2" />
|
<PackageReference Include="NodaTime" Version="3.2.2" />
|
||||||
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
|
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
|
||||||
|
@@ -20,9 +20,7 @@ public static class KestrelConfiguration
|
|||||||
builder.WebHost.ConfigureKestrel(options =>
|
builder.WebHost.ConfigureKestrel(options =>
|
||||||
{
|
{
|
||||||
options.Limits.MaxRequestBodySize = maxRequestBodySize;
|
options.Limits.MaxRequestBodySize = maxRequestBodySize;
|
||||||
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
|
|
||||||
options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
|
|
||||||
|
|
||||||
var configuredUrl = Environment.GetEnvironmentVariable("ASPNETCORE_URLS");
|
var configuredUrl = Environment.GetEnvironmentVariable("ASPNETCORE_URLS");
|
||||||
if (!string.IsNullOrEmpty(configuredUrl)) return;
|
if (!string.IsNullOrEmpty(configuredUrl)) return;
|
||||||
|
|
||||||
|
@@ -21,7 +21,6 @@ public static class GrpcClientHelper
|
|||||||
? X509Certificate2.CreateFromPemFile(clientCertPath, clientKeyPath)
|
? X509Certificate2.CreateFromPemFile(clientCertPath, clientKeyPath)
|
||||||
: X509Certificate2.CreateFromEncryptedPemFile(clientCertPath, clientCertPassword, clientKeyPath)
|
: X509Certificate2.CreateFromEncryptedPemFile(clientCertPath, clientCertPassword, clientKeyPath)
|
||||||
);
|
);
|
||||||
// TODO: Verify the ca in the future
|
|
||||||
handler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true;
|
handler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true;
|
||||||
var httpClient = new HttpClient(handler);
|
var httpClient = new HttpClient(handler);
|
||||||
httpClient.DefaultRequestVersion = HttpVersion.Version20;
|
httpClient.DefaultRequestVersion = HttpVersion.Version20;
|
||||||
|
@@ -32,6 +32,8 @@ message Account {
|
|||||||
|
|
||||||
google.protobuf.Timestamp created_at = 14;
|
google.protobuf.Timestamp created_at = 14;
|
||||||
google.protobuf.Timestamp updated_at = 15;
|
google.protobuf.Timestamp updated_at = 15;
|
||||||
|
|
||||||
|
google.protobuf.StringValue automated_id = 17;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enum for status attitude
|
// Enum for status attitude
|
||||||
@@ -75,6 +77,8 @@ message AccountProfile {
|
|||||||
int32 experience = 14;
|
int32 experience = 14;
|
||||||
int32 level = 15;
|
int32 level = 15;
|
||||||
double leveling_progress = 16;
|
double leveling_progress = 16;
|
||||||
|
double social_credits = 17;
|
||||||
|
int32 social_credits_level = 18;
|
||||||
|
|
||||||
CloudFile picture = 19;
|
CloudFile picture = 19;
|
||||||
CloudFile background = 20;
|
CloudFile background = 20;
|
||||||
@@ -244,7 +248,9 @@ message GetAccountStatusBatchResponse {
|
|||||||
service AccountService {
|
service AccountService {
|
||||||
// Account Operations
|
// Account Operations
|
||||||
rpc GetAccount(GetAccountRequest) returns (Account) {}
|
rpc GetAccount(GetAccountRequest) returns (Account) {}
|
||||||
|
rpc GetBotAccount(GetBotAccountRequest) returns (Account) {}
|
||||||
rpc GetAccountBatch(GetAccountBatchRequest) returns (GetAccountBatchResponse) {}
|
rpc GetAccountBatch(GetAccountBatchRequest) returns (GetAccountBatchResponse) {}
|
||||||
|
rpc GetBotAccountBatch(GetBotAccountBatchRequest) returns (GetAccountBatchResponse) {}
|
||||||
rpc LookupAccountBatch(LookupAccountBatchRequest) returns (GetAccountBatchResponse) {}
|
rpc LookupAccountBatch(LookupAccountBatchRequest) returns (GetAccountBatchResponse) {}
|
||||||
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse) {}
|
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse) {}
|
||||||
|
|
||||||
@@ -319,10 +325,18 @@ message GetAccountRequest {
|
|||||||
string id = 1; // Account ID to retrieve
|
string id = 1; // Account ID to retrieve
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message GetBotAccountRequest {
|
||||||
|
string automated_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
message GetAccountBatchRequest {
|
message GetAccountBatchRequest {
|
||||||
repeated string id = 1; // Account ID to retrieve
|
repeated string id = 1; // Account ID to retrieve
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message GetBotAccountBatchRequest {
|
||||||
|
repeated string automated_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
message LookupAccountBatchRequest {
|
message LookupAccountBatchRequest {
|
||||||
repeated string names = 1;
|
repeated string names = 1;
|
||||||
}
|
}
|
||||||
|
@@ -13,7 +13,6 @@ import 'account.proto';
|
|||||||
// Represents a user session
|
// Represents a user session
|
||||||
message AuthSession {
|
message AuthSession {
|
||||||
string id = 1;
|
string id = 1;
|
||||||
google.protobuf.StringValue label = 2;
|
|
||||||
optional google.protobuf.Timestamp last_granted_at = 3;
|
optional google.protobuf.Timestamp last_granted_at = 3;
|
||||||
optional google.protobuf.Timestamp expired_at = 4;
|
optional google.protobuf.Timestamp expired_at = 4;
|
||||||
string account_id = 5;
|
string account_id = 5;
|
||||||
@@ -71,6 +70,7 @@ service AuthService {
|
|||||||
|
|
||||||
message AuthenticateRequest {
|
message AuthenticateRequest {
|
||||||
string token = 1;
|
string token = 1;
|
||||||
|
optional google.protobuf.StringValue ip_address = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AuthenticateResponse {
|
message AuthenticateResponse {
|
||||||
|
@@ -5,6 +5,7 @@ package proto;
|
|||||||
option csharp_namespace = "DysonNetwork.Shared.Proto";
|
option csharp_namespace = "DysonNetwork.Shared.Proto";
|
||||||
|
|
||||||
import "google/protobuf/timestamp.proto";
|
import "google/protobuf/timestamp.proto";
|
||||||
|
import "google/protobuf/wrappers.proto";
|
||||||
import "account.proto";
|
import "account.proto";
|
||||||
import "file.proto";
|
import "file.proto";
|
||||||
|
|
||||||
@@ -18,6 +19,12 @@ message CustomAppOauthConfig {
|
|||||||
bool allow_offline_access = 7;
|
bool allow_offline_access = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message CustomAppLinks {
|
||||||
|
string home_page = 1;
|
||||||
|
string privacy_policy = 2;
|
||||||
|
string terms_of_service = 3;
|
||||||
|
}
|
||||||
|
|
||||||
enum CustomAppStatus {
|
enum CustomAppStatus {
|
||||||
CUSTOM_APP_STATUS_UNSPECIFIED = 0;
|
CUSTOM_APP_STATUS_UNSPECIFIED = 0;
|
||||||
DEVELOPING = 1;
|
DEVELOPING = 1;
|
||||||
@@ -34,10 +41,10 @@ message CustomApp {
|
|||||||
CustomAppStatus status = 5;
|
CustomAppStatus status = 5;
|
||||||
|
|
||||||
// jsonb columns represented as bytes
|
// jsonb columns represented as bytes
|
||||||
bytes picture = 6;
|
CloudFile picture = 6;
|
||||||
bytes background = 7;
|
CloudFile background = 7;
|
||||||
bytes verification = 8;
|
VerificationMark verification = 8;
|
||||||
bytes links = 9;
|
CustomAppLinks links = 9;
|
||||||
CustomAppOauthConfig oauth_config = 13;
|
CustomAppOauthConfig oauth_config = 13;
|
||||||
|
|
||||||
string project_id = 10;
|
string project_id = 10;
|
||||||
@@ -108,6 +115,8 @@ message BotAccount {
|
|||||||
message CreateBotAccountRequest {
|
message CreateBotAccountRequest {
|
||||||
Account account = 1;
|
Account account = 1;
|
||||||
string automated_id = 2;
|
string automated_id = 2;
|
||||||
|
optional string picture_id = 8;
|
||||||
|
optional string background_id = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CreateBotAccountResponse {
|
message CreateBotAccountResponse {
|
||||||
@@ -117,6 +126,8 @@ message CreateBotAccountResponse {
|
|||||||
message UpdateBotAccountRequest {
|
message UpdateBotAccountRequest {
|
||||||
string automated_id = 1; // ID of the bot account to update
|
string automated_id = 1; // ID of the bot account to update
|
||||||
Account account = 2; // Updated account information
|
Account account = 2; // Updated account information
|
||||||
|
optional string picture_id = 8;
|
||||||
|
optional string background_id = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
message UpdateBotAccountResponse {
|
message UpdateBotAccountResponse {
|
||||||
@@ -132,15 +143,43 @@ message DeleteBotAccountResponse {
|
|||||||
bool success = 1; // Whether the deletion was successful
|
bool success = 1; // Whether the deletion was successful
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// This service should be implemented by the Pass service to handle the creation, update, and deletion of bot accounts
|
||||||
service BotAccountReceiverService {
|
service BotAccountReceiverService {
|
||||||
// Create a new bot account
|
|
||||||
rpc CreateBotAccount(CreateBotAccountRequest) returns (CreateBotAccountResponse);
|
rpc CreateBotAccount(CreateBotAccountRequest) returns (CreateBotAccountResponse);
|
||||||
|
|
||||||
// Update an existing bot account
|
|
||||||
rpc UpdateBotAccount(UpdateBotAccountRequest) returns (UpdateBotAccountResponse);
|
rpc UpdateBotAccount(UpdateBotAccountRequest) returns (UpdateBotAccountResponse);
|
||||||
|
|
||||||
// Delete a bot account
|
|
||||||
rpc DeleteBotAccount(DeleteBotAccountRequest) returns (DeleteBotAccountResponse);
|
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);
|
||||||
}
|
}
|
||||||
|
|
@@ -206,6 +206,11 @@ message DeleteResourceReferencesRequest {
|
|||||||
optional string usage = 2;
|
optional string usage = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message DeleteResourceReferencesBatchRequest {
|
||||||
|
repeated string resource_ids = 1;
|
||||||
|
optional string usage = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message DeleteResourceReferencesResponse {
|
message DeleteResourceReferencesResponse {
|
||||||
int32 deleted_count = 1;
|
int32 deleted_count = 1;
|
||||||
}
|
}
|
||||||
@@ -277,6 +282,9 @@ service FileReferenceService {
|
|||||||
|
|
||||||
// Deletes references for a specific resource and optional usage
|
// Deletes references for a specific resource and optional usage
|
||||||
rpc DeleteResourceReferences(DeleteResourceReferencesRequest) returns (DeleteResourceReferencesResponse);
|
rpc DeleteResourceReferences(DeleteResourceReferencesRequest) returns (DeleteResourceReferencesResponse);
|
||||||
|
|
||||||
|
// Deletes references for multiple specific resources and optional usage
|
||||||
|
rpc DeleteResourceReferencesBatch(DeleteResourceReferencesBatchRequest) returns (DeleteResourceReferencesResponse);
|
||||||
|
|
||||||
// Deletes a specific file reference
|
// Deletes a specific file reference
|
||||||
rpc DeleteReference(DeleteReferenceRequest) returns (DeleteReferenceResponse);
|
rpc DeleteReference(DeleteReferenceRequest) returns (DeleteReferenceResponse);
|
||||||
|
83
DysonNetwork.Shared/Proto/leveling.proto
Normal file
83
DysonNetwork.Shared/Proto/leveling.proto
Normal 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);
|
||||||
|
}
|
@@ -11,6 +11,13 @@ public class AccountClientHelper(AccountService.AccountServiceClient accounts)
|
|||||||
var response = await accounts.GetAccountAsync(request);
|
var response = await accounts.GetAccountAsync(request);
|
||||||
return response;
|
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)
|
public async Task<List<Account>> GetAccountBatch(List<Guid> ids)
|
||||||
{
|
{
|
||||||
@@ -19,6 +26,14 @@ public class AccountClientHelper(AccountService.AccountServiceClient accounts)
|
|||||||
var response = await accounts.GetAccountBatchAsync(request);
|
var response = await accounts.GetAccountBatchAsync(request);
|
||||||
return response.Accounts.ToList();
|
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)
|
public async Task<Dictionary<Guid, AccountStatusReference>> GetAccountStatusBatch(List<Guid> ids)
|
||||||
{
|
{
|
||||||
|
11
DysonNetwork.Shared/Stream/AccountEvent.cs
Normal file
11
DysonNetwork.Shared/Stream/AccountEvent.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
21
DysonNetwork.Shared/Stream/Connector.cs
Normal file
21
DysonNetwork.Shared/Stream/Connector.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NATS.Client.Core;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Shared.Stream;
|
||||||
|
|
||||||
|
public static class Connector
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddStreamConnection(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var connectionString = configuration.GetConnectionString("Stream");
|
||||||
|
if (connectionString is null)
|
||||||
|
throw new ArgumentNullException(nameof(connectionString));
|
||||||
|
services.AddSingleton<INatsConnection>(_ => new NatsConnection(new NatsOpts()
|
||||||
|
{
|
||||||
|
Url = connectionString
|
||||||
|
}));
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
@@ -1,7 +1,8 @@
|
|||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using DysonNetwork.Sphere.WebReader;
|
|
||||||
using DysonNetwork.Sphere.Discovery;
|
using DysonNetwork.Sphere.Discovery;
|
||||||
using DysonNetwork.Sphere.Post;
|
using DysonNetwork.Sphere.Post;
|
||||||
|
using DysonNetwork.Sphere.Realm;
|
||||||
|
using DysonNetwork.Sphere.WebReader;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ public class ActivityService(
|
|||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
Publisher.PublisherService pub,
|
Publisher.PublisherService pub,
|
||||||
PostService ps,
|
PostService ps,
|
||||||
|
RealmService rs,
|
||||||
DiscoveryService ds,
|
DiscoveryService ds,
|
||||||
AccountService.AccountServiceClient accounts
|
AccountService.AccountServiceClient accounts
|
||||||
)
|
)
|
||||||
@@ -27,58 +29,28 @@ public class ActivityService(
|
|||||||
public async Task<List<Activity>> GetActivitiesForAnyone(
|
public async Task<List<Activity>> GetActivitiesForAnyone(
|
||||||
int take,
|
int take,
|
||||||
Instant? cursor,
|
Instant? cursor,
|
||||||
HashSet<string>? debugInclude = null
|
HashSet<string>? debugInclude = null)
|
||||||
)
|
|
||||||
{
|
{
|
||||||
var activities = new List<Activity>();
|
var activities = new List<Activity>();
|
||||||
debugInclude ??= new HashSet<string>();
|
debugInclude ??= new HashSet<string>();
|
||||||
|
|
||||||
|
// Add realm discovery if needed
|
||||||
if (cursor == null && (debugInclude.Contains("realms") || Random.Shared.NextDouble() < 0.2))
|
if (cursor == null && (debugInclude.Contains("realms") || Random.Shared.NextDouble() < 0.2))
|
||||||
{
|
{
|
||||||
var realms = await ds.GetCommunityRealmAsync(null, 5, 0, true);
|
var realmActivity = await GetRealmDiscoveryActivity();
|
||||||
if (realms.Count > 0)
|
if (realmActivity != null)
|
||||||
{
|
activities.Add(realmActivity);
|
||||||
activities.Add(new DiscoveryActivity(
|
|
||||||
realms.Select(x => new DiscoveryItem("realm", x)).ToList()
|
|
||||||
).ToActivity());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add article discovery if needed
|
||||||
if (debugInclude.Contains("articles") || Random.Shared.NextDouble() < 0.2)
|
if (debugInclude.Contains("articles") || Random.Shared.NextDouble() < 0.2)
|
||||||
{
|
{
|
||||||
var recentFeedIds = await db.WebArticles
|
var articleActivity = await GetArticleDiscoveryActivity();
|
||||||
.GroupBy(a => a.FeedId)
|
if (articleActivity != null)
|
||||||
.OrderByDescending(g => g.Max(a => a.PublishedAt))
|
activities.Add(articleActivity);
|
||||||
.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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch a larger batch of recent posts to rank
|
// Get and process posts
|
||||||
var postsQuery = db.Posts
|
var postsQuery = db.Posts
|
||||||
.Include(e => e.RepliedPost)
|
.Include(e => e.RepliedPost)
|
||||||
.Include(e => e.ForwardedPost)
|
.Include(e => e.ForwardedPost)
|
||||||
@@ -89,31 +61,13 @@ public class ActivityService(
|
|||||||
.Where(p => cursor == null || p.PublishedAt < cursor)
|
.Where(p => cursor == null || p.PublishedAt < cursor)
|
||||||
.OrderByDescending(p => p.PublishedAt)
|
.OrderByDescending(p => p.PublishedAt)
|
||||||
.FilterWithVisibility(null, [], [], isListing: true)
|
.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();
|
var posts = await GetAndProcessPosts(postsQuery);
|
||||||
posts = await ps.LoadPostInfo(posts, null, true);
|
posts = RankPosts(posts, take);
|
||||||
|
|
||||||
var postsId = posts.Select(e => e.Id).ToList();
|
// Add posts to activities
|
||||||
var reactionMaps = await ps.GetPostReactionMapBatch(postsId);
|
activities.AddRange(posts.Select(post => post.ToActivity()));
|
||||||
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());
|
|
||||||
|
|
||||||
if (activities.Count == 0)
|
if (activities.Count == 0)
|
||||||
activities.Add(Activity.Empty());
|
activities.Add(Activity.Empty());
|
||||||
@@ -126,135 +80,76 @@ public class ActivityService(
|
|||||||
Instant? cursor,
|
Instant? cursor,
|
||||||
Account currentUser,
|
Account currentUser,
|
||||||
string? filter = null,
|
string? filter = null,
|
||||||
HashSet<string>? debugInclude = null
|
HashSet<string>? debugInclude = null)
|
||||||
)
|
|
||||||
{
|
{
|
||||||
var activities = new List<Activity>();
|
var activities = new List<Activity>();
|
||||||
|
debugInclude ??= [];
|
||||||
|
|
||||||
|
// Get user's friends and publishers
|
||||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||||
{
|
{
|
||||||
AccountId = currentUser.Id
|
AccountId = currentUser.Id
|
||||||
});
|
});
|
||||||
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||||
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
|
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
|
||||||
debugInclude ??= [];
|
|
||||||
|
|
||||||
|
// Add discovery activities if no specific filter is applied
|
||||||
if (string.IsNullOrEmpty(filter))
|
if (string.IsNullOrEmpty(filter))
|
||||||
{
|
{
|
||||||
|
// Add realm discovery if needed
|
||||||
if (cursor == null && (debugInclude.Contains("realms") || Random.Shared.NextDouble() < 0.2))
|
if (cursor == null && (debugInclude.Contains("realms") || Random.Shared.NextDouble() < 0.2))
|
||||||
{
|
{
|
||||||
var realms = await ds.GetCommunityRealmAsync(null, 5, 0, true);
|
var realmActivity = await GetRealmDiscoveryActivity();
|
||||||
if (realms.Count > 0)
|
if (realmActivity != null)
|
||||||
{
|
activities.Add(realmActivity);
|
||||||
activities.Add(new DiscoveryActivity(
|
|
||||||
realms.Select(x => new DiscoveryItem("realm", x)).ToList()
|
|
||||||
).ToActivity());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add publisher discovery if needed
|
||||||
if (cursor == null && (debugInclude.Contains("publishers") || Random.Shared.NextDouble() < 0.2))
|
if (cursor == null && (debugInclude.Contains("publishers") || Random.Shared.NextDouble() < 0.2))
|
||||||
{
|
{
|
||||||
var popularPublishers = await GetPopularPublishers(5);
|
var publisherActivity = await GetPublisherDiscoveryActivity();
|
||||||
if (popularPublishers.Count > 0)
|
if (publisherActivity != null)
|
||||||
{
|
activities.Add(publisherActivity);
|
||||||
activities.Add(new DiscoveryActivity(
|
|
||||||
popularPublishers.Select(x => new DiscoveryItem("publisher", x)).ToList()
|
|
||||||
).ToActivity());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add article discovery if needed
|
||||||
if (debugInclude.Contains("articles") || Random.Shared.NextDouble() < 0.2)
|
if (debugInclude.Contains("articles") || Random.Shared.NextDouble() < 0.2)
|
||||||
{
|
{
|
||||||
var recentFeedIds = await db.WebArticles
|
var articleActivity = await GetArticleDiscoveryActivity();
|
||||||
.GroupBy(a => a.FeedId)
|
if (articleActivity != null)
|
||||||
.OrderByDescending(g => g.Max(a => a.PublishedAt))
|
activities.Add(articleActivity);
|
||||||
.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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get publishers based on filter
|
// Get publishers based on filter
|
||||||
var filteredPublishers = filter switch
|
var filteredPublishers = await GetFilteredPublishers(filter, currentUser, userFriends);
|
||||||
{
|
|
||||||
"subscriptions" => await pub.GetSubscribedPublishers(Guid.Parse(currentUser.Id)),
|
|
||||||
"friends" => (await pub.GetUserPublishersBatch(userFriends)).SelectMany(x => x.Value)
|
|
||||||
.DistinctBy(x => x.Id)
|
|
||||||
.ToList(),
|
|
||||||
_ => null
|
|
||||||
};
|
|
||||||
|
|
||||||
var filteredPublishersId = filteredPublishers?.Select(e => e.Id).ToList();
|
var filteredPublishersId = filteredPublishers?.Select(e => e.Id).ToList();
|
||||||
|
|
||||||
// Build the query based on the filter
|
var userRealms = await rs.GetUserRealms(Guid.Parse(currentUser.Id));
|
||||||
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();
|
|
||||||
|
|
||||||
if (filteredPublishersId is not null)
|
// Build and execute the posts query
|
||||||
postsQuery = postsQuery.Where(p => filteredPublishersId.Contains(p.PublisherId));
|
var postsQuery = BuildPostsQuery(cursor, filteredPublishersId, userRealms);
|
||||||
|
|
||||||
// Complete the query with visibility filtering and execute
|
// Apply visibility filtering and execute
|
||||||
var posts = await postsQuery
|
postsQuery = postsQuery
|
||||||
.FilterWithVisibility(currentUser, userFriends, filter is null ? userPublishers : [], isListing: true)
|
.FilterWithVisibility(
|
||||||
.Take(take * 5) // Fetch more posts to have a good pool for ranking
|
currentUser,
|
||||||
.ToListAsync();
|
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();
|
posts = RankPosts(posts, take);
|
||||||
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>();
|
|
||||||
|
|
||||||
// Track view for each post in the feed
|
// Add posts to activities
|
||||||
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
|
|
||||||
activities.AddRange(posts.Select(post => post.ToActivity()));
|
activities.AddRange(posts.Select(post => post.ToActivity()));
|
||||||
|
|
||||||
if (activities.Count == 0)
|
if (activities.Count == 0)
|
||||||
@@ -263,11 +158,20 @@ public class ActivityService(
|
|||||||
return activities;
|
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);
|
// TODO: This feature is disabled for now
|
||||||
var postCount = posts.Count;
|
// Uncomment and implement when ready
|
||||||
return score + postCount;
|
/*
|
||||||
|
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)
|
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 publisherIds = posts.Select(p => p.PublisherId).Distinct().ToList();
|
||||||
var publishers = await db.Publishers.Where(p => publisherIds.Contains(p.Id)).ToListAsync();
|
var publishers = await db.Publishers.Where(p => publisherIds.Contains(p.Id)).ToListAsync();
|
||||||
|
|
||||||
var rankedPublishers = publishers
|
return publishers
|
||||||
.Select(p => new
|
.Select(p => new
|
||||||
{
|
{
|
||||||
Publisher = p,
|
Publisher = p,
|
||||||
@@ -292,7 +196,129 @@ public class ActivityService(
|
|||||||
.Select(x => x.Publisher)
|
.Select(x => x.Publisher)
|
||||||
.Take(take)
|
.Take(take)
|
||||||
.ToList();
|
.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;
|
||||||
|
}
|
||||||
|
}
|
@@ -35,6 +35,7 @@ public class AppDatabase(
|
|||||||
public DbSet<PostCategory> PostCategories { get; set; } = null!;
|
public DbSet<PostCategory> PostCategories { get; set; } = null!;
|
||||||
public DbSet<PostCollection> PostCollections { get; set; } = null!;
|
public DbSet<PostCollection> PostCollections { get; set; } = null!;
|
||||||
public DbSet<PostFeaturedRecord> PostFeaturedRecords { 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.Poll> Polls { get; set; } = null!;
|
||||||
public DbSet<Poll.PollQuestion> PollQuestions { 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.WebArticle> WebArticles { get; set; } = null!;
|
||||||
public DbSet<WebReader.WebFeed> WebFeeds { 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)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
|
@@ -150,7 +150,7 @@ public class ChatRoomController(
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<ChatRoom>> GetDirectChatRoom(Guid accountId)
|
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();
|
return Unauthorized();
|
||||||
|
|
||||||
var room = await db.ChatRooms
|
var room = await db.ChatRooms
|
||||||
@@ -968,25 +968,31 @@ public class ChatRoomController(
|
|||||||
|
|
||||||
private async Task _SendInviteNotify(ChatMember member, Account sender)
|
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 title = localizer["ChatInviteTitle"];
|
||||||
|
|
||||||
string body = member.ChatRoom.Type == ChatRoomType.DirectMessage
|
string body = member.ChatRoom.Type == ChatRoomType.DirectMessage
|
||||||
? localizer["ChatInviteDirectBody", sender.Nick]
|
? localizer["ChatInviteDirectBody", sender.Nick]
|
||||||
: localizer["ChatInviteBody", member.ChatRoom.Name ?? "Unnamed"];
|
: localizer["ChatInviteBody", member.ChatRoom.Name ?? "Unnamed"];
|
||||||
|
|
||||||
CultureService.SetCultureInfo(member.Account!.Language);
|
|
||||||
await pusher.SendPushNotificationToUserAsync(
|
await pusher.SendPushNotificationToUserAsync(
|
||||||
new SendPushNotificationToUserRequest
|
new SendPushNotificationToUserRequest
|
||||||
{
|
{
|
||||||
UserId = member.Account.Id.ToString(),
|
UserId = account.Id,
|
||||||
Notification = new PushNotification
|
Notification = new PushNotification
|
||||||
{
|
{
|
||||||
Topic = "invites.chats",
|
Topic = "invites.chats",
|
||||||
Title = title,
|
Title = title,
|
||||||
Body = body,
|
Body = body,
|
||||||
IsSavable = true
|
IsSavable = true,
|
||||||
|
Meta = GrpcTypeHelper.ConvertObjectToByteString(new
|
||||||
|
{
|
||||||
|
room_id = member.ChatRoomId
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -69,7 +69,8 @@ public partial class ChatService(
|
|||||||
dbMessage,
|
dbMessage,
|
||||||
dbMessage.Sender,
|
dbMessage.Sender,
|
||||||
dbMessage.ChatRoom,
|
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="message">The message to process</param>
|
||||||
/// <param name="webReader">The web reader service</param>
|
/// <param name="webReader">The web reader service</param>
|
||||||
/// <returns>The message with link previews added to its meta data</returns>
|
/// <returns>The message with link previews added to its meta data</returns>
|
||||||
public async Task<Message> PreviewMessageLinkAsync(Message message,
|
public async Task<Message> PreviewMessageLinkAsync(Message message, WebReaderService? webReader = null)
|
||||||
WebReader.WebReaderService? webReader = null)
|
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(message.Content))
|
if (string.IsNullOrEmpty(message.Content))
|
||||||
return message;
|
return message;
|
||||||
@@ -110,8 +110,7 @@ public partial class ChatService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var embeds = (List<Dictionary<string, object>>)message.Meta["embeds"];
|
var embeds = (List<Dictionary<string, object>>)message.Meta["embeds"];
|
||||||
webReader ??= scopeFactory.CreateScope().ServiceProvider
|
webReader ??= scopeFactory.CreateScope().ServiceProvider.GetRequiredService<WebReaderService>();
|
||||||
.GetRequiredService<WebReader.WebReaderService>();
|
|
||||||
|
|
||||||
// Process up to 3 links to avoid excessive processing
|
// Process up to 3 links to avoid excessive processing
|
||||||
var processedLinks = 0;
|
var processedLinks = 0;
|
||||||
@@ -195,7 +194,8 @@ public partial class ChatService(
|
|||||||
Message message,
|
Message message,
|
||||||
ChatMember sender,
|
ChatMember sender,
|
||||||
ChatRoom room,
|
ChatRoom room,
|
||||||
string type = WebSocketPacketType.MessageNew
|
string type = WebSocketPacketType.MessageNew,
|
||||||
|
bool notify = true
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
message.Sender = sender;
|
message.Sender = sender;
|
||||||
@@ -205,11 +205,29 @@ public partial class ChatService(
|
|||||||
var scopedNty = scope.ServiceProvider.GetRequiredService<PusherService.PusherServiceClient>();
|
var scopedNty = scope.ServiceProvider.GetRequiredService<PusherService.PusherServiceClient>();
|
||||||
var scopedCrs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
|
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" :
|
var roomSubject = room is { Type: ChatRoomType.DirectMessage, Name: null } ? "DM" :
|
||||||
room.Realm is not null ? $"{room.Name}, {room.Realm.Name}" : room.Name;
|
room.Realm is not null ? $"{room.Name}, {room.Realm.Name}" : room.Name;
|
||||||
|
|
||||||
var members = await scopedCrs.ListRoomMembers(room.Id);
|
|
||||||
|
|
||||||
if (sender.Account is null)
|
if (sender.Account is null)
|
||||||
sender = await scopedCrs.LoadMemberAccount(sender);
|
sender = await scopedCrs.LoadMemberAccount(sender);
|
||||||
if (sender.Account is null)
|
if (sender.Account is null)
|
||||||
@@ -273,17 +291,6 @@ public partial class ChatService(
|
|||||||
|
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
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 = [];
|
List<Account> accountsToNotify = [];
|
||||||
foreach (
|
foreach (
|
||||||
var member in members
|
var member in members
|
||||||
@@ -524,25 +531,51 @@ public partial class ChatService(
|
|||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var changesMembers = changes
|
// Get messages that need member data
|
||||||
|
var messagesNeedingSenders = changes
|
||||||
.Where(c => c.Message != null)
|
.Where(c => c.Message != null)
|
||||||
.Select(c => c.Message!.Sender)
|
.Select(c => c.Message!)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// If no messages need senders, return with the latest timestamp from changes
|
||||||
|
if (messagesNeedingSenders.Count <= 0)
|
||||||
|
{
|
||||||
|
var latestTimestamp = changes.Count > 0
|
||||||
|
? changes.Max(c => c.Timestamp)
|
||||||
|
: SystemClock.Instance.GetCurrentInstant();
|
||||||
|
|
||||||
|
return new SyncResponse
|
||||||
|
{
|
||||||
|
Changes = changes,
|
||||||
|
CurrentTimestamp = latestTimestamp
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load member accounts for messages that need them
|
||||||
|
var changesMembers = messagesNeedingSenders
|
||||||
|
.Select(m => m.Sender)
|
||||||
.DistinctBy(x => x.Id)
|
.DistinctBy(x => x.Id)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
changesMembers = await crs.LoadMemberAccounts(changesMembers);
|
changesMembers = await crs.LoadMemberAccounts(changesMembers);
|
||||||
|
|
||||||
foreach (var change in changes)
|
// Update sender information for messages that have it
|
||||||
|
foreach (var message in messagesNeedingSenders)
|
||||||
{
|
{
|
||||||
if (change.Message == null) continue;
|
var sender = changesMembers.FirstOrDefault(x => x.Id == message.SenderId);
|
||||||
var sender = changesMembers.FirstOrDefault(x => x.Id == change.Message.SenderId);
|
|
||||||
if (sender is not null)
|
if (sender is not null)
|
||||||
change.Message.Sender = sender;
|
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
|
return new SyncResponse
|
||||||
{
|
{
|
||||||
Changes = changes,
|
Changes = changes,
|
||||||
CurrentTimestamp = SystemClock.Instance.GetCurrentInstant()
|
CurrentTimestamp = latestChangeTimestamp
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,4 +687,4 @@ public class SyncResponse
|
|||||||
{
|
{
|
||||||
public List<MessageChange> Changes { get; set; } = [];
|
public List<MessageChange> Changes { get; set; } = [];
|
||||||
public Instant CurrentTimestamp { get; set; }
|
public Instant CurrentTimestamp { get; set; }
|
||||||
}
|
}
|
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="post" class="container max-w-5xl mx-auto mt-4">
|
<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">
|
<n-gi span="3">
|
||||||
<post-item :item="post" />
|
<post-item :item="post" />
|
||||||
</n-gi>
|
</n-gi>
|
||||||
|
@@ -30,6 +30,7 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="NATS.Client.Core" Version="2.6.6" />
|
||||||
<PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115">
|
<PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
<PackageReference Include="StackExchange.Redis" Version="2.8.41"/>
|
<PackageReference Include="StackExchange.Redis" Version="2.8.41"/>
|
||||||
<PackageReference Include="StackExchange.Redis.Extensions.AspNetCore" Version="11.0.0"/>
|
<PackageReference Include="StackExchange.Redis.Extensions.AspNetCore" Version="11.0.0"/>
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3"/>
|
<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="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.3"/>
|
||||||
<PackageReference Include="System.ServiceModel.Syndication" Version="9.0.7" />
|
<PackageReference Include="System.ServiceModel.Syndication" Version="9.0.7" />
|
||||||
<PackageReference Include="TencentCloudSDK.Tmt" Version="3.0.1276" />
|
<PackageReference Include="TencentCloudSDK.Tmt" Version="3.0.1276" />
|
||||||
|
1952
DysonNetwork.Sphere/Migrations/20250819175925_AddPostParentGone.Designer.cs
generated
Normal file
1952
DysonNetwork.Sphere/Migrations/20250819175925_AddPostParentGone.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user