Compare commits
50 Commits
c1c17b5f4e
...
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 |
@@ -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
|
||||||
|
@@ -24,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"
|
||||||
}
|
}
|
||||||
|
@@ -337,8 +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) &&
|
case "image":
|
||||||
!AnimatedImageExtensions.Contains(fileExtension):
|
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))
|
||||||
{
|
{
|
||||||
@@ -672,8 +678,8 @@ public class FileService(
|
|||||||
foreach (var file in fileGroup)
|
foreach (var file in fileGroup)
|
||||||
{
|
{
|
||||||
objectsToDelete.Add(file.StorageId ?? file.Id);
|
objectsToDelete.Add(file.StorageId ?? file.Id);
|
||||||
if(file.HasCompression) objectsToDelete.Add(file.StorageId ?? file.Id + ".compressed");
|
if (file.HasCompression) objectsToDelete.Add(file.StorageId ?? file.Id + ".compressed");
|
||||||
if(file.HasThumbnail) objectsToDelete.Add(file.StorageId ?? file.Id + ".thumbnail");
|
if (file.HasThumbnail) objectsToDelete.Add(file.StorageId ?? file.Id + ".thumbnail");
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.RemoveObjectsAsync(
|
await client.RemoveObjectsAsync(
|
||||||
|
@@ -76,26 +76,6 @@ public class RegistryProxyConfigProvider : IProxyConfigProvider, IDisposable
|
|||||||
|
|
||||||
var gatewayServiceName = _configuration["Service:Name"];
|
var gatewayServiceName = _configuration["Service:Name"];
|
||||||
|
|
||||||
// Add direct route for /cgi to Gateway
|
|
||||||
var gatewayCluster = new ClusterConfig
|
|
||||||
{
|
|
||||||
ClusterId = "gateway-self",
|
|
||||||
Destinations = new Dictionary<string, DestinationConfig>
|
|
||||||
{
|
|
||||||
{ "self", new DestinationConfig { Address = _configuration["Kestrel:Endpoints:Http:Url"] ?? "http://localhost:5000" } }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
clusters.Add(gatewayCluster);
|
|
||||||
|
|
||||||
var cgiRoute = new RouteConfig
|
|
||||||
{
|
|
||||||
RouteId = "gateway-cgi-route",
|
|
||||||
ClusterId = "gateway-self",
|
|
||||||
Match = new RouteMatch { Path = "/cgi/{**catch-all}" }
|
|
||||||
};
|
|
||||||
routes.Add(cgiRoute);
|
|
||||||
_logger.LogInformation(" Added CGI Route: /cgi/** -> Gateway");
|
|
||||||
|
|
||||||
// Add direct routes
|
// Add direct routes
|
||||||
foreach (var directRoute in directRoutes)
|
foreach (var directRoute in directRoutes)
|
||||||
{
|
{
|
||||||
|
@@ -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),
|
||||||
|
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
@@ -6,6 +6,7 @@ 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 DysonNetwork.Shared.Stream;
|
||||||
using EFCore.BulkExtensions;
|
using EFCore.BulkExtensions;
|
||||||
@@ -21,6 +22,8 @@ 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,
|
||||||
@@ -182,7 +185,7 @@ public class AccountService(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Account> CreateBotAccount(Account account, Guid automatedId)
|
public async Task<Account> CreateBotAccount(Account account, Guid automatedId, string? pictureId, string? backgroundId)
|
||||||
{
|
{
|
||||||
var dupeAutomateCount = await db.Accounts.Where(a => a.AutomatedId == automatedId).CountAsync();
|
var dupeAutomateCount = await db.Accounts.Where(a => a.AutomatedId == automatedId).CountAsync();
|
||||||
if (dupeAutomateCount > 0)
|
if (dupeAutomateCount > 0)
|
||||||
@@ -195,8 +198,38 @@ public class AccountService(
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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,7 @@ 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 NodaTime.Serialization.Protobuf;
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Auth;
|
namespace DysonNetwork.Pass.Auth;
|
||||||
|
|
||||||
@@ -9,11 +10,41 @@ public class ApiKey : ModelBase
|
|||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
[MaxLength(1024)] public string Label { get; set; } = null!;
|
[MaxLength(1024)] public string Label { get; set; } = null!;
|
||||||
|
|
||||||
public Guid AccountId { get; set; }
|
public Guid AccountId { get; set; }
|
||||||
public Account.Account Account { get; set; } = null!;
|
public Account.Account Account { get; set; } = null!;
|
||||||
public Guid SessionId { get; set; }
|
public Guid SessionId { get; set; }
|
||||||
public AuthSession Session { get; set; } = null!;
|
public AuthSession Session { get; set; } = null!;
|
||||||
|
|
||||||
[NotMapped] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Key { get; set; }
|
[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()
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -317,7 +317,7 @@ public class AuthService(
|
|||||||
|
|
||||||
return factor.VerifyPassword(pinCode);
|
return factor.VerifyPassword(pinCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ApiKey?> GetApiKey(Guid id, Guid? accountId = null)
|
public async Task<ApiKey?> GetApiKey(Guid id, Guid? accountId = null)
|
||||||
{
|
{
|
||||||
var key = await db.ApiKeys
|
var key = await db.ApiKeys
|
||||||
@@ -340,13 +340,13 @@ public class AuthService(
|
|||||||
ExpiredAt = expiredAt
|
ExpiredAt = expiredAt
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
db.ApiKeys.Add(key);
|
db.ApiKeys.Add(key);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> IssueApiKeyToken(ApiKey key)
|
public async Task<string> IssueApiKeyToken(ApiKey key)
|
||||||
{
|
{
|
||||||
key.Session.LastGrantedAt = SystemClock.Instance.GetCurrentInstant();
|
key.Session.LastGrantedAt = SystemClock.Instance.GetCurrentInstant();
|
||||||
@@ -355,26 +355,48 @@ public class AuthService(
|
|||||||
var tk = CreateToken(key.Session);
|
var tk = CreateToken(key.Session);
|
||||||
return tk;
|
return tk;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RevokeApiKeyToken(ApiKey key)
|
public async Task RevokeApiKeyToken(ApiKey key)
|
||||||
{
|
{
|
||||||
db.Remove(key);
|
db.Remove(key);
|
||||||
db.Remove(key.Session);
|
db.Remove(key.Session);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ApiKey> RotateApiKeyToken(ApiKey key)
|
public async Task<ApiKey> RotateApiKeyToken(ApiKey key)
|
||||||
{
|
{
|
||||||
var originalSession = key.Session;
|
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||||
db.Remove(originalSession);
|
try
|
||||||
key.Session = new AuthSession
|
|
||||||
{
|
{
|
||||||
AccountId = key.AccountId,
|
var oldSessionId = key.SessionId;
|
||||||
ExpiredAt = originalSession.ExpiredAt
|
|
||||||
};
|
// Create new session
|
||||||
db.Add(key.Session);
|
var newSession = new AuthSession
|
||||||
await db.SaveChangesAsync();
|
{
|
||||||
return key;
|
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
|
||||||
|
@@ -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." };
|
||||||
|
|
||||||
|
@@ -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>
|
||||||
|
@@ -1,11 +1,10 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
using NodaTime.Serialization.Protobuf;
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Credit;
|
namespace DysonNetwork.Pass.Credit;
|
||||||
|
|
||||||
using Google.Protobuf.WellKnownTypes;
|
|
||||||
|
|
||||||
public class SocialCreditRecord : ModelBase
|
public class SocialCreditRecord : ModelBase
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
@@ -26,8 +25,8 @@ public class SocialCreditRecord : ModelBase
|
|||||||
Reason = Reason,
|
Reason = Reason,
|
||||||
Delta = Delta,
|
Delta = Delta,
|
||||||
AccountId = AccountId.ToString(),
|
AccountId = AccountId.ToString(),
|
||||||
CreatedAt = Timestamp.FromDateTimeOffset(CreatedAt.ToDateTimeOffset()),
|
CreatedAt = CreatedAt.ToTimestamp(),
|
||||||
UpdatedAt = Timestamp.FromDateTimeOffset(UpdatedAt.ToDateTimeOffset())
|
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||||
};
|
};
|
||||||
|
|
||||||
return proto;
|
return proto;
|
||||||
|
@@ -18,6 +18,13 @@ public class SocialCreditService(AppDatabase db, ICacheService cache)
|
|||||||
};
|
};
|
||||||
db.SocialCreditRecords.Add(record);
|
db.SocialCreditRecords.Add(record);
|
||||||
await db.SaveChangesAsync();
|
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;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +39,7 @@ public class SocialCreditService(AppDatabase db, ICacheService cache)
|
|||||||
.Where(x => x.AccountId == accountId)
|
.Where(x => x.AccountId == accountId)
|
||||||
.SumAsync(x => x.Delta);
|
.SumAsync(x => x.Delta);
|
||||||
records += BaseSocialCredit;
|
records += BaseSocialCredit;
|
||||||
|
|
||||||
await cache.SetAsync($"{CacheKeyPrefix}{accountId}", records);
|
await cache.SetAsync($"{CacheKeyPrefix}{accountId}", records);
|
||||||
return records;
|
return records;
|
||||||
}
|
}
|
||||||
|
@@ -1,10 +1,9 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
|
using NodaTime.Serialization.Protobuf;
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Leveling;
|
namespace DysonNetwork.Pass.Leveling;
|
||||||
|
|
||||||
using Google.Protobuf.WellKnownTypes;
|
|
||||||
|
|
||||||
public class ExperienceRecord : ModelBase
|
public class ExperienceRecord : ModelBase
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
@@ -26,8 +25,8 @@ public class ExperienceRecord : ModelBase
|
|||||||
Delta = Delta,
|
Delta = Delta,
|
||||||
BonusMultiplier = BonusMultiplier,
|
BonusMultiplier = BonusMultiplier,
|
||||||
AccountId = AccountId.ToString(),
|
AccountId = AccountId.ToString(),
|
||||||
CreatedAt = Timestamp.FromDateTimeOffset(CreatedAt.ToDateTimeOffset()),
|
CreatedAt = CreatedAt.ToTimestamp(),
|
||||||
UpdatedAt = Timestamp.FromDateTimeOffset(UpdatedAt.ToDateTimeOffset())
|
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||||
};
|
};
|
||||||
|
|
||||||
return proto;
|
return proto;
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -462,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)")
|
||||||
|
@@ -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 _);
|
||||||
}
|
}
|
||||||
|
@@ -2,10 +2,10 @@ using CorePush.Apple;
|
|||||||
using CorePush.Firebase;
|
using CorePush.Firebase;
|
||||||
using DysonNetwork.Pusher.Connection;
|
using DysonNetwork.Pusher.Connection;
|
||||||
using DysonNetwork.Pusher.Services;
|
using DysonNetwork.Pusher.Services;
|
||||||
using DysonNetwork.Shared.Cache;
|
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
using WebSocketPacket = DysonNetwork.Pusher.Connection.WebSocketPacket;
|
||||||
|
|
||||||
namespace DysonNetwork.Pusher.Notification;
|
namespace DysonNetwork.Pusher.Notification;
|
||||||
|
|
||||||
@@ -56,8 +56,6 @@ public class PushService
|
|||||||
_ws = ws;
|
_ws = ws;
|
||||||
_queueService = queueService;
|
_queueService = queueService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
_logger.LogInformation("PushService initialized");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UnsubscribeDevice(string deviceId)
|
public async Task UnsubscribeDevice(string deviceId)
|
||||||
@@ -150,6 +148,12 @@ public class PushService
|
|||||||
|
|
||||||
public async Task DeliverPushNotification(Notification notification, CancellationToken cancellationToken = default)
|
public async Task DeliverPushNotification(Notification notification, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
_ws.SendPacketToAccount(notification.AccountId.ToString(), new WebSocketPacket()
|
||||||
|
{
|
||||||
|
Type = "notifications.new",
|
||||||
|
Data = notification,
|
||||||
|
});
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
@@ -163,7 +167,7 @@ public class PushService
|
|||||||
.Where(s => s.AccountId == notification.AccountId)
|
.Where(s => s.AccountId == notification.AccountId)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
if (!subscriptions.Any())
|
if (subscriptions.Count == 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("No push subscriptions found for account {AccountId}", notification.AccountId);
|
_logger.LogInformation("No push subscriptions found for account {AccountId}", notification.AccountId);
|
||||||
return;
|
return;
|
||||||
@@ -175,7 +179,7 @@ public class PushService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
tasks.Add(SendPushNotificationAsync(subscription, notification, cancellationToken));
|
tasks.Add(SendPushNotificationAsync(subscription, notification));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -208,6 +212,7 @@ public class PushService
|
|||||||
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,16 +259,10 @@ public class PushService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all subscribers once and enqueue to workers
|
|
||||||
var subscriptions = await _db.PushSubscriptions
|
|
||||||
.Where(s => accounts.Contains(s.AccountId))
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
await DeliverPushNotification(notification);
|
await DeliverPushNotification(notification);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SendPushNotificationAsync(PushSubscription subscription, Notification notification, CancellationToken cancellationToken)
|
private async Task SendPushNotificationAsync(PushSubscription subscription, Notification notification)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -360,4 +359,27 @@ public class PushService
|
|||||||
_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();
|
||||||
|
}
|
||||||
|
}
|
@@ -12,9 +12,7 @@ namespace DysonNetwork.Pusher.Services;
|
|||||||
public class PusherServiceGrpc(
|
public class PusherServiceGrpc(
|
||||||
QueueService queueService,
|
QueueService queueService,
|
||||||
WebSocketService websocket,
|
WebSocketService websocket,
|
||||||
PushService pushService,
|
PushService pushService
|
||||||
AccountClientHelper accountsHelper,
|
|
||||||
EmailService emailService
|
|
||||||
) : PusherService.PusherServiceBase
|
) : PusherService.PusherServiceBase
|
||||||
{
|
{
|
||||||
public override async Task<Empty> SendEmail(SendEmailRequest request, ServerCallContext context)
|
public override async Task<Empty> SendEmail(SendEmailRequest request, ServerCallContext context)
|
||||||
@@ -100,11 +98,15 @@ public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDev
|
|||||||
Content = request.Notification.Body,
|
Content = request.Notification.Body,
|
||||||
Meta = request.Notification.HasMeta
|
Meta = request.Notification.HasMeta
|
||||||
? GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Notification.Meta) ?? []
|
? GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Notification.Meta) ?? []
|
||||||
: []
|
: [],
|
||||||
|
AccountId = Guid.Parse(request.UserId),
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
|
if (request.Notification.IsSavable)
|
||||||
|
await pushService.SaveNotification(notification);
|
||||||
|
|
||||||
await queueService.EnqueuePushNotification(
|
await queueService.EnqueuePushNotification(
|
||||||
notification,
|
notification,
|
||||||
@@ -131,11 +133,15 @@ public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDev
|
|||||||
|
|
||||||
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 userIds = request.UserIds.Select(Guid.Parse).ToList();
|
||||||
|
if (request.Notification.IsSavable)
|
||||||
|
await pushService.SaveNotification(notification, userIds);
|
||||||
|
|
||||||
var tasks = request.UserIds
|
var tasks = userIds
|
||||||
.Select(userId => queueService.EnqueuePushNotification(
|
.Select(userId => queueService.EnqueuePushNotification(
|
||||||
notification,
|
notification,
|
||||||
Guid.Parse(userId),
|
userId,
|
||||||
request.Notification.IsSavable
|
request.Notification.IsSavable
|
||||||
));
|
));
|
||||||
|
|
||||||
|
@@ -16,8 +16,8 @@ public class QueueBackgroundService(
|
|||||||
)
|
)
|
||||||
: BackgroundService
|
: BackgroundService
|
||||||
{
|
{
|
||||||
private const string QueueName = "pusher.queue";
|
public const string QueueName = "pusher.queue";
|
||||||
private const string QueueGroup = "pusher.workers";
|
public const string QueueGroup = "pusher.workers";
|
||||||
private readonly int _consumerCount = configuration.GetValue<int?>("ConsumerCount") ?? Environment.ProcessorCount;
|
private readonly int _consumerCount = configuration.GetValue<int?>("ConsumerCount") ?? Environment.ProcessorCount;
|
||||||
private readonly List<Task> _consumerTasks = [];
|
private readonly List<Task> _consumerTasks = [];
|
||||||
|
|
||||||
@@ -51,7 +51,6 @@ public class QueueBackgroundService(
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await msg.ReplyAsync(cancellationToken: stoppingToken);
|
|
||||||
logger.LogWarning($"Invalid message format for {msg.Subject}");
|
logger.LogWarning($"Invalid message format for {msg.Subject}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,7 +80,7 @@ public class QueueBackgroundService(
|
|||||||
switch (message.Type)
|
switch (message.Type)
|
||||||
{
|
{
|
||||||
case QueueMessageType.Email:
|
case QueueMessageType.Email:
|
||||||
await ProcessEmailMessageAsync(message, scope, cancellationToken);
|
await ProcessEmailMessageAsync(message, scope);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case QueueMessageType.PushNotification:
|
case QueueMessageType.PushNotification:
|
||||||
@@ -92,8 +91,6 @@ public class QueueBackgroundService(
|
|||||||
logger.LogWarning("Unknown message type: {MessageType}", message.Type);
|
logger.LogWarning("Unknown message type: {MessageType}", message.Type);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
await rawMsg.ReplyAsync(cancellationToken: cancellationToken);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -103,8 +100,7 @@ public class QueueBackgroundService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task ProcessEmailMessageAsync(QueueMessage message, IServiceScope scope,
|
private static async Task ProcessEmailMessageAsync(QueueMessage message, IServiceScope scope)
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
var emailService = scope.ServiceProvider.GetRequiredService<EmailService>();
|
var emailService = scope.ServiceProvider.GetRequiredService<EmailService>();
|
||||||
var emailMessage = JsonSerializer.Deserialize<EmailMessage>(message.Data)
|
var emailMessage = JsonSerializer.Deserialize<EmailMessage>(message.Data)
|
||||||
@@ -122,7 +118,7 @@ public class QueueBackgroundService(
|
|||||||
{
|
{
|
||||||
var pushService = scope.ServiceProvider.GetRequiredService<PushService>();
|
var pushService = scope.ServiceProvider.GetRequiredService<PushService>();
|
||||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<QueueBackgroundService>>();
|
var logger = scope.ServiceProvider.GetRequiredService<ILogger<QueueBackgroundService>>();
|
||||||
|
|
||||||
var notification = JsonSerializer.Deserialize<Notification.Notification>(message.Data);
|
var notification = JsonSerializer.Deserialize<Notification.Notification>(message.Data);
|
||||||
if (notification == null)
|
if (notification == null)
|
||||||
{
|
{
|
||||||
|
@@ -6,8 +6,6 @@ namespace DysonNetwork.Pusher.Services;
|
|||||||
|
|
||||||
public class QueueService(INatsConnection nats)
|
public class QueueService(INatsConnection nats)
|
||||||
{
|
{
|
||||||
private const string QueueName = "pusher_queue";
|
|
||||||
|
|
||||||
public async Task EnqueueEmail(string toName, string toAddress, string subject, string body)
|
public async Task EnqueueEmail(string toName, string toAddress, string subject, string body)
|
||||||
{
|
{
|
||||||
var message = new QueueMessage
|
var message = new QueueMessage
|
||||||
@@ -22,7 +20,7 @@ public class QueueService(INatsConnection nats)
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray();
|
var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray();
|
||||||
await nats.PublishAsync(QueueName, rawMessage);
|
await nats.PublishAsync(QueueBackgroundService.QueueName, rawMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task EnqueuePushNotification(Notification.Notification notification, Guid userId, bool isSavable = false)
|
public async Task EnqueuePushNotification(Notification.Notification notification, Guid userId, bool isSavable = false)
|
||||||
@@ -37,7 +35,7 @@ public class QueueService(INatsConnection nats)
|
|||||||
Data = JsonSerializer.Serialize(notification)
|
Data = JsonSerializer.Serialize(notification)
|
||||||
};
|
};
|
||||||
var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray();
|
var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray();
|
||||||
await nats.PublishAsync(QueueName, rawMessage);
|
await nats.PublishAsync(QueueBackgroundService.QueueName, rawMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -134,7 +134,7 @@ 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>();
|
||||||
|
|
||||||
|
@@ -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
|
||||||
|
}
|
@@ -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;
|
||||||
|
|
||||||
|
@@ -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;
|
||||||
}
|
}
|
||||||
|
@@ -70,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);
|
||||||
}
|
}
|
||||||
|
|
@@ -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)
|
||||||
{
|
{
|
||||||
|
@@ -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!;
|
||||||
|
@@ -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>
|
||||||
|
@@ -60,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" />
|
||||||
|
2006
DysonNetwork.Sphere/Migrations/20250825045548_AddPostPin.Designer.cs
generated
Normal file
2006
DysonNetwork.Sphere/Migrations/20250825045548_AddPostPin.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
DysonNetwork.Sphere/Migrations/20250825045548_AddPostPin.cs
Normal file
28
DysonNetwork.Sphere/Migrations/20250825045548_AddPostPin.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPostPin : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "pin_mode",
|
||||||
|
table: "posts",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "pin_mode",
|
||||||
|
table: "posts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
2066
DysonNetwork.Sphere/Migrations/20250825054901_AddPostCategoryTagSubscription.Designer.cs
generated
Normal file
2066
DysonNetwork.Sphere/Migrations/20250825054901_AddPostCategoryTagSubscription.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPostCategoryTagSubscription : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "post_category_subscriptions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
category_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
tag_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
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_post_category_subscriptions", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_post_category_subscriptions_post_categories_category_id",
|
||||||
|
column: x => x.category_id,
|
||||||
|
principalTable: "post_categories",
|
||||||
|
principalColumn: "id");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_post_category_subscriptions_post_tags_tag_id",
|
||||||
|
column: x => x.tag_id,
|
||||||
|
principalTable: "post_tags",
|
||||||
|
principalColumn: "id");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_post_category_subscriptions_category_id",
|
||||||
|
table: "post_category_subscriptions",
|
||||||
|
column: "category_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_post_category_subscriptions_tag_id",
|
||||||
|
table: "post_category_subscriptions",
|
||||||
|
column: "tag_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "post_category_subscriptions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -566,6 +566,10 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("meta");
|
.HasColumnName("meta");
|
||||||
|
|
||||||
|
b.Property<int?>("PinMode")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("pin_mode");
|
||||||
|
|
||||||
b.Property<Instant?>("PublishedAt")
|
b.Property<Instant?>("PublishedAt")
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("published_at");
|
.HasColumnName("published_at");
|
||||||
@@ -691,6 +695,49 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
b.ToTable("post_categories", (string)null);
|
b.ToTable("post_categories", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Sphere.Post.PostCategorySubscription", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<Guid>("AccountId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("account_id");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CategoryId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("category_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<Guid?>("TagId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("tag_id");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_post_category_subscriptions");
|
||||||
|
|
||||||
|
b.HasIndex("CategoryId")
|
||||||
|
.HasDatabaseName("ix_post_category_subscriptions_category_id");
|
||||||
|
|
||||||
|
b.HasIndex("TagId")
|
||||||
|
.HasDatabaseName("ix_post_category_subscriptions_tag_id");
|
||||||
|
|
||||||
|
b.ToTable("post_category_subscriptions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Sphere.Post.PostCollection", b =>
|
modelBuilder.Entity("DysonNetwork.Sphere.Post.PostCollection", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1723,6 +1770,23 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
b.Navigation("RepliedPost");
|
b.Navigation("RepliedPost");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Sphere.Post.PostCategorySubscription", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Sphere.Post.PostCategory", "Category")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CategoryId")
|
||||||
|
.HasConstraintName("fk_post_category_subscriptions_post_categories_category_id");
|
||||||
|
|
||||||
|
b.HasOne("DysonNetwork.Sphere.Post.PostTag", "Tag")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TagId")
|
||||||
|
.HasConstraintName("fk_post_category_subscriptions_post_tags_tag_id");
|
||||||
|
|
||||||
|
b.Navigation("Category");
|
||||||
|
|
||||||
|
b.Navigation("Tag");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Sphere.Post.PostCollection", b =>
|
modelBuilder.Entity("DysonNetwork.Sphere.Post.PostCollection", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DysonNetwork.Sphere.Publisher.Publisher", "Publisher")
|
b.HasOne("DysonNetwork.Sphere.Publisher.Publisher", "Publisher")
|
||||||
|
@@ -51,17 +51,15 @@ public class PostPageData(
|
|||||||
.Include(e => e.Categories)
|
.Include(e => e.Categories)
|
||||||
.FilterWithVisibility(currentUser, userFriends, userPublishers)
|
.FilterWithVisibility(currentUser, userFriends, userPublishers)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
if (post == null) return new Dictionary<string, object?>();
|
||||||
post = await ps.LoadPostInfo(post, currentUser);
|
post = await ps.LoadPostInfo(post, currentUser);
|
||||||
|
|
||||||
// Track view - use the account ID as viewer ID if user is logged in
|
|
||||||
await ps.IncreaseViewCount(post.Id, currentUser?.Id);
|
|
||||||
|
|
||||||
var og = OpenGraph.MakeGraph(
|
var og = OpenGraph.MakeGraph(
|
||||||
title: post.Title ?? $"Post from {post.Publisher.Name}",
|
title: post.Title ?? $"Post from {post.Publisher.Name}",
|
||||||
type: "article",
|
type: "article",
|
||||||
image: $"{_siteUrl}/cgi/drive/files/{post.Publisher.Background?.Id}?original=true",
|
image: $"{_siteUrl}/cgi/drive/files/{post.Publisher.Background?.Id}?original=true",
|
||||||
url: $"{_siteUrl}/@{slug}",
|
url: $"{_siteUrl}/@{slug}",
|
||||||
description: post.Description ?? post.Content?[..80] ?? "Posted with some media",
|
description: post.Description ?? (post.Content?.Length > 80 ? post.Content?[..80] : post.Content) ?? "Posted with some media",
|
||||||
siteName: "Solar Network"
|
siteName: "Solar Network"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@@ -23,6 +23,13 @@ public enum PostVisibility
|
|||||||
Private
|
Private
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum PostPinMode
|
||||||
|
{
|
||||||
|
PublisherPage,
|
||||||
|
RealmPage,
|
||||||
|
ReplyPage,
|
||||||
|
}
|
||||||
|
|
||||||
public class Post : ModelBase, IIdentifiedResource, IActivity
|
public class Post : ModelBase, IIdentifiedResource, IActivity
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
@@ -37,6 +44,7 @@ public class Post : ModelBase, IIdentifiedResource, IActivity
|
|||||||
public string? Content { get; set; }
|
public string? Content { get; set; }
|
||||||
|
|
||||||
public PostType Type { get; set; }
|
public PostType Type { get; set; }
|
||||||
|
public PostPinMode? PinMode { get; set; }
|
||||||
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; }
|
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; }
|
||||||
[Column(TypeName = "jsonb")] public List<ContentSensitiveMark>? SensitiveMarks { get; set; } = [];
|
[Column(TypeName = "jsonb")] public List<ContentSensitiveMark>? SensitiveMarks { get; set; } = [];
|
||||||
|
|
||||||
@@ -97,7 +105,7 @@ public class PostTag : ModelBase
|
|||||||
[MaxLength(128)] public string Slug { get; set; } = null!;
|
[MaxLength(128)] public string Slug { get; set; } = null!;
|
||||||
[MaxLength(256)] public string? Name { get; set; }
|
[MaxLength(256)] public string? Name { get; set; }
|
||||||
[JsonIgnore] public ICollection<Post> Posts { get; set; } = new List<Post>();
|
[JsonIgnore] public ICollection<Post> Posts { get; set; } = new List<Post>();
|
||||||
|
|
||||||
[NotMapped] public int? Usage { get; set; }
|
[NotMapped] public int? Usage { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,10 +115,21 @@ public class PostCategory : ModelBase
|
|||||||
[MaxLength(128)] public string Slug { get; set; } = null!;
|
[MaxLength(128)] public string Slug { get; set; } = null!;
|
||||||
[MaxLength(256)] public string? Name { get; set; }
|
[MaxLength(256)] public string? Name { get; set; }
|
||||||
[JsonIgnore] public ICollection<Post> Posts { get; set; } = new List<Post>();
|
[JsonIgnore] public ICollection<Post> Posts { get; set; } = new List<Post>();
|
||||||
|
|
||||||
[NotMapped] public int? Usage { get; set; }
|
[NotMapped] public int? Usage { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class PostCategorySubscription : ModelBase
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
|
||||||
|
public Guid? CategoryId { get; set; }
|
||||||
|
public PostCategory? Category { get; set; }
|
||||||
|
public Guid? TagId { get; set; }
|
||||||
|
public PostTag? Tag { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class PostCollection : ModelBase
|
public class PostCollection : ModelBase
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
|
@@ -1,3 +1,6 @@
|
|||||||
|
using DysonNetwork.Shared.Data;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -33,12 +36,12 @@ public class PostCategoryController(AppDatabase db) : ControllerBase
|
|||||||
|
|
||||||
var totalCount = await categoriesQuery.CountAsync();
|
var totalCount = await categoriesQuery.CountAsync();
|
||||||
Response.Headers.Append("X-Total", totalCount.ToString());
|
Response.Headers.Append("X-Total", totalCount.ToString());
|
||||||
|
|
||||||
// Get categories with their post counts in a single query
|
// Get categories with their post counts in a single query
|
||||||
var categories = await categoriesQuery
|
var categories = await categoriesQuery
|
||||||
.Skip(offset)
|
.Skip(offset)
|
||||||
.Take(take)
|
.Take(take)
|
||||||
.Select(c => new
|
.Select(c => new
|
||||||
{
|
{
|
||||||
Category = c,
|
Category = c,
|
||||||
PostCount = c.Posts.Count
|
PostCount = c.Posts.Count
|
||||||
@@ -46,15 +49,13 @@ public class PostCategoryController(AppDatabase db) : ControllerBase
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
// Project results back to the original type and set the Usage property
|
// Project results back to the original type and set the Usage property
|
||||||
var result = categories.Select(x =>
|
var result = categories.Select(x =>
|
||||||
{
|
{
|
||||||
x.Category.Usage = x.PostCount;
|
x.Category.Usage = x.PostCount;
|
||||||
return x.Category;
|
return x.Category;
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
|
|
||||||
return Ok(categories);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("tags")]
|
[HttpGet("tags")]
|
||||||
@@ -83,12 +84,12 @@ public class PostCategoryController(AppDatabase db) : ControllerBase
|
|||||||
|
|
||||||
var totalCount = await tagsQuery.CountAsync();
|
var totalCount = await tagsQuery.CountAsync();
|
||||||
Response.Headers.Append("X-Total", totalCount.ToString());
|
Response.Headers.Append("X-Total", totalCount.ToString());
|
||||||
|
|
||||||
// Get tags with their post counts in a single query
|
// Get tags with their post counts in a single query
|
||||||
var tags = await tagsQuery
|
var tags = await tagsQuery
|
||||||
.Skip(offset)
|
.Skip(offset)
|
||||||
.Take(take)
|
.Take(take)
|
||||||
.Select(t => new
|
.Select(t => new
|
||||||
{
|
{
|
||||||
Tag = t,
|
Tag = t,
|
||||||
PostCount = t.Posts.Count
|
PostCount = t.Posts.Count
|
||||||
@@ -96,7 +97,7 @@ public class PostCategoryController(AppDatabase db) : ControllerBase
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
// Project results back to the original type and set the Usage property
|
// Project results back to the original type and set the Usage property
|
||||||
var result = tags.Select(x =>
|
var result = tags.Select(x =>
|
||||||
{
|
{
|
||||||
x.Tag.Usage = x.PostCount;
|
x.Tag.Usage = x.PostCount;
|
||||||
return x.Tag;
|
return x.Tag;
|
||||||
@@ -122,4 +123,162 @@ public class PostCategoryController(AppDatabase db) : ControllerBase
|
|||||||
return NotFound();
|
return NotFound();
|
||||||
return Ok(tag);
|
return Ok(tag);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
[HttpPost("categories/{slug}/subscribe")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<PostCategorySubscription>> SubscribeCategory(string slug)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
var category = await db.PostCategories.FirstOrDefaultAsync(c => c.Slug == slug);
|
||||||
|
if (category == null)
|
||||||
|
{
|
||||||
|
return NotFound("Category not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingSubscription = await db.PostCategorySubscriptions
|
||||||
|
.FirstOrDefaultAsync(s => s.CategoryId == category.Id && s.AccountId == accountId);
|
||||||
|
|
||||||
|
if (existingSubscription != null)
|
||||||
|
return Ok(existingSubscription);
|
||||||
|
|
||||||
|
var subscription = new PostCategorySubscription
|
||||||
|
{
|
||||||
|
AccountId = accountId,
|
||||||
|
CategoryId = category.Id
|
||||||
|
};
|
||||||
|
|
||||||
|
db.PostCategorySubscriptions.Add(subscription);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return CreatedAtAction(nameof(GetCategorySubscription), new { slug }, subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("categories/{slug}/unsubscribe")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> UnsubscribeCategory(string slug)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
var category = await db.PostCategories.FirstOrDefaultAsync(c => c.Slug == slug);
|
||||||
|
if (category == null)
|
||||||
|
return NotFound("Category not found.");
|
||||||
|
|
||||||
|
var subscription = await db.PostCategorySubscriptions
|
||||||
|
.FirstOrDefaultAsync(s => s.CategoryId == category.Id && s.AccountId == accountId);
|
||||||
|
|
||||||
|
if (subscription == null)
|
||||||
|
return NoContent();
|
||||||
|
|
||||||
|
db.PostCategorySubscriptions.Remove(subscription);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("categories/{slug}/subscription")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<PostCategorySubscription>> GetCategorySubscription(string slug)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
var category = await db.PostCategories.FirstOrDefaultAsync(c => c.Slug == slug);
|
||||||
|
if (category == null)
|
||||||
|
return NotFound("Category not found.");
|
||||||
|
|
||||||
|
var subscription = await db.PostCategorySubscriptions
|
||||||
|
.FirstOrDefaultAsync(s => s.CategoryId == category.Id && s.AccountId == accountId);
|
||||||
|
|
||||||
|
if (subscription == null)
|
||||||
|
return NotFound("Subscription not found.");
|
||||||
|
|
||||||
|
return Ok(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("tags/{slug}/subscribe")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<PostCategorySubscription>> SubscribeTag(string slug)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
var tag = await db.PostTags.FirstOrDefaultAsync(t => t.Slug == slug);
|
||||||
|
if (tag == null)
|
||||||
|
{
|
||||||
|
return NotFound("Tag not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingSubscription = await db.PostCategorySubscriptions
|
||||||
|
.FirstOrDefaultAsync(s => s.TagId == tag.Id && s.AccountId == accountId);
|
||||||
|
|
||||||
|
if (existingSubscription != null)
|
||||||
|
{
|
||||||
|
return Ok(existingSubscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscription = new PostCategorySubscription
|
||||||
|
{
|
||||||
|
AccountId = accountId,
|
||||||
|
TagId = tag.Id
|
||||||
|
};
|
||||||
|
|
||||||
|
db.PostCategorySubscriptions.Add(subscription);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return CreatedAtAction(nameof(GetTagSubscription), new { slug }, subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("tags/{slug}/unsubscribe")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> UnsubscribeTag(string slug)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
var tag = await db.PostTags.FirstOrDefaultAsync(t => t.Slug == slug);
|
||||||
|
if (tag == null)
|
||||||
|
{
|
||||||
|
return NotFound("Tag not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscription = await db.PostCategorySubscriptions
|
||||||
|
.FirstOrDefaultAsync(s => s.TagId == tag.Id && s.AccountId == accountId);
|
||||||
|
|
||||||
|
if (subscription == null)
|
||||||
|
{
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
db.PostCategorySubscriptions.Remove(subscription);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("tags/{slug}/subscription")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<PostCategorySubscription>> GetTagSubscription(string slug)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
var tag = await db.PostTags.FirstOrDefaultAsync(t => t.Slug == slug);
|
||||||
|
if (tag == null)
|
||||||
|
{
|
||||||
|
return NotFound("Tag not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscription = await db.PostCategorySubscriptions
|
||||||
|
.FirstOrDefaultAsync(s => s.TagId == tag.Id && s.AccountId == accountId);
|
||||||
|
|
||||||
|
if (subscription == null)
|
||||||
|
{
|
||||||
|
return NotFound("Subscription not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(subscription);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -10,6 +10,8 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
using PublisherMemberRole = DysonNetwork.Sphere.Publisher.PublisherMemberRole;
|
||||||
using PublisherService = DysonNetwork.Sphere.Publisher.PublisherService;
|
using PublisherService = DysonNetwork.Sphere.Publisher.PublisherService;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Post;
|
namespace DysonNetwork.Sphere.Post;
|
||||||
@@ -37,7 +39,39 @@ public class PostController(
|
|||||||
return Ok(posts);
|
return Ok(posts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a paginated list of posts with optional filtering and sorting.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="includeReplies">Whether to include reply posts in the results. If false, only root posts are returned.</param>
|
||||||
|
/// <param name="offset">The number of posts to skip for pagination.</param>
|
||||||
|
/// <param name="take">The maximum number of posts to return (default: 20).</param>
|
||||||
|
/// <param name="pubName">Filter posts by publisher name.</param>
|
||||||
|
/// <param name="realmName">Filter posts by realm slug.</param>
|
||||||
|
/// <param name="type">Filter posts by post type (as integer).</param>
|
||||||
|
/// <param name="categories">Filter posts by category slugs.</param>
|
||||||
|
/// <param name="tags">Filter posts by tag slugs.</param>
|
||||||
|
/// <param name="queryTerm">Search term to filter posts by title, description, or content.</param>
|
||||||
|
/// <param name="queryVector">If true, uses vector search with the query term. If false, performs a simple ILIKE search.</param>
|
||||||
|
/// <param name="onlyMedia">If true, only returns posts that have attachments.</param>
|
||||||
|
/// <param name="shuffle">If true, returns posts in random order. If false, orders by published/created date (newest first).</param>
|
||||||
|
/// <param name="pinned">If true, returns posts that pinned. If false, returns posts that are not pinned. If null, returns all posts.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// Returns an ActionResult containing a list of Post objects that match the specified criteria.
|
||||||
|
/// Includes an X-Total header with the total count of matching posts before pagination.
|
||||||
|
/// </returns>
|
||||||
|
/// <response code="200">Returns the list of posts matching the criteria.</response>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<Post>))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "Retrieves a paginated list of posts",
|
||||||
|
Description =
|
||||||
|
"Gets posts with various filtering and sorting options. Supports pagination and advanced search capabilities.",
|
||||||
|
OperationId = "ListPosts",
|
||||||
|
Tags = ["Posts"]
|
||||||
|
)]
|
||||||
|
[SwaggerResponse(StatusCodes.Status200OK, "Successfully retrieved the list of posts", typeof(List<Post>))]
|
||||||
|
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request parameters")]
|
||||||
public async Task<ActionResult<List<Post>>> ListPosts(
|
public async Task<ActionResult<List<Post>>> ListPosts(
|
||||||
[FromQuery] int offset = 0,
|
[FromQuery] int offset = 0,
|
||||||
[FromQuery] int take = 20,
|
[FromQuery] int take = 20,
|
||||||
@@ -48,9 +82,10 @@ public class PostController(
|
|||||||
[FromQuery(Name = "tags")] List<string>? tags = null,
|
[FromQuery(Name = "tags")] List<string>? tags = null,
|
||||||
[FromQuery(Name = "query")] string? queryTerm = null,
|
[FromQuery(Name = "query")] string? queryTerm = null,
|
||||||
[FromQuery(Name = "vector")] bool queryVector = false,
|
[FromQuery(Name = "vector")] bool queryVector = false,
|
||||||
[FromQuery(Name = "replies")] bool includeReplies = false,
|
|
||||||
[FromQuery(Name = "media")] bool onlyMedia = false,
|
[FromQuery(Name = "media")] bool onlyMedia = false,
|
||||||
[FromQuery(Name = "shuffle")] bool shuffle = false
|
[FromQuery(Name = "shuffle")] bool shuffle = false,
|
||||||
|
[FromQuery(Name = "replies")] bool? includeReplies = null,
|
||||||
|
[FromQuery(Name = "pinned")] bool? pinned = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||||
@@ -60,11 +95,13 @@ public class PostController(
|
|||||||
if (currentUser != null)
|
if (currentUser != null)
|
||||||
{
|
{
|
||||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||||
{ AccountId = currentUser.Id });
|
{ AccountId = currentUser.Id });
|
||||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
|
var accountId = currentUser is null ? Guid.Empty : Guid.Parse(currentUser.Id);
|
||||||
|
var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(accountId);
|
||||||
|
var userRealms = currentUser is null ? [] : await rs.GetUserRealms(accountId);
|
||||||
|
|
||||||
var publisher = pubName == null ? null : await db.Publishers.FirstOrDefaultAsync(p => p.Name == pubName);
|
var publisher = pubName == null ? null : await db.Publishers.FirstOrDefaultAsync(p => p.Name == pubName);
|
||||||
var realm = realmName == null ? null : await db.Realms.FirstOrDefaultAsync(r => r.Slug == realmName);
|
var realm = realmName == null ? null : await db.Realms.FirstOrDefaultAsync(r => r.Slug == realmName);
|
||||||
@@ -72,6 +109,9 @@ public class PostController(
|
|||||||
var query = db.Posts
|
var query = db.Posts
|
||||||
.Include(e => e.Categories)
|
.Include(e => e.Categories)
|
||||||
.Include(e => e.Tags)
|
.Include(e => e.Tags)
|
||||||
|
.Include(e => e.RepliedPost)
|
||||||
|
.Include(e => e.ForwardedPost)
|
||||||
|
.Include(e => e.Realm)
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
if (publisher != null)
|
if (publisher != null)
|
||||||
query = query.Where(p => p.PublisherId == publisher.Id);
|
query = query.Where(p => p.PublisherId == publisher.Id);
|
||||||
@@ -83,11 +123,36 @@ public class PostController(
|
|||||||
query = query.Where(p => p.Categories.Any(c => categories.Contains(c.Slug)));
|
query = query.Where(p => p.Categories.Any(c => categories.Contains(c.Slug)));
|
||||||
if (tags is { Count: > 0 })
|
if (tags is { Count: > 0 })
|
||||||
query = query.Where(p => p.Tags.Any(c => tags.Contains(c.Slug)));
|
query = query.Where(p => p.Tags.Any(c => tags.Contains(c.Slug)));
|
||||||
if (!includeReplies)
|
|
||||||
query = query.Where(e => e.RepliedPostId == null);
|
|
||||||
if (onlyMedia)
|
if (onlyMedia)
|
||||||
query = query.Where(e => e.Attachments.Count > 0);
|
query = query.Where(e => e.Attachments.Count > 0);
|
||||||
|
|
||||||
|
if (realm == null)
|
||||||
|
query = query.Where(p =>
|
||||||
|
p.RealmId == null || p.Realm == null || userRealms.Contains(p.RealmId.Value) || p.Realm.IsPublic);
|
||||||
|
|
||||||
|
switch (pinned)
|
||||||
|
{
|
||||||
|
case true when realm != null:
|
||||||
|
query = query.Where(p => p.PinMode == PostPinMode.RealmPage);
|
||||||
|
break;
|
||||||
|
case true when publisher != null:
|
||||||
|
query = query.Where(p => p.PinMode == PostPinMode.PublisherPage);
|
||||||
|
break;
|
||||||
|
case true:
|
||||||
|
return BadRequest(
|
||||||
|
"You need pass extra realm or publisher params in order to filter with pinned posts.");
|
||||||
|
case false:
|
||||||
|
query = query.Where(p => p.PinMode == null);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
query = includeReplies switch
|
||||||
|
{
|
||||||
|
false => query.Where(e => e.RepliedPostId == null),
|
||||||
|
true => query.Where(e => e.RepliedPostId != null),
|
||||||
|
_ => query
|
||||||
|
};
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(queryTerm))
|
if (!string.IsNullOrWhiteSpace(queryTerm))
|
||||||
{
|
{
|
||||||
if (queryVector)
|
if (queryVector)
|
||||||
@@ -106,15 +171,11 @@ public class PostController(
|
|||||||
var totalCount = await query
|
var totalCount = await query
|
||||||
.CountAsync();
|
.CountAsync();
|
||||||
|
|
||||||
if (shuffle)
|
query = shuffle
|
||||||
query = query.OrderBy(e => EF.Functions.Random());
|
? query.OrderBy(e => EF.Functions.Random())
|
||||||
else
|
: query.OrderByDescending(e => e.PublishedAt ?? e.CreatedAt);
|
||||||
query = query.OrderByDescending(e => e.PublishedAt ?? e.CreatedAt);
|
|
||||||
|
|
||||||
var posts = await query
|
var posts = await query
|
||||||
.Include(e => e.RepliedPost)
|
|
||||||
.Include(e => e.ForwardedPost)
|
|
||||||
.Include(e => e.Realm)
|
|
||||||
.Skip(offset)
|
.Skip(offset)
|
||||||
.Take(take)
|
.Take(take)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -134,7 +195,7 @@ public class PostController(
|
|||||||
if (currentUser != null)
|
if (currentUser != null)
|
||||||
{
|
{
|
||||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||||
{ AccountId = currentUser.Id });
|
{ AccountId = currentUser.Id });
|
||||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,9 +214,6 @@ public class PostController(
|
|||||||
if (post is null) return NotFound();
|
if (post is null) return NotFound();
|
||||||
post = await ps.LoadPostInfo(post, currentUser);
|
post = await ps.LoadPostInfo(post, currentUser);
|
||||||
|
|
||||||
// Track view - use the account ID as viewer ID if user is logged in
|
|
||||||
await ps.IncreaseViewCount(post.Id, currentUser?.Id);
|
|
||||||
|
|
||||||
return Ok(post);
|
return Ok(post);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +226,7 @@ public class PostController(
|
|||||||
if (currentUser != null)
|
if (currentUser != null)
|
||||||
{
|
{
|
||||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||||
{ AccountId = currentUser.Id });
|
{ AccountId = currentUser.Id });
|
||||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,65 +245,9 @@ public class PostController(
|
|||||||
if (post is null) return NotFound();
|
if (post is null) return NotFound();
|
||||||
post = await ps.LoadPostInfo(post, currentUser);
|
post = await ps.LoadPostInfo(post, currentUser);
|
||||||
|
|
||||||
// Track view - use the account ID as viewer ID if user is logged in
|
|
||||||
await ps.IncreaseViewCount(post.Id, currentUser?.Id);
|
|
||||||
|
|
||||||
return Ok(post);
|
return Ok(post);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("search")]
|
|
||||||
[Obsolete("Use the new ListPost API")]
|
|
||||||
public async Task<ActionResult<List<Post>>> SearchPosts(
|
|
||||||
[FromQuery] string query,
|
|
||||||
[FromQuery] int offset = 0,
|
|
||||||
[FromQuery] int take = 20,
|
|
||||||
[FromQuery] bool useVector = true
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(query))
|
|
||||||
return BadRequest("Search query cannot be empty");
|
|
||||||
|
|
||||||
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
|
||||||
var currentUser = currentUserValue as Account;
|
|
||||||
List<Guid> userFriends = [];
|
|
||||||
if (currentUser != null)
|
|
||||||
{
|
|
||||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
|
||||||
{ AccountId = currentUser.Id });
|
|
||||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
|
|
||||||
|
|
||||||
var queryable = db.Posts
|
|
||||||
.FilterWithVisibility(currentUser, userFriends, userPublishers, isListing: true)
|
|
||||||
.AsQueryable();
|
|
||||||
if (useVector)
|
|
||||||
queryable = queryable.Where(p => p.SearchVector.Matches(EF.Functions.ToTsQuery(query)));
|
|
||||||
else
|
|
||||||
queryable = queryable.Where(p =>
|
|
||||||
(p.Title != null && EF.Functions.ILike(p.Title, $"%{query}%")) ||
|
|
||||||
(p.Description != null && EF.Functions.ILike(p.Description, $"%{query}%")) ||
|
|
||||||
(p.Content != null && EF.Functions.ILike(p.Content, $"%{query}%"))
|
|
||||||
);
|
|
||||||
|
|
||||||
var totalCount = await queryable.CountAsync();
|
|
||||||
|
|
||||||
var posts = await queryable
|
|
||||||
.Include(e => e.RepliedPost)
|
|
||||||
.Include(e => e.ForwardedPost)
|
|
||||||
.Include(e => e.Categories)
|
|
||||||
.Include(e => e.Tags)
|
|
||||||
.OrderByDescending(e => e.PublishedAt ?? e.CreatedAt)
|
|
||||||
.Skip(offset)
|
|
||||||
.Take(take)
|
|
||||||
.ToListAsync();
|
|
||||||
posts = await ps.LoadPostInfo(posts, currentUser, true);
|
|
||||||
|
|
||||||
Response.Headers["X-Total"] = totalCount.ToString();
|
|
||||||
return Ok(posts);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{id:guid}/reactions")]
|
[HttpGet("{id:guid}/reactions")]
|
||||||
public async Task<ActionResult<List<PostReaction>>> GetReactions(
|
public async Task<ActionResult<List<PostReaction>>> GetReactions(
|
||||||
Guid id,
|
Guid id,
|
||||||
@@ -279,7 +281,7 @@ public class PostController(
|
|||||||
if (currentUser != null)
|
if (currentUser != null)
|
||||||
{
|
{
|
||||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||||
{ AccountId = currentUser.Id });
|
{ AccountId = currentUser.Id });
|
||||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,12 +298,36 @@ public class PostController(
|
|||||||
.FilterWithVisibility(currentUser, userFriends, userPublishers)
|
.FilterWithVisibility(currentUser, userFriends, userPublishers)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (post is null) return NotFound();
|
if (post is null) return NotFound();
|
||||||
post = await ps.LoadPostInfo(post, currentUser);
|
post = await ps.LoadPostInfo(post, currentUser, true);
|
||||||
|
|
||||||
// Track view - use the account ID as viewer ID if user is logged in
|
return Ok(post);
|
||||||
await ps.IncreaseViewCount(post.Id, currentUser?.Id);
|
}
|
||||||
|
|
||||||
return await ps.LoadPostInfo(post);
|
[HttpGet("{id:guid}/replies/pinned")]
|
||||||
|
public async Task<ActionResult<List<Post>>> ListPinnedReplies(Guid id)
|
||||||
|
{
|
||||||
|
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||||
|
var currentUser = currentUserValue as Account;
|
||||||
|
List<Guid> userFriends = [];
|
||||||
|
if (currentUser != null)
|
||||||
|
{
|
||||||
|
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||||
|
{ AccountId = currentUser.Id });
|
||||||
|
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
|
||||||
|
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var posts = await db.Posts
|
||||||
|
.Where(e => e.RepliedPostId == id && e.PinMode == PostPinMode.ReplyPage)
|
||||||
|
.OrderByDescending(p => p.CreatedAt)
|
||||||
|
.FilterWithVisibility(currentUser, userFriends, userPublishers)
|
||||||
|
.ToListAsync();
|
||||||
|
if (posts is null) return NotFound();
|
||||||
|
posts = await ps.LoadPostInfo(posts, currentUser);
|
||||||
|
|
||||||
|
return Ok(posts);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}/replies")]
|
[HttpGet("{id:guid}/replies")]
|
||||||
@@ -315,7 +341,7 @@ public class PostController(
|
|||||||
if (currentUser != null)
|
if (currentUser != null)
|
||||||
{
|
{
|
||||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||||
{ AccountId = currentUser.Id });
|
{ AccountId = currentUser.Id });
|
||||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,7 +528,7 @@ public class PostController(
|
|||||||
|
|
||||||
var friendsResponse =
|
var friendsResponse =
|
||||||
await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||||
{ AccountId = currentUser.Id.ToString() });
|
{ AccountId = currentUser.Id.ToString() });
|
||||||
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));
|
||||||
|
|
||||||
@@ -553,6 +579,106 @@ public class PostController(
|
|||||||
return Ok(reaction);
|
return Ok(reaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class PostPinRequest
|
||||||
|
{
|
||||||
|
[Required] public PostPinMode Mode { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/pin")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<Post>> PinPost(Guid id, [FromBody] PostPinRequest request)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var post = await db.Posts
|
||||||
|
.Where(e => e.Id == id)
|
||||||
|
.Include(e => e.Publisher)
|
||||||
|
.Include(e => e.RepliedPost)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (post is null) return NotFound();
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
if (!await pub.IsMemberWithRole(post.PublisherId, accountId, PublisherMemberRole.Editor))
|
||||||
|
return StatusCode(403, "You are not an editor of this publisher");
|
||||||
|
|
||||||
|
if (request.Mode == PostPinMode.RealmPage && post.RealmId != null)
|
||||||
|
{
|
||||||
|
if (!await rs.IsMemberWithRole(post.RealmId.Value, accountId, RealmMemberRole.Moderator))
|
||||||
|
return StatusCode(403, "You are not a moderator of this realm");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ps.PinPostAsync(post, currentUser, request.Mode);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException err)
|
||||||
|
{
|
||||||
|
return BadRequest(err.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = als.CreateActionLogAsync(new CreateActionLogRequest
|
||||||
|
{
|
||||||
|
Action = ActionLogType.PostPin,
|
||||||
|
Meta =
|
||||||
|
{
|
||||||
|
{ "post_id", Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString()) },
|
||||||
|
{ "mode", Google.Protobuf.WellKnownTypes.Value.ForString(request.Mode.ToString()) }
|
||||||
|
},
|
||||||
|
AccountId = currentUser.Id.ToString(),
|
||||||
|
UserAgent = Request.Headers.UserAgent,
|
||||||
|
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(post);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}/pin")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<Post>> UnpinPost(Guid id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var post = await db.Posts
|
||||||
|
.Where(e => e.Id == id)
|
||||||
|
.Include(e => e.Publisher)
|
||||||
|
.Include(e => e.RepliedPost)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (post is null) return NotFound();
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
if (!await pub.IsMemberWithRole(post.PublisherId, accountId, PublisherMemberRole.Editor))
|
||||||
|
return StatusCode(403, "You are not an editor of this publisher");
|
||||||
|
|
||||||
|
if (post is { PinMode: PostPinMode.RealmPage, RealmId: not null })
|
||||||
|
{
|
||||||
|
if (!await rs.IsMemberWithRole(post.RealmId.Value, accountId, RealmMemberRole.Moderator))
|
||||||
|
return StatusCode(403, "You are not a moderator of this realm");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ps.UnpinPostAsync(post, currentUser);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException err)
|
||||||
|
{
|
||||||
|
return BadRequest(err.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = als.CreateActionLogAsync(new CreateActionLogRequest
|
||||||
|
{
|
||||||
|
Action = ActionLogType.PostUnpin,
|
||||||
|
Meta =
|
||||||
|
{
|
||||||
|
{ "post_id", Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString()) }
|
||||||
|
},
|
||||||
|
AccountId = currentUser.Id.ToString(),
|
||||||
|
UserAgent = Request.Headers.UserAgent,
|
||||||
|
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(post);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPatch("{id:guid}")]
|
[HttpPatch("{id:guid}")]
|
||||||
public async Task<ActionResult<Post>> UpdatePost(
|
public async Task<ActionResult<Post>> UpdatePost(
|
||||||
Guid id,
|
Guid id,
|
||||||
@@ -697,4 +823,4 @@ public class PostController(
|
|||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -25,6 +25,7 @@ public partial class PostService(
|
|||||||
FileService.FileServiceClient files,
|
FileService.FileServiceClient files,
|
||||||
FileReferenceService.FileReferenceServiceClient fileRefs,
|
FileReferenceService.FileReferenceServiceClient fileRefs,
|
||||||
PollService polls,
|
PollService polls,
|
||||||
|
Publisher.PublisherService ps,
|
||||||
WebReaderService reader
|
WebReaderService reader
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -418,6 +419,56 @@ public partial class PostService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Post> PinPostAsync(Post post, Account currentUser, PostPinMode pinMode)
|
||||||
|
{
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
if (post.RepliedPostId != null)
|
||||||
|
{
|
||||||
|
if (pinMode != PostPinMode.ReplyPage) throw new InvalidOperationException("Replies can only be pinned in the reply page.");
|
||||||
|
if (post.RepliedPost == null) throw new ArgumentNullException(nameof(post.RepliedPost));
|
||||||
|
|
||||||
|
if (!await ps.IsMemberWithRole(post.RepliedPost.PublisherId, accountId, Publisher.PublisherMemberRole.Editor))
|
||||||
|
throw new InvalidOperationException("Only editors of original post can pin replies.");
|
||||||
|
|
||||||
|
post.PinMode = pinMode;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!await ps.IsMemberWithRole(post.PublisherId, accountId, Publisher.PublisherMemberRole.Editor))
|
||||||
|
throw new InvalidOperationException("Only editors can pin replies.");
|
||||||
|
|
||||||
|
post.PinMode = pinMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Update(post);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return post;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Post> UnpinPostAsync(Post post, Account currentUser)
|
||||||
|
{
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
if (post.RepliedPostId != null)
|
||||||
|
{
|
||||||
|
if (post.RepliedPost == null) throw new ArgumentNullException(nameof(post.RepliedPost));
|
||||||
|
|
||||||
|
if (!await ps.IsMemberWithRole(post.RepliedPost.PublisherId, accountId, Publisher.PublisherMemberRole.Editor))
|
||||||
|
throw new InvalidOperationException("Only editors of original post can unpin replies.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!await ps.IsMemberWithRole(post.PublisherId, accountId, Publisher.PublisherMemberRole.Editor))
|
||||||
|
throw new InvalidOperationException("Only editors can unpin posts.");
|
||||||
|
}
|
||||||
|
|
||||||
|
post.PinMode = null;
|
||||||
|
db.Update(post);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return post;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Calculate the total number of votes for a post.
|
/// Calculate the total number of votes for a post.
|
||||||
/// This function helps you save the new reactions.
|
/// This function helps you save the new reactions.
|
||||||
@@ -770,7 +821,6 @@ public partial class PostService(
|
|||||||
var reactSocialPoints = await db.PostReactions
|
var reactSocialPoints = await db.PostReactions
|
||||||
.Include(e => e.Post)
|
.Include(e => e.Post)
|
||||||
.Where(e => e.Post.Visibility == PostVisibility.Public)
|
.Where(e => e.Post.Visibility == PostVisibility.Public)
|
||||||
.Where(e => e.CreatedAt >= periodStart && e.CreatedAt < periodEnd)
|
|
||||||
.Where(e => e.Post.CreatedAt >= periodStart && e.Post.CreatedAt < periodEnd)
|
.Where(e => e.Post.CreatedAt >= periodStart && e.Post.CreatedAt < periodEnd)
|
||||||
.GroupBy(e => e.PostId)
|
.GroupBy(e => e.PostId)
|
||||||
.Select(e => new
|
.Select(e => new
|
||||||
@@ -784,16 +834,27 @@ public partial class PostService(
|
|||||||
|
|
||||||
featuredIds = reactSocialPoints.Select(e => e.Key).ToList();
|
featuredIds = reactSocialPoints.Select(e => e.Key).ToList();
|
||||||
|
|
||||||
await cache.SetAsync(FeaturedPostCacheKey, featuredIds, TimeSpan.FromHours(24));
|
await cache.SetAsync(FeaturedPostCacheKey, featuredIds, TimeSpan.FromHours(4));
|
||||||
|
|
||||||
// Create featured record
|
// Create featured record
|
||||||
var records = reactSocialPoints.Select(e => new PostFeaturedRecord
|
var existingFeaturedPostIds = await db.PostFeaturedRecords
|
||||||
|
.Where(r => featuredIds.Contains(r.PostId))
|
||||||
|
.Select(r => r.PostId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var records = reactSocialPoints
|
||||||
|
.Where(p => !existingFeaturedPostIds.Contains(p.Key))
|
||||||
|
.Select(e => new PostFeaturedRecord
|
||||||
|
{
|
||||||
|
PostId = e.Key,
|
||||||
|
SocialCredits = e.Value
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
if (records.Any())
|
||||||
{
|
{
|
||||||
PostId = e.Key,
|
db.PostFeaturedRecords.AddRange(records);
|
||||||
SocialCredits = e.Value
|
await db.SaveChangesAsync();
|
||||||
}).ToList();
|
}
|
||||||
db.PostFeaturedRecords.AddRange(records);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var posts = await db.Posts
|
var posts = await db.Posts
|
||||||
|
@@ -55,14 +55,7 @@ public class PublisherSubscriptionService(
|
|||||||
return 0;
|
return 0;
|
||||||
if (post.Visibility != PostVisibility.Public)
|
if (post.Visibility != PostVisibility.Public)
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
var subscribers = await db.PublisherSubscriptions
|
|
||||||
.Where(p => p.PublisherId == post.PublisherId &&
|
|
||||||
p.Status == PublisherSubscriptionStatus.Active)
|
|
||||||
.ToListAsync();
|
|
||||||
if (subscribers.Count == 0)
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
// Create notification data
|
// Create notification data
|
||||||
var (title, message) = ps.ChopPostForNotification(post);
|
var (title, message) = ps.ChopPostForNotification(post);
|
||||||
|
|
||||||
@@ -73,9 +66,38 @@ public class PublisherSubscriptionService(
|
|||||||
{ "publisher_id", post.Publisher.Id.ToString() }
|
{ "publisher_id", post.Publisher.Id.ToString() }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Gather subscribers
|
||||||
|
var subscribers = await db.PublisherSubscriptions
|
||||||
|
.Where(p => p.PublisherId == post.PublisherId &&
|
||||||
|
p.Status == PublisherSubscriptionStatus.Active)
|
||||||
|
.ToListAsync();
|
||||||
|
if (subscribers.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
List<PostCategorySubscription> categorySubscribers = [];
|
||||||
|
if (post.Categories.Count > 0)
|
||||||
|
{
|
||||||
|
var categoryIds = post.Categories.Select(x => x.Id).ToList();
|
||||||
|
var subs = await db.PostCategorySubscriptions
|
||||||
|
.Where(s => s.CategoryId != null && categoryIds.Contains(s.CategoryId.Value))
|
||||||
|
.ToListAsync();
|
||||||
|
categorySubscribers.AddRange(subs);
|
||||||
|
}
|
||||||
|
if (post.Tags.Count > 0)
|
||||||
|
{
|
||||||
|
var tagIds = post.Tags.Select(x => x.Id).ToList();
|
||||||
|
var subs = await db.PostCategorySubscriptions
|
||||||
|
.Where(s => s.TagId != null && tagIds.Contains(s.TagId.Value))
|
||||||
|
.ToListAsync();
|
||||||
|
categorySubscribers.AddRange(subs);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<string> requestAccountIds = [];
|
||||||
|
requestAccountIds.AddRange(subscribers.Select(x => x.AccountId.ToString()));
|
||||||
|
requestAccountIds.AddRange(categorySubscribers.Select(x => x.AccountId.ToString()));
|
||||||
|
|
||||||
var queryRequest = new GetAccountBatchRequest();
|
var queryRequest = new GetAccountBatchRequest();
|
||||||
queryRequest.Id.AddRange(subscribers.DistinctBy(s => s.AccountId).Select(m => m.AccountId.ToString()));
|
queryRequest.Id.AddRange(requestAccountIds.Distinct());
|
||||||
var queryResponse = await accounts.GetAccountBatchAsync(queryRequest);
|
var queryResponse = await accounts.GetAccountBatchAsync(queryRequest);
|
||||||
|
|
||||||
// Notify each subscriber
|
// Notify each subscriber
|
||||||
|
@@ -245,14 +245,14 @@ public class RealmController(
|
|||||||
members.Select(m => m.AccountId).ToList()
|
members.Select(m => m.AccountId).ToList()
|
||||||
);
|
);
|
||||||
|
|
||||||
members = members
|
members = members
|
||||||
.Select(m =>
|
.Select(m =>
|
||||||
{
|
{
|
||||||
m.Status = memberStatuses.TryGetValue(m.AccountId, out var s) ? s : null;
|
m.Status = memberStatuses.TryGetValue(m.AccountId, out var s) ? s : null;
|
||||||
return m;
|
return m;
|
||||||
})
|
})
|
||||||
.OrderByDescending(m => m.Status?.IsOnline ?? false)
|
.OrderByDescending(m => m.Status?.IsOnline ?? false)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var total = members.Count;
|
var total = members.Count;
|
||||||
Response.Headers.Append("X-Total", total.ToString());
|
Response.Headers.Append("X-Total", total.ToString());
|
||||||
@@ -260,7 +260,7 @@ public class RealmController(
|
|||||||
var result = members.Skip(offset).Take(take).ToList();
|
var result = members.Skip(offset).Take(take).ToList();
|
||||||
|
|
||||||
members = await rs.LoadMemberAccounts(result);
|
members = await rs.LoadMemberAccounts(result);
|
||||||
|
|
||||||
return Ok(members.Where(m => m.Account is not null).ToList());
|
return Ok(members.Where(m => m.Account is not null).ToList());
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -742,4 +742,4 @@ public class RealmController(
|
|||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
using DysonNetwork.Shared;
|
using DysonNetwork.Shared;
|
||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using DysonNetwork.Shared.Registry;
|
using DysonNetwork.Shared.Registry;
|
||||||
using DysonNetwork.Sphere.Localization;
|
using DysonNetwork.Sphere.Localization;
|
||||||
@@ -12,9 +13,31 @@ public class RealmService(
|
|||||||
PusherService.PusherServiceClient pusher,
|
PusherService.PusherServiceClient pusher,
|
||||||
AccountService.AccountServiceClient accounts,
|
AccountService.AccountServiceClient accounts,
|
||||||
IStringLocalizer<NotificationResource> localizer,
|
IStringLocalizer<NotificationResource> localizer,
|
||||||
AccountClientHelper accountsHelper
|
AccountClientHelper accountsHelper,
|
||||||
|
ICacheService cache
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
private const string CacheKeyPrefix = "account:realms:";
|
||||||
|
|
||||||
|
public async Task<List<Guid>> GetUserRealms(Guid accountId)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{CacheKeyPrefix}{accountId}";
|
||||||
|
var (found, cachedRealms) = await cache.GetAsyncWithStatus<List<Guid>>(cacheKey);
|
||||||
|
if (found && cachedRealms != null)
|
||||||
|
return cachedRealms;
|
||||||
|
|
||||||
|
var realms = await db.RealmMembers
|
||||||
|
.Include(m => m.Realm)
|
||||||
|
.Where(m => m.AccountId == accountId)
|
||||||
|
.Select(m => m.Realm!.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// Cache the result for 5 minutes
|
||||||
|
await cache.SetAsync(cacheKey, realms, TimeSpan.FromMinutes(5));
|
||||||
|
|
||||||
|
return realms;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task SendInviteNotify(RealmMember member)
|
public async Task SendInviteNotify(RealmMember member)
|
||||||
{
|
{
|
||||||
var account = await accounts.GetAccountAsync(new GetAccountRequest { Id = member.AccountId.ToString() });
|
var account = await accounts.GetAccountAsync(new GetAccountRequest { Id = member.AccountId.ToString() });
|
||||||
|
@@ -31,7 +31,7 @@ public class StickerController(
|
|||||||
return NotFound("Sticker pack not found");
|
return NotFound("Sticker pack not found");
|
||||||
|
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
if (!await ps.IsMemberWithRole(accountId, pack.PublisherId, requiredRole))
|
if (!await ps.IsMemberWithRole(pack.PublisherId, accountId, requiredRole))
|
||||||
return StatusCode(403, "You are not a member of this publisher");
|
return StatusCode(403, "You are not a member of this publisher");
|
||||||
|
|
||||||
return Ok();
|
return Ok();
|
||||||
@@ -75,6 +75,7 @@ public class StickerController(
|
|||||||
var packs = await queryable
|
var packs = await queryable
|
||||||
.Skip(offset)
|
.Skip(offset)
|
||||||
.Take(take)
|
.Take(take)
|
||||||
|
.Include(e => e.Stickers.OrderByDescending(s => s.CreatedAt).Take(8))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
Response.Headers["X-Total"] = totalCount.ToString();
|
Response.Headers["X-Total"] = totalCount.ToString();
|
||||||
|
@@ -123,6 +123,7 @@ public class WebFeedPublicController(
|
|||||||
|
|
||||||
var totalCount = await query.CountAsync();
|
var totalCount = await query.CountAsync();
|
||||||
var subscriptions = await query
|
var subscriptions = await query
|
||||||
|
.Select(q => q.Feed)
|
||||||
.Skip(offset)
|
.Skip(offset)
|
||||||
.Take(take)
|
.Take(take)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -234,15 +235,9 @@ public class WebFeedPublicController(
|
|||||||
|
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
// Get IDs of already subscribed feeds
|
|
||||||
var subscribedFeedIds = await db.WebFeedSubscriptions
|
|
||||||
.Where(s => s.AccountId == accountId)
|
|
||||||
.Select(s => s.FeedId)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
var feedsQuery = db.WebFeeds
|
var feedsQuery = db.WebFeeds
|
||||||
.Include(f => f.Publisher)
|
.Include(f => f.Publisher)
|
||||||
.Where(f => !subscribedFeedIds.Contains(f.Id))
|
.OrderByDescending(f => f.CreatedAt)
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
|
|
||||||
// Apply search filter if query is provided
|
// Apply search filter if query is provided
|
||||||
@@ -255,9 +250,6 @@ public class WebFeedPublicController(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Order by most recently created first
|
|
||||||
feedsQuery = feedsQuery.OrderByDescending(f => f.CreatedAt);
|
|
||||||
|
|
||||||
var totalCount = await feedsQuery.CountAsync();
|
var totalCount = await feedsQuery.CountAsync();
|
||||||
var feeds = await feedsQuery
|
var feeds = await feedsQuery
|
||||||
.Skip(offset)
|
.Skip(offset)
|
||||||
|
@@ -146,6 +146,7 @@
|
|||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATusDiskStore_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003F1c_003F21999acd_003FTusDiskStore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATusDiskStore_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003F1c_003F21999acd_003FTusDiskStore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUri_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5d2c480da9be415dab9be535bb6d08713cc00_003Fd0_003Fffc36a51_003FUri_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUri_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5d2c480da9be415dab9be535bb6d08713cc00_003Fd0_003Fffc36a51_003FUri_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AValidationContext_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003F6b_003F741ceebe_003FValidationContext_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AValidationContext_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003F6b_003F741ceebe_003FValidationContext_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AWebSocketAcceptContext_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F16e10e91b7834a87b2f3f4f30bfada3ee000_003Fd0_003F44ef97dc_003FWebSocketAcceptContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AWebSocketCloseStatus_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F82dcad099d814e3facee3a7c5e19928a3ae00_003F67_003F9e63fab4_003FWebSocketCloseStatus_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AWebSocketCloseStatus_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F82dcad099d814e3facee3a7c5e19928a3ae00_003F67_003F9e63fab4_003FWebSocketCloseStatus_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/Environment/AssemblyExplorer/XmlDocument/@EntryValue"><AssemblyExplorer>
|
<s:String x:Key="/Default/Environment/AssemblyExplorer/XmlDocument/@EntryValue"><AssemblyExplorer>
|
||||||
<Assembly Path="/opt/homebrew/Cellar/dotnet/9.0.6/libexec/packs/Microsoft.AspNetCore.App.Ref/9.0.6/ref/net9.0/Microsoft.AspNetCore.RateLimiting.dll" />
|
<Assembly Path="/opt/homebrew/Cellar/dotnet/9.0.6/libexec/packs/Microsoft.AspNetCore.App.Ref/9.0.6/ref/net9.0/Microsoft.AspNetCore.RateLimiting.dll" />
|
||||||
|
Reference in New Issue
Block a user